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 */} +
+
+ + + 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 && ( +
+
+ + + 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." + /> +
+ + +
+ + + + +
+
+
+ ); +} 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(); + }} + > + + + + {!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 +
+
+
+
+
+
+ + ); +};