Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818ba5daa4 | ||
|
|
3a30f76629 | ||
|
|
34dc21c89d | ||
|
|
2e37703622 |
@@ -1,7 +1,7 @@
|
||||
## Contributor License Agreement
|
||||
|
||||
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
||||
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
|
||||
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
|
||||
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
|
||||
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
|
||||
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
|
||||
|
||||
@@ -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
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.delete && (
|
||||
<TabsTrigger value={"peer-job"}>
|
||||
<RadioTowerIcon size={16} />
|
||||
Remote Jobs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{permission.routes.read && (
|
||||
@@ -339,6 +349,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>
|
||||
);
|
||||
};
|
||||
@@ -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) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
36
src/components/TooltipListItem.tsx
Normal file
36
src/components/TooltipListItem.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { UserAvatar } from "@components/ui/UserAvatar";
|
||||
import { LogOutIcon, User2 } from "lucide-react";
|
||||
import { KeyRound, LogOutIcon, User2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
@@ -19,9 +19,13 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import useOSDetection from "@/hooks/useOperatingSystem";
|
||||
import { ChangePasswordModalContent } from "@/modules/users/ChangePasswordModal";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
|
||||
export default function UserDropdown() {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [changePasswordModal, setChangePasswordModal] = useState(false);
|
||||
const { user } = useApplicationContext();
|
||||
const { loggedInUser, logout } = useLoggedInUser();
|
||||
const { isRestricted, permission } = usePermissions();
|
||||
@@ -31,11 +35,22 @@ export default function UserDropdown() {
|
||||
useHotkeys("shift+mod+l", () => logout(), []);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<>
|
||||
<Modal
|
||||
open={changePasswordModal}
|
||||
onOpenChange={setChangePasswordModal}
|
||||
key={changePasswordModal ? 1 : 0}
|
||||
>
|
||||
<ChangePasswordModalContent
|
||||
userId={loggedInUser?.id}
|
||||
onSuccess={() => setChangePasswordModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<UserAvatar size={"medium"} />
|
||||
</DropdownMenuTrigger>
|
||||
@@ -72,6 +87,20 @@ export default function UserDropdown() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isNetBirdHosted() && loggedInUser?.idp_id === "local" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
setChangePasswordModal(true);
|
||||
}}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<KeyRound size={14} />
|
||||
Change Password
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={logout}>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<LogOutIcon size={14} />
|
||||
@@ -81,6 +110,7 @@ export default function UserDropdown() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
23
src/interfaces/Job.ts
Normal file
23
src/interfaces/Job.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -277,6 +277,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "user.password.change")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Password was changed for user <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Service User
|
||||
*/
|
||||
@@ -693,6 +701,16 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Jobs
|
||||
*/
|
||||
|
||||
if (event.activity_code == "peer.job.create")
|
||||
return (<div className={"inline"}>
|
||||
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (event.activity_code == "account.settings.extra.flow.group.remove")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
|
||||
@@ -33,6 +33,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
|
||||
rename: ActionStatus.INFO,
|
||||
unblock: ActionStatus.INFO,
|
||||
login: ActionStatus.INFO,
|
||||
change: ActionStatus.INFO,
|
||||
};
|
||||
|
||||
export function getColorFromCode(code: string): string {
|
||||
|
||||
@@ -101,8 +101,9 @@ export function DNSRecordModalContent({
|
||||
|
||||
const domainError = useMemo(() => {
|
||||
if (domain == "") return "";
|
||||
if (domain === "*") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowWildcard: true,
|
||||
allowOnlyTld: true,
|
||||
});
|
||||
if (!valid) {
|
||||
@@ -210,12 +211,13 @@ export function DNSRecordModalContent({
|
||||
<div className={"w-full mb-3"}>
|
||||
<Label>Hostname</Label>
|
||||
<HelpText>
|
||||
Enter a subdomain or leave empty to use the primary domain.
|
||||
Enter a subdomain, wildcard or leave empty to use the primary
|
||||
domain.
|
||||
</HelpText>
|
||||
<div className={"flex w-full"}>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
placeholder={"Subdomain (leave empty for primary domain)"}
|
||||
placeholder={"E.g., dev, * or leave empty for primary domain"}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"bottom"}
|
||||
error={domainError}
|
||||
|
||||
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal file
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal file
@@ -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
src/modules/jobs/table/JobOutputCell.tsx
Normal file
60
src/modules/jobs/table/JobOutputCell.tsx
Normal file
@@ -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 />;
|
||||
};
|
||||
56
src/modules/jobs/table/JobParametersCell.tsx
Normal file
56
src/modules/jobs/table/JobParametersCell.tsx
Normal file
@@ -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 <EmptyRow />;
|
||||
}
|
||||
|
||||
const entries = Object.entries(parameters);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
side={"top"}
|
||||
interactive={true}
|
||||
delayDuration={250}
|
||||
skipDelayDuration={100}
|
||||
contentClassName={"p-0"}
|
||||
content={
|
||||
<div
|
||||
className={"text-xs flex flex-col"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{entries.map(([key, value]) => (
|
||||
<TooltipListItem
|
||||
label={key.replaceAll("_", " ")}
|
||||
labelClassName={"capitalize"}
|
||||
value={
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
? "Yes"
|
||||
: "No"
|
||||
: String(value)
|
||||
}
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
variant="gray"
|
||||
className="flex items-center gap-1.5 cursor-default"
|
||||
>
|
||||
<InfoIcon size={12} />
|
||||
{entries.length} Parameters
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
30
src/modules/jobs/table/JobStatusCell.tsx
Normal file
30
src/modules/jobs/table/JobStatusCell.tsx
Normal file
@@ -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<Props>) {
|
||||
const status = job.status;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
|
||||
data-cy={"job-status-cell"}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
status == "pending" && "bg-yellow-400",
|
||||
status == "failed" && "bg-red-500",
|
||||
status == "succeeded" && "bg-green-500",
|
||||
)}
|
||||
></span>
|
||||
{status == "pending" && "Pending"}
|
||||
{status == "failed" && "Failed"}
|
||||
{status == "succeeded" && "Completed"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/modules/jobs/table/JobTypeCell.tsx
Normal file
22
src/modules/jobs/table/JobTypeCell.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={"flex items-center gap-2 whitespace-nowrap text-nb-gray-200"}
|
||||
>
|
||||
<BugIcon size={14} />
|
||||
<span>Debug Bundle</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmptyRow />;
|
||||
};
|
||||
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal file
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal file
@@ -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<Job>[] = [
|
||||
{
|
||||
accessorKey: "Type",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Type</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <JobTypeCell job={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "CreatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Created</DataTableHeader>
|
||||
),
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => (
|
||||
<LastTimeRow date={row.original.created_at} text="Created at" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Status",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Status</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <JobStatusCell job={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "CompletedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Completed</DataTableHeader>
|
||||
),
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) =>
|
||||
row.original.completed_at ? (
|
||||
<LastTimeRow date={row.original.completed_at} text="Completed at" />
|
||||
) : (
|
||||
<EmptyRow />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Parameters",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Parameters</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<JobParametersCell parameters={row.original.workload.parameters} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ResultOrReason",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Output</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <JobOutputCell job={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function PeerRemoteJobsTable({
|
||||
jobs,
|
||||
isLoading,
|
||||
headingTarget,
|
||||
peerID,
|
||||
}: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "CreatedAt", desc: true },
|
||||
]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
rightSide={() => (
|
||||
<div className={"gap-x-4 ml-auto flex"}>
|
||||
<RemoteJobDropdownButton />
|
||||
</div>
|
||||
)}
|
||||
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={
|
||||
<NoResults
|
||||
className="py-4"
|
||||
title="This peer has no remote jobs"
|
||||
description="Create a debug bundle or trigger other remote jobs to see them listed here."
|
||||
icon={<ClipboardList size={20} className="text-nb-gray-300" />}
|
||||
/>
|
||||
}
|
||||
paginationPaddingClassName="px-0 pt-8"
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage table={table} disabled={jobs?.length == 0} />
|
||||
<DataTableRefreshButton
|
||||
isDisabled={jobs?.length == 0}
|
||||
onClick={() => {
|
||||
mutate(`/peers/${peerID}/jobs`).then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export default function AddRouteDropdownButton() {
|
||||
icon={<PlusCircle size={14} />}
|
||||
color={"green"}
|
||||
margin={""}
|
||||
size={"small"}
|
||||
/>
|
||||
<div className={"flex flex-col text-left"}>
|
||||
<div className={"text-left text-white"}>New Network Route</div>
|
||||
@@ -79,6 +80,7 @@ export default function AddRouteDropdownButton() {
|
||||
}
|
||||
color={"netbird"}
|
||||
margin={""}
|
||||
size={"small"}
|
||||
/>
|
||||
<div className={"flex flex-col text-left"}>
|
||||
<div className={"text-left text-white"}>Existing Network</div>
|
||||
|
||||
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal file
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal file
@@ -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<Job[]>(`/peers/${peerID}/jobs`);
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<div className="pb-10 px-8">
|
||||
<div className="max-w-6xl">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<div>
|
||||
<h2 ref={headingRef}>Remote Jobs</h2>
|
||||
<Paragraph>
|
||||
Remotely trigger actions such as debug bundles or other tasks on
|
||||
this peer, without requiring CLI access.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink href={"https://docs.netbird.io"} target={"_blank"}>
|
||||
Remote Jobs <ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div>
|
||||
<SkeletonTableHeader className="!p-0" />
|
||||
<div className="mt-8 w-full">
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PeerRemoteJobsTable
|
||||
peerID={peerID}
|
||||
jobs={jobs}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal file
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
|
||||
<CreateDebugJobModalContent
|
||||
peerID={peer.id!}
|
||||
onSuccess={() => setModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Button variant={"primary"} disabled={disabled}>
|
||||
Run Remote Job
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto" align="end" sideOffset={10}>
|
||||
{!isConnected && (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"text-xs flex items-center w-full justify-center max-w-xs px-3 py-3 text-nb-gray-200 font-light"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
Peer{" "}
|
||||
<span className={"text-white font-medium"}>{peer.name}</span>{" "}
|
||||
is currently offline. Please connect the peer to run remote
|
||||
jobs.
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setModal(true)}
|
||||
disabled={disabled || !isConnected}
|
||||
>
|
||||
<div className={"flex gap-3 items-center justify-center pr-3"}>
|
||||
<SquareIcon
|
||||
icon={<BugPlay size={14} />}
|
||||
margin={""}
|
||||
size={"small"}
|
||||
/>
|
||||
<div className={"flex flex-col text-left"}>
|
||||
<div className={"text-left text-white"}>Debug Bundle</div>
|
||||
<div className={"text-xs"}>
|
||||
Collect debug information for troubleshooting
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
194
src/modules/users/ChangePasswordModal.tsx
Normal file
194
src/modules/users/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import Separator from "@components/Separator";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { KeyRound, LockIcon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
children,
|
||||
userId,
|
||||
}: Readonly<Props>) {
|
||||
const [modal, setModal] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ChangePasswordModalContent
|
||||
userId={userId}
|
||||
onSuccess={() => setModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalProps = {
|
||||
userId?: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export function ChangePasswordModalContent({
|
||||
userId,
|
||||
onSuccess,
|
||||
}: Readonly<ModalProps>) {
|
||||
const passwordRequest = useApiCall<void>(`/users/${userId}/password`, true);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const currentPasswordError = useMemo(() => {
|
||||
if (currentPassword.length === 0) return undefined;
|
||||
return undefined;
|
||||
}, [currentPassword]);
|
||||
|
||||
const newPasswordError = useMemo(() => {
|
||||
if (newPassword.length === 0) return undefined;
|
||||
if (newPassword.length < 8) return "Password must be at least 8 characters";
|
||||
return undefined;
|
||||
}, [newPassword]);
|
||||
|
||||
const confirmPasswordError = useMemo(() => {
|
||||
if (confirmPassword.length === 0) return undefined;
|
||||
if (newPassword !== confirmPassword) return "Passwords do not match";
|
||||
return undefined;
|
||||
}, [newPassword, confirmPassword]);
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
if (currentPassword.length === 0) return true;
|
||||
if (newPassword.length < 8) return true;
|
||||
if (confirmPassword.length === 0) return true;
|
||||
if (newPassword !== confirmPassword) return true;
|
||||
return false;
|
||||
}, [currentPassword, newPassword, confirmPassword]);
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!userId || isDisabled) return;
|
||||
|
||||
setIsLoading(true);
|
||||
notify({
|
||||
title: "Change Password",
|
||||
description: "Your password has been successfully changed.",
|
||||
promise: passwordRequest
|
||||
.put({
|
||||
old_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
.then(() => {
|
||||
onSuccess && onSuccess();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
}),
|
||||
loadingMessage: "Changing password...",
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isDisabled && !isLoading) {
|
||||
changePassword();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-lg"}>
|
||||
<ModalHeader
|
||||
icon={<KeyRound size={18} />}
|
||||
title={"Change Password"}
|
||||
description={"Update your account password."}
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<form className={"px-8 py-6 flex flex-col gap-6"} onSubmit={changePassword}>
|
||||
<div>
|
||||
<Label>Current Password</Label>
|
||||
<HelpText>Enter your current password to verify your identity.</HelpText>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={"Enter current password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
showPasswordToggle
|
||||
error={currentPasswordError}
|
||||
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
|
||||
name={"current-password"}
|
||||
autoComplete={"current-password"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>New Password</Label>
|
||||
<HelpText>
|
||||
Enter your new password. Must be at least 8 characters.
|
||||
</HelpText>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={"Enter new password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
showPasswordToggle
|
||||
error={newPasswordError}
|
||||
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
|
||||
name={"new-password"}
|
||||
autoComplete={"new-password"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Confirm New Password</Label>
|
||||
<HelpText>Re-enter your new password to confirm.</HelpText>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={"Confirm new password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
showPasswordToggle
|
||||
error={confirmPasswordError}
|
||||
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
|
||||
name={"confirm-password"}
|
||||
autoComplete={"confirm-password"}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={isDisabled || isLoading}
|
||||
onClick={changePassword}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user