diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx
index 968401c1..bf432c31 100644
--- a/src/app/(dashboard)/peer/page.tsx
+++ b/src/app/(dashboard)/peer/page.tsx
@@ -40,6 +40,8 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
+ RadioTowerIcon,
+ TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
@@ -60,6 +62,7 @@ import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
+import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
@@ -326,6 +329,13 @@ const PeerOverviewTabs = () => {
Accessible Peers
)}
+
+ {peer?.id && permission.peers.delete && (
+
+
+ Remote Jobs
+
+ )}
{permission.routes.read && (
@@ -339,6 +349,11 @@ const PeerOverviewTabs = () => {
)}
+ {peer.id && permission.peers.delete && (
+
+
+
+ )}
);
};
@@ -526,9 +541,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
peer.connected
? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
- " (" +
- dayjs().to(peer.last_seen) +
- ")"
+ " (" +
+ dayjs().to(peer.last_seen) +
+ ")"
}
/>
diff --git a/src/components/TooltipListItem.tsx b/src/components/TooltipListItem.tsx
new file mode 100644
index 00000000..3c5422af
--- /dev/null
+++ b/src/components/TooltipListItem.tsx
@@ -0,0 +1,36 @@
+import { cn } from "@utils/helpers";
+import * as React from "react";
+
+export const TooltipListItem = ({
+ icon,
+ label,
+ value,
+ className,
+ labelClassName,
+}: {
+ icon?: React.ReactNode;
+ label: string;
+ value: string | React.ReactNode;
+ className?: string;
+ labelClassName?: string;
+}) => {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ );
+};
diff --git a/src/interfaces/Job.ts b/src/interfaces/Job.ts
new file mode 100644
index 00000000..a89c65b8
--- /dev/null
+++ b/src/interfaces/Job.ts
@@ -0,0 +1,23 @@
+export interface Job {
+ id: string;
+ triggered_by: string;
+ completed_at: Date | null;
+ created_at: Date;
+ failed_reason: string | null;
+ workload: Workload;
+ status: "pending" | "succeeded" | "failed";
+}
+
+export interface Workload {
+ type: "bundle";
+ parameters: BundleJobParameters;
+ result: string | null;
+}
+
+// Parameters for bundle job
+export interface BundleJobParameters {
+ anonymize: boolean;
+ bundle_for: boolean;
+ bundle_for_time: number;
+ log_file_count: number;
+}
diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx
index 295389bd..613a82a1 100644
--- a/src/modules/activity/ActivityDescription.tsx
+++ b/src/modules/activity/ActivityDescription.tsx
@@ -701,6 +701,16 @@ export default function ActivityDescription({ event }: Props) {
);
+ /**
+ * Jobs
+ */
+
+ if (event.activity_code == "peer.job.create")
+ return (
+ Remote job {m.job_type} created for peer {m.for_peer_name}
+
+ )
+
if (event.activity_code == "account.settings.extra.flow.group.remove")
return (
diff --git a/src/modules/jobs/CreateDebugJobModal.tsx b/src/modules/jobs/CreateDebugJobModal.tsx
new file mode 100644
index 00000000..683e0573
--- /dev/null
+++ b/src/modules/jobs/CreateDebugJobModal.tsx
@@ -0,0 +1,194 @@
+import {
+ AlarmClock,
+ BugPlay,
+ FileText,
+ PlusCircle,
+ Shield,
+} from "lucide-react";
+import { useMemo, useState } from "react";
+import { useSWRConfig } from "swr";
+import Button from "@/components/Button";
+import FancyToggleSwitch from "@/components/FancyToggleSwitch";
+import HelpText from "@/components/HelpText";
+import { Input } from "@/components/Input";
+import { Label } from "@/components/Label";
+import {
+ ModalClose,
+ ModalContent,
+ ModalFooter,
+} from "@/components/modal/Modal";
+import ModalHeader from "@/components/modal/ModalHeader";
+import { notify } from "@/components/Notification";
+import Separator from "@/components/Separator";
+import { Workload } from "@/interfaces/Job";
+import { useApiCall } from "@/utils/api";
+
+type Props = {
+ peerID: string;
+ onSuccess: () => void;
+};
+
+export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
+ const jobRequest = useApiCall
(`/peers/${peerID}/jobs`, true);
+ const { mutate } = useSWRConfig();
+
+ const [bundleForTimeEnabled, setBundleForTimeEnabled] = useState(false);
+ const [bundleForTime, setBundleForTime] = useState("");
+ const [logFileCount, setLogFileCount] = useState("10");
+ const [anonymize, setAnonymize] = useState(false);
+
+ const isValid = useMemo(() => {
+ let validBundleFor = true;
+ let validLogFileCount = true;
+
+ const logFileCountNumber = Number(logFileCount);
+ const bundleForTimeNumber = Number(bundleForTime);
+
+ if (bundleForTime) {
+ validBundleFor = bundleForTimeNumber >= 1 && bundleForTimeNumber <= 5;
+ }
+
+ validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
+
+ return validLogFileCount && validBundleFor;
+ }, [bundleForTime, logFileCount]);
+
+ const createDebugJob = async () => {
+ notify({
+ title: "Create Debug Job",
+ description: "Debug job triggered successfully.",
+ loadingMessage: "Creating job...",
+ promise: jobRequest
+ .post({
+ workload: {
+ type: "bundle",
+ parameters: {
+ anonymize,
+ bundle_for: bundleForTimeEnabled,
+ bundle_for_time: bundleForTimeEnabled
+ ? Number(bundleForTime)
+ : undefined,
+ log_file_count: logFileCount ? Number(logFileCount) : 10,
+ },
+ },
+ })
+ .then((job) => {
+ mutate(`/peers/${peerID}/jobs`);
+ onSuccess();
+ return job;
+ }),
+ });
+ };
+ return (
+
+ }
+ title="Debug Bundle"
+ description="Generate a debug bundle on this peer with logs and diagnostics. Useful for troubleshooting without CLI access."
+ color="netbird"
+ />
+
+
+
+ {/* Log File Count */}
+
+
+ Log File Count
+
+ Sets the limit for how many individual log files will be included
+ in the debug bundle.
+
+
+
+
setLogFileCount(e.target.value)}
+ maxWidthClass="w-[220px]"
+ customPrefix={
}
+ customSuffix="File(s)"
+ />
+
+ {/* Bundle Duration */}
+
+
{
+ setBundleForTimeEnabled(enabled);
+ if (!enabled) {
+ setBundleForTime("");
+ } else {
+ setBundleForTime("2");
+ }
+ }}
+ label={
+ <>
+
+ Enable Bundle Duration
+ >
+ }
+ helpText="When enabled, allows you to specify a time period for log collection before generating the debug bundle."
+ />
+
+ {bundleForTimeEnabled && (
+
+
+ Duration
+
+ Time period for which logs should be collected before creating
+ the debug bundle.
+
+
+
+
setBundleForTime(e.target.value)}
+ maxWidthClass="w-[220px]"
+ placeholder={"2"}
+ customPrefix={
+
+ }
+ customSuffix="Minute(s)"
+ />
+
+ )}
+
+
+ {/* Anonymize Data */}
+
+
+ Anonymize Log Data
+ >
+ }
+ helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
+ />
+
+
+
+
+
+ Cancel
+
+
+
+ Create Debug Bundle
+
+
+
+
+ );
+}
diff --git a/src/modules/jobs/table/JobOutputCell.tsx b/src/modules/jobs/table/JobOutputCell.tsx
new file mode 100644
index 00000000..402c0d97
--- /dev/null
+++ b/src/modules/jobs/table/JobOutputCell.tsx
@@ -0,0 +1,60 @@
+import Badge from "@components/Badge";
+import CopyToClipboardText from "@components/CopyToClipboardText";
+import FullTooltip from "@components/FullTooltip";
+import { Input } from "@components/Input";
+import * as React from "react";
+import { Job } from "@/interfaces/Job";
+import EmptyRow from "@/modules/common-table-rows/EmptyRow";
+
+type Props = {
+ job: Job;
+};
+
+export const JobOutputCell = ({ job }: Props) => {
+ if (job.status === "succeeded" && job.workload.result) {
+ return (
+
+ {Object.entries(job.workload.result).map(([key, value]) => (
+
+
+ {key.replaceAll("_", " ")}
+
+
+
+
+
+ {typeof value === "boolean"
+ ? value
+ ? "Yes"
+ : "No"
+ : String(value)}
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (job.status === "failed" && job.failed_reason) {
+ return (
+
+ {job.failed_reason}
+ }
+ >
+
+ {job.failed_reason}
+
+
+
+ );
+ }
+
+ return ;
+};
diff --git a/src/modules/jobs/table/JobParametersCell.tsx b/src/modules/jobs/table/JobParametersCell.tsx
new file mode 100644
index 00000000..7f536dcb
--- /dev/null
+++ b/src/modules/jobs/table/JobParametersCell.tsx
@@ -0,0 +1,56 @@
+import Badge from "@components/Badge";
+import FullTooltip from "@components/FullTooltip";
+import { TooltipListItem } from "@components/TooltipListItem";
+import { InfoIcon } from "lucide-react";
+import React from "react";
+import EmptyRow from "@/modules/common-table-rows/EmptyRow";
+
+export const JobParametersCell = ({ parameters }: { parameters: any }) => {
+ if (!parameters || Object.keys(parameters).length === 0) {
+ return ;
+ }
+
+ const entries = Object.entries(parameters);
+
+ return (
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+ {entries.map(([key, value]) => (
+
+ ))}
+
+ }
+ >
+
+
+ {entries.length} Parameters
+
+
+ );
+};
diff --git a/src/modules/jobs/table/JobStatusCell.tsx b/src/modules/jobs/table/JobStatusCell.tsx
new file mode 100644
index 00000000..bc140c80
--- /dev/null
+++ b/src/modules/jobs/table/JobStatusCell.tsx
@@ -0,0 +1,30 @@
+import { cn } from "@utils/helpers";
+import React from "react";
+import { Job } from "@/interfaces/Job";
+
+type Props = {
+ job: Job;
+};
+
+export default function JobStatusCell({ job }: Readonly) {
+ const status = job.status;
+
+ return (
+
+
+ {status == "pending" && "Pending"}
+ {status == "failed" && "Failed"}
+ {status == "succeeded" && "Completed"}
+
+ );
+}
diff --git a/src/modules/jobs/table/JobTypeCell.tsx b/src/modules/jobs/table/JobTypeCell.tsx
new file mode 100644
index 00000000..3d0dd247
--- /dev/null
+++ b/src/modules/jobs/table/JobTypeCell.tsx
@@ -0,0 +1,22 @@
+import { BugIcon } from "lucide-react";
+import * as React from "react";
+import { Job } from "@/interfaces/Job";
+import EmptyRow from "@/modules/common-table-rows/EmptyRow";
+
+type Props = {
+ job: Job;
+};
+export const JobTypeCell = ({ job }: Props) => {
+ if (job.workload.type === "bundle") {
+ return (
+
+
+ Debug Bundle
+
+ );
+ }
+
+ return ;
+};
diff --git a/src/modules/jobs/table/PeerRemoteJobsTable.tsx b/src/modules/jobs/table/PeerRemoteJobsTable.tsx
new file mode 100644
index 00000000..652b2195
--- /dev/null
+++ b/src/modules/jobs/table/PeerRemoteJobsTable.tsx
@@ -0,0 +1,141 @@
+import Card from "@components/Card";
+import { DataTable } from "@components/table/DataTable";
+import DataTableHeader from "@components/table/DataTableHeader";
+import NoResults from "@components/ui/NoResults";
+import { ColumnDef, SortingState } from "@tanstack/react-table";
+import { ClipboardList } from "lucide-react";
+import React, { useState } from "react";
+import { useSWRConfig } from "swr";
+import DataTableRefreshButton from "@/components/table/DataTableRefreshButton";
+import { DataTableRowsPerPage } from "@/components/table/DataTableRowsPerPage";
+import { Job } from "@/interfaces/Job";
+import EmptyRow from "@/modules/common-table-rows/EmptyRow";
+import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
+import { JobOutputCell } from "@/modules/jobs/table/JobOutputCell";
+import { JobParametersCell } from "@/modules/jobs/table/JobParametersCell";
+import JobStatusCell from "@/modules/jobs/table/JobStatusCell";
+import { JobTypeCell } from "@/modules/jobs/table/JobTypeCell";
+import { RemoteJobDropdownButton } from "@/modules/peer/RemoteJobDropdownButton";
+
+type Props = {
+ jobs?: Job[];
+ peerID: string;
+ isLoading: boolean;
+ headingTarget?: HTMLHeadingElement | null;
+};
+
+const PeerRemoteJobsColumns: ColumnDef[] = [
+ {
+ accessorKey: "Type",
+ header: ({ column }) => (
+ Type
+ ),
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "CreatedAt",
+ header: ({ column }) => (
+ Created
+ ),
+ sortingFn: "datetime",
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: "Status",
+ header: ({ column }) => (
+ Status
+ ),
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "CompletedAt",
+ header: ({ column }) => (
+ Completed
+ ),
+ sortingFn: "datetime",
+ cell: ({ row }) =>
+ row.original.completed_at ? (
+
+ ) : (
+
+ ),
+ },
+ {
+ accessorKey: "Parameters",
+ header: ({ column }) => (
+ Parameters
+ ),
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ id: "ResultOrReason",
+ header: ({ column }) => (
+ Output
+ ),
+ cell: ({ row }) => ,
+ },
+];
+
+export default function PeerRemoteJobsTable({
+ jobs,
+ isLoading,
+ headingTarget,
+ peerID,
+}: Props) {
+ const { mutate } = useSWRConfig();
+
+ const [sorting, setSorting] = useState([
+ { id: "CreatedAt", desc: true },
+ ]);
+
+ return (
+ (
+
+
+
+ )}
+ wrapperComponent={Card}
+ wrapperProps={{ className: "mt-6 w-full" }}
+ headingTarget={headingTarget}
+ useRowId={true}
+ sorting={sorting}
+ setSorting={setSorting}
+ minimal={true}
+ showSearchAndFilters={true}
+ inset={false}
+ tableClassName="mt-0"
+ text="Jobs"
+ columns={PeerRemoteJobsColumns}
+ keepStateInLocalStorage={false}
+ data={jobs}
+ searchPlaceholder="Search by type, status, or parameters..."
+ isLoading={isLoading}
+ getStartedCard={
+ }
+ />
+ }
+ paginationPaddingClassName="px-0 pt-8"
+ >
+ {(table) => (
+ <>
+
+ {
+ mutate(`/peers/${peerID}/jobs`).then();
+ }}
+ />
+ >
+ )}
+
+ );
+}
diff --git a/src/modules/peer/AddRouteDropdownButton.tsx b/src/modules/peer/AddRouteDropdownButton.tsx
index 7fedb5bd..72407f58 100644
--- a/src/modules/peer/AddRouteDropdownButton.tsx
+++ b/src/modules/peer/AddRouteDropdownButton.tsx
@@ -58,6 +58,7 @@ export default function AddRouteDropdownButton() {
icon={ }
color={"green"}
margin={""}
+ size={"small"}
/>
New Network Route
@@ -79,6 +80,7 @@ export default function AddRouteDropdownButton() {
}
color={"netbird"}
margin={""}
+ size={"small"}
/>
Existing Network
diff --git a/src/modules/peer/PeerRemoteJobsSection.tsx b/src/modules/peer/PeerRemoteJobsSection.tsx
new file mode 100644
index 00000000..ac448c14
--- /dev/null
+++ b/src/modules/peer/PeerRemoteJobsSection.tsx
@@ -0,0 +1,64 @@
+import { ExternalLinkIcon } from "lucide-react";
+import React, { lazy, Suspense } from "react";
+import InlineLink from "@/components/InlineLink";
+import Paragraph from "@/components/Paragraph";
+import SkeletonTable, {
+ SkeletonTableHeader,
+} from "@/components/skeletons/SkeletonTable";
+import { usePortalElement } from "@/hooks/usePortalElement";
+import { Job } from "@/interfaces/Job";
+import useFetchApi from "@/utils/api";
+
+const PeerRemoteJobsTable = lazy(
+ () => import("@/modules/jobs/table/PeerRemoteJobsTable"),
+);
+type Props = {
+ peerID: string;
+};
+
+export const PeerRemoteJobsSection = ({ peerID }: Props) => {
+ const { data: jobs, isLoading } = useFetchApi
(`/peers/${peerID}/jobs`);
+ const { ref: headingRef, portalTarget } =
+ usePortalElement();
+
+ return (
+
+
+
+
+
Remote Jobs
+
+ Remotely trigger actions such as debug bundles or other tasks on
+ this peer, without requiring CLI access.
+
+
+ Learn more about{" "}
+
+ Remote Jobs
+
+ in our documentation.
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+ );
+};
diff --git a/src/modules/peer/RemoteJobDropdownButton.tsx b/src/modules/peer/RemoteJobDropdownButton.tsx
new file mode 100644
index 00000000..5c10d9a1
--- /dev/null
+++ b/src/modules/peer/RemoteJobDropdownButton.tsx
@@ -0,0 +1,87 @@
+import Button from "@components/Button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@components/DropdownMenu";
+import { Modal } from "@components/modal/Modal";
+import SquareIcon from "@components/SquareIcon";
+import { BugPlay, ChevronDown } from "lucide-react";
+import React, { useState } from "react";
+import { usePeer } from "@/contexts/PeerProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
+import { CreateDebugJobModalContent } from "../jobs/CreateDebugJobModal";
+
+export const RemoteJobDropdownButton = () => {
+ const [modal, setModal] = useState(false);
+ const { peer } = usePeer();
+ const { permission } = usePermissions();
+ const isConnected = peer?.connected;
+ const disabled = !permission.peers.delete;
+
+ return (
+ <>
+
+ setModal(false)}
+ />
+
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+
+ Run Remote Job
+
+
+
+
+ {!isConnected && (
+ <>
+
+
+ Peer{" "}
+ {peer.name} {" "}
+ is currently offline. Please connect the peer to run remote
+ jobs.
+
+
+
+ >
+ )}
+
+ setModal(true)}
+ disabled={disabled || !isConnected}
+ >
+
+
}
+ margin={""}
+ size={"small"}
+ />
+
+
Debug Bundle
+
+ Collect debug information for troubleshooting
+
+
+
+
+
+
+ >
+ );
+};