Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/app/(dashboard)/peer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
RadioTowerIcon,
TerminalSquare,
TimerResetIcon,
} from "lucide-react";
Expand All @@ -65,6 +66,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";

export default function PeerPage() {
const queryParameter = useSearchParams();
Expand Down Expand Up @@ -397,6 +399,13 @@ const PeerOverviewTabs = () => {
Accessible Peers
</TabsTrigger>
)}

{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>

{permission.routes.read && (
Expand All @@ -410,6 +419,11 @@ const PeerOverviewTabs = () => {
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
Expand Down Expand Up @@ -580,9 +594,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) +
")"
}
/>

Expand Down
36 changes: 36 additions & 0 deletions src/components/TooltipListItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
>
<div
className={cn(
"flex items-center gap-2 text-nb-gray-100 font-medium",
labelClassName,
)}
>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
);
};
23 changes: 23 additions & 0 deletions src/interfaces/Job.ts
Original file line number Diff line number Diff line change
@@ -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;
}
194 changes: 194 additions & 0 deletions src/modules/jobs/CreateDebugJobModal.tsx
Original file line number Diff line number Diff line change
@@ -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<Workload>(`/peers/${peerID}/jobs`, true);
const { mutate } = useSWRConfig();

const [bundleForTimeEnabled, setBundleForTimeEnabled] = useState(false);
const [bundleForTime, setBundleForTime] = useState<string>("");
const [logFileCount, setLogFileCount] = useState<string>("10");
const [anonymize, setAnonymize] = useState<boolean>(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 (
<ModalContent maxWidthClass="max-w-xl">
<ModalHeader
icon={<BugPlay size={20} />}
title="Debug Bundle"
description="Generate a debug bundle on this peer with logs and diagnostics. Useful for troubleshooting without CLI access."
color="netbird"
/>

<Separator />
<div className={"px-8 py-6 flex flex-col gap-4"}>
{/* Log File Count */}
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Log File Count</Label>
<HelpText>
Sets the limit for how many individual log files will be included
in the debug bundle.
</HelpText>
</div>

<Input
type="number"
min={1}
placeholder={"10"}
max={50}
value={logFileCount}
onChange={(e) => setLogFileCount(e.target.value)}
maxWidthClass="w-[220px]"
customPrefix={<FileText size={16} className="text-nb-gray-300" />}
customSuffix="File(s)"
/>
</div>
{/* Bundle Duration */}
<div>
<FancyToggleSwitch
value={bundleForTimeEnabled}
onChange={(enabled) => {
setBundleForTimeEnabled(enabled);
if (!enabled) {
setBundleForTime("");
} else {
setBundleForTime("2");
}
}}
label={
<>
<AlarmClock size={15} />
Enable Bundle Duration
</>
}
helpText="When enabled, allows you to specify a time period for log collection before generating the debug bundle."
/>

{bundleForTimeEnabled && (
<div className="flex justify-between gap-6 mt-6 mb-3">
<div className={"max-w-[300px]"}>
<Label>Duration</Label>
<HelpText>
Time period for which logs should be collected before creating
the debug bundle.
</HelpText>
</div>

<Input
type="number"
min={1}
max={60}
value={bundleForTime}
onChange={(e) => setBundleForTime(e.target.value)}
maxWidthClass="w-[220px]"
placeholder={"2"}
customPrefix={
<AlarmClock size={16} className="text-nb-gray-300" />
}
customSuffix="Minute(s)"
/>
</div>
)}
</div>

{/* Anonymize Data */}
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={
<>
<Shield size={15} />
Anonymize Log Data
</>
}
helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
/>
</div>

<ModalFooter className="items-center">
<div className="flex gap-3 w-full justify-end">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
disabled={!isValid}
onClick={createDebugJob}
>
<PlusCircle size={16} />
Create Debug Bundle
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
60 changes: 60 additions & 0 deletions src/modules/jobs/table/JobOutputCell.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-1 items-start justify-center pb-1">
{Object.entries(job.workload.result).map(([key, value]) => (
<div key={key} className="text-sm max-w-[200px]">
<span className="font-normal capitalize text-nb-gray-300 text-xs">
{key.replaceAll("_", " ")}
</span>
<br />
<span className="text-nb-gray-200 truncate">
<CopyToClipboardText
message={"Upload key has been copied to your clipboard"}
alwaysShowIcon={true}
>
<span className={"font-mono truncate"}>
{typeof value === "boolean"
? value
? "Yes"
: "No"
: String(value)}
</span>
</CopyToClipboardText>
</span>
</div>
))}
</div>
);
}

if (job.status === "failed" && job.failed_reason) {
return (
<div className={"flex"}>
<FullTooltip
content={
<div className={"max-w-xs text-xs"}>{job.failed_reason}</div>
}
>
<Badge variant={"red"} className={"px-3 max-w-[200px]"}>
<div className={"truncate"}>{job.failed_reason}</div>
</Badge>
</FullTooltip>
</div>
);
}

return <EmptyRow />;
};
Loading