mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-13 12:19:54 +00:00
update settings nav
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useStatus } from "@/hooks/useStatus";
|
||||
import { Update as UpdateSvc } from "../../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
|
||||
|
||||
export const AutoUpdate = () => {
|
||||
const { status } = useStatus();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (import.meta.env.DEV) return null;
|
||||
|
||||
const updateVersion = (status?.events ?? [])
|
||||
.map((e) => e.metadata?.["new_version_available"])
|
||||
.find((v): v is string => Boolean(v));
|
||||
|
||||
if (!updateVersion || dismissed) return null;
|
||||
|
||||
const triggerUpdate = () => {
|
||||
UpdateSvc.Trigger().catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"absolute bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg border border-nb-gray-800 bg-nb-gray-920/95 backdrop-blur px-4 py-2.5 shadow-lg"
|
||||
}
|
||||
>
|
||||
<p className={"text-sm text-nb-gray-100 pr-2"}>
|
||||
NetBird will update when you restart the app.
|
||||
</p>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Restart now
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoUpdate;
|
||||
@@ -4,8 +4,14 @@ import {
|
||||
Settings as SettingsSvc,
|
||||
Profiles as ProfilesSvc,
|
||||
Update as UpdateSvc,
|
||||
Debug as DebugSvc,
|
||||
Connection as ConnectionSvc,
|
||||
} from "../../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
|
||||
import type { Config } from "../../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
|
||||
import type {
|
||||
Config,
|
||||
DebugBundleResult,
|
||||
} from "../../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
|
||||
import { Check, Copy, FolderOpen, Loader2 } from "lucide-react";
|
||||
import netbirdAppIcon from "@/assets/logos/netbird-app-icon.svg";
|
||||
import pkg from "../../../package.json";
|
||||
import { useStatus } from "@/hooks/useStatus";
|
||||
@@ -16,10 +22,8 @@ import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||
import {
|
||||
SettingsNavigation,
|
||||
SettingsSection,
|
||||
} from "@/modules/settings/SettingsNavigation.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
|
||||
|
||||
type Ctx = {
|
||||
cfg: Config;
|
||||
@@ -62,7 +66,7 @@ const buildPayload = (
|
||||
});
|
||||
|
||||
export const Settings = () => {
|
||||
const [active, setActive] = useState<SettingsSection>("general");
|
||||
const [active, setActive] = useState("general");
|
||||
const [username, setUsername] = useState("");
|
||||
const [profile, setProfile] = useState("");
|
||||
const [cfg, setCfg] = useState<Config | null>(null);
|
||||
@@ -113,10 +117,12 @@ export const Settings = () => {
|
||||
}, [cfg, saveNow]);
|
||||
|
||||
return (
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
<div className={"flex flex-col w-52 shrink-0 items-center"}>
|
||||
<SettingsNavigation active={active} onChange={setActive} />
|
||||
</div>
|
||||
<VerticalTabs
|
||||
value={active}
|
||||
onValueChange={setActive}
|
||||
className={"wails-draggable p-4"}
|
||||
>
|
||||
<SettingsNavigationTriggers />
|
||||
<MainRightSide>
|
||||
{error && (
|
||||
<p className={"px-6 py-2 text-sm text-red-500"}>{error}</p>
|
||||
@@ -128,40 +134,45 @@ export const Settings = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className={"px-6 py-5"}>
|
||||
{active === "general" && (
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<GeneralSection
|
||||
cfg={cfg}
|
||||
setField={setField}
|
||||
onSaveServer={saveNow}
|
||||
/>
|
||||
)}
|
||||
{active === "network" && (
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<NetworkSection cfg={cfg} setField={setField} />
|
||||
)}
|
||||
{active === "security" && (
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SecuritySection
|
||||
cfg={cfg}
|
||||
setField={setField}
|
||||
/>
|
||||
)}
|
||||
{active === "ssh" && (
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SshSection cfg={cfg} setField={setField} />
|
||||
)}
|
||||
{active === "advanced" && (
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<AdvancedSection
|
||||
cfg={cfg}
|
||||
setField={setField}
|
||||
/>
|
||||
)}
|
||||
{active === "troubleshooting" && (
|
||||
<TroubleshootingSection />
|
||||
)}
|
||||
{active === "about" && <AboutSection />}
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"troubleshooting"}>
|
||||
<TroubleshootingSection
|
||||
profile={profile}
|
||||
username={username}
|
||||
/>
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<AboutSection />
|
||||
</VerticalTabs.Content>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MainRightSide>
|
||||
</div>
|
||||
</VerticalTabs>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -488,16 +499,396 @@ function AdvancedSection({ cfg, setField }: Ctx) {
|
||||
);
|
||||
}
|
||||
|
||||
function TroubleshootingSection() {
|
||||
const NETBIRD_UPLOAD_URL = "https://debug.netbird.io/upload";
|
||||
const TRACE_LOG_FILE_COUNT = 5;
|
||||
const PLAIN_LOG_FILE_COUNT = 1;
|
||||
|
||||
type Stage =
|
||||
| { kind: "idle" }
|
||||
| { kind: "preparing-trace" }
|
||||
| { kind: "reconnecting" }
|
||||
| { kind: "capturing"; remainingSec: number; totalSec: number }
|
||||
| { kind: "restoring-level" }
|
||||
| { kind: "bundling" }
|
||||
| { kind: "uploading" }
|
||||
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function TroubleshootingSection({
|
||||
profile,
|
||||
username,
|
||||
}: {
|
||||
profile: string;
|
||||
username: string;
|
||||
}) {
|
||||
const [anonymize, setAnonymize] = useState(true);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [trace, setTrace] = useState(false);
|
||||
const [traceMinutes, setTraceMinutes] = useState(3);
|
||||
const [stage, setStage] = useState<Stage>({ kind: "idle" });
|
||||
|
||||
const isRunning =
|
||||
stage.kind !== "idle" &&
|
||||
stage.kind !== "done" &&
|
||||
stage.kind !== "error";
|
||||
|
||||
const reset = () => setStage({ kind: "idle" });
|
||||
|
||||
const run = async () => {
|
||||
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||
try {
|
||||
let originalLevel = "info";
|
||||
if (trace) {
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
const cur = await DebugSvc.GetLogLevel();
|
||||
if (cur?.level) originalLevel = cur.level;
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
await ConnectionSvc.Down();
|
||||
} catch {
|
||||
// already down
|
||||
}
|
||||
await ConnectionSvc.Up({ profileName: profile, username });
|
||||
|
||||
const totalSec = Math.max(
|
||||
1,
|
||||
Math.min(30, traceMinutes),
|
||||
) * 60;
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({
|
||||
kind: "capturing",
|
||||
remainingSec: remaining,
|
||||
totalSec,
|
||||
});
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||
} catch {
|
||||
// restore is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
setStage({ kind: "bundling" });
|
||||
const logFileCount = trace
|
||||
? TRACE_LOG_FILE_COUNT
|
||||
: PLAIN_LOG_FILE_COUNT;
|
||||
|
||||
if (uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
anonymize,
|
||||
systemInfo,
|
||||
uploadUrl,
|
||||
logFileCount,
|
||||
});
|
||||
setStage({
|
||||
kind: "done",
|
||||
result,
|
||||
uploadAttempted: Boolean(uploadUrl),
|
||||
});
|
||||
} catch (e) {
|
||||
setStage({ kind: "error", message: String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionGroup title={"Debug bundle"}>
|
||||
<p className={"text-sm text-nb-gray-400"}>
|
||||
Debug bundle creation is not yet wired up in this view.
|
||||
<p className={"text-sm text-nb-gray-300 mb-2"}>
|
||||
A debug bundle helps NetBird support investigate connection
|
||||
problems. It's a zip file with logs and system details from
|
||||
this device.
|
||||
</p>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label={"Anonymize personal data"}
|
||||
helpText={
|
||||
"Replace IPs, hostnames, and peer names before saving."
|
||||
}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
label={"Include system info"}
|
||||
helpText={
|
||||
"Include OS, kernel, network interfaces, and routing tables."
|
||||
}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={upload}
|
||||
onChange={setUpload}
|
||||
label={"Send to NetBird support"}
|
||||
helpText={
|
||||
"Uploads the bundle directly. You'll get a key to share with us."
|
||||
}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={trace}
|
||||
onChange={setTrace}
|
||||
label={"Capture detailed (trace) logs"}
|
||||
helpText={
|
||||
"Restart NetBird with extra logging for a few minutes, then create the bundle. NetBird will briefly disconnect."
|
||||
}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<div className={"flex items-center gap-3 max-w-sm"}>
|
||||
<Label as={"div"} className={"!mb-0"}>
|
||||
Capture for
|
||||
</Label>
|
||||
<div className={"w-24"}>
|
||||
<Input
|
||||
type={"number"}
|
||||
min={1}
|
||||
max={30}
|
||||
value={traceMinutes}
|
||||
onChange={(e) =>
|
||||
setTraceMinutes(
|
||||
Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
30,
|
||||
Number(e.target.value) || 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
customSuffix={
|
||||
<span className={"text-nb-gray-400"}>min</span>
|
||||
}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FancyToggleSwitch>
|
||||
|
||||
<div className={"flex items-center gap-3 mt-2"}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
onClick={run}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isRunning ? "Creating bundle…" : "Create bundle"}
|
||||
</Button>
|
||||
{stage.kind === "error" && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"md"}
|
||||
onClick={reset}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BundleStatus stage={stage} />
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleStatus({ stage }: { stage: Stage }) {
|
||||
if (stage.kind === "idle") return null;
|
||||
|
||||
if (
|
||||
stage.kind === "preparing-trace" ||
|
||||
stage.kind === "reconnecting" ||
|
||||
stage.kind === "capturing" ||
|
||||
stage.kind === "restoring-level" ||
|
||||
stage.kind === "bundling" ||
|
||||
stage.kind === "uploading"
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mt-4 flex items-center gap-3 rounded-md border border-nb-gray-800 bg-nb-gray-920 px-4 py-3"
|
||||
}
|
||||
>
|
||||
<Loader2
|
||||
className={"animate-spin text-netbird shrink-0"}
|
||||
size={18}
|
||||
/>
|
||||
<p className={"text-sm text-nb-gray-200"}>
|
||||
{stageLabel(stage)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stage.kind === "error") {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mt-4 rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300"
|
||||
}
|
||||
>
|
||||
{stage.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BundleResult result={stage.result} uploaded={stage.uploadAttempted} />;
|
||||
}
|
||||
|
||||
function stageLabel(stage: Stage): string {
|
||||
switch (stage.kind) {
|
||||
case "preparing-trace":
|
||||
return "Switching to trace logging…";
|
||||
case "reconnecting":
|
||||
return "Reconnecting NetBird…";
|
||||
case "capturing": {
|
||||
const fmt = (s: number) =>
|
||||
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
return `Capturing logs — ${fmt(
|
||||
stage.totalSec - stage.remainingSec,
|
||||
)} / ${fmt(stage.totalSec)}`;
|
||||
}
|
||||
case "restoring-level":
|
||||
return "Restoring previous log level…";
|
||||
case "bundling":
|
||||
return "Building bundle…";
|
||||
case "uploading":
|
||||
return "Uploading to NetBird…";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function BundleResult({
|
||||
result,
|
||||
uploaded,
|
||||
}: {
|
||||
result: DebugBundleResult;
|
||||
uploaded: boolean;
|
||||
}) {
|
||||
const uploadFailed = uploaded && !result.uploadedKey;
|
||||
return (
|
||||
<div className={"mt-4 flex flex-col gap-3"}>
|
||||
{uploaded && result.uploadedKey && (
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-800 bg-nb-gray-920 px-4 py-4"
|
||||
}
|
||||
>
|
||||
<p className={"text-sm font-medium mb-1"}>
|
||||
Bundle uploaded
|
||||
</p>
|
||||
<p className={"text-xs text-nb-gray-400 mb-3"}>
|
||||
Share this key with NetBird support so they can find
|
||||
your bundle.
|
||||
</p>
|
||||
<CopyableValue value={result.uploadedKey} mono large />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadFailed && (
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300"
|
||||
}
|
||||
>
|
||||
Upload failed
|
||||
{result.uploadFailureReason
|
||||
? `: ${result.uploadFailureReason}`
|
||||
: "."}{" "}
|
||||
The bundle is still saved locally.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.path && (
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-800 bg-nb-gray-920 px-4 py-3"
|
||||
}
|
||||
>
|
||||
<p className={"text-xs text-nb-gray-400 mb-2"}>
|
||||
{uploaded && result.uploadedKey
|
||||
? "A local copy was also saved at:"
|
||||
: "Bundle saved to:"}
|
||||
</p>
|
||||
<CopyableValue value={result.path} mono />
|
||||
<p className={"text-xs text-nb-gray-500 mt-2"}>
|
||||
You may need admin privileges to open this file.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyableValue({
|
||||
value,
|
||||
mono = false,
|
||||
large = false,
|
||||
}: {
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
large?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
const onReveal = () => {
|
||||
void Browser.OpenURL(`file://${value}`).catch(() => {});
|
||||
};
|
||||
return (
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<code
|
||||
className={cn(
|
||||
"flex-1 min-w-0 truncate rounded bg-nb-gray-900 px-3 py-2 border border-nb-gray-800",
|
||||
mono && "font-mono",
|
||||
large ? "text-sm" : "text-xs",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</code>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onCopy}
|
||||
className={
|
||||
"p-2 rounded-md border border-nb-gray-800 text-nb-gray-300 hover:text-white hover:bg-nb-gray-900"
|
||||
}
|
||||
aria-label={"Copy"}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
{value.startsWith("/") || value.match(/^[A-Za-z]:\\/) ? (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onReveal}
|
||||
className={
|
||||
"p-2 rounded-md border border-nb-gray-800 text-nb-gray-300 hover:text-white hover:bg-nb-gray-900"
|
||||
}
|
||||
aria-label={"Reveal"}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LEGAL_LINKS: { label: string; url: string }[] = [
|
||||
{ label: "Imprint", url: "https://netbird.io/imprint" },
|
||||
{ label: "Privacy", url: "https://netbird.io/privacy" },
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NavItem } from "@/components/NavItem.tsx";
|
||||
import {
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
SlidersHorizontalIcon,
|
||||
TerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export type SettingsSection =
|
||||
| "general"
|
||||
| "network"
|
||||
| "security"
|
||||
| "ssh"
|
||||
| "advanced"
|
||||
| "troubleshooting"
|
||||
| "about";
|
||||
|
||||
type Props = {
|
||||
active: SettingsSection;
|
||||
onChange: (section: SettingsSection) => void;
|
||||
};
|
||||
|
||||
const ITEMS: {
|
||||
id: SettingsSection;
|
||||
icon: typeof SlidersHorizontalIcon;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ id: "general", icon: SlidersHorizontalIcon, title: "General" },
|
||||
{ id: "network", icon: NetworkIcon, title: "Network" },
|
||||
{ id: "security", icon: ShieldIcon, title: "Security" },
|
||||
{ id: "ssh", icon: TerminalIcon, title: "SSH" },
|
||||
{ id: "advanced", icon: WrenchIcon, title: "Advanced" },
|
||||
{ id: "troubleshooting", icon: LifeBuoyIcon, title: "Troubleshooting" },
|
||||
{ id: "about", icon: InfoIcon, title: "About" },
|
||||
];
|
||||
|
||||
export const SettingsNavigation = ({ active, onChange }: Props) => {
|
||||
return (
|
||||
<nav className={"w-full flex flex-col gap-1"}>
|
||||
{ITEMS.map(({ id, icon, title }) => (
|
||||
<NavItem
|
||||
key={id}
|
||||
icon={icon}
|
||||
title={title}
|
||||
iconSize={14}
|
||||
iconBackground={false}
|
||||
className={"py-2.5"}
|
||||
active={active === id}
|
||||
onClick={() => {
|
||||
if (active !== id) onChange(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import {
|
||||
BoltIcon,
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SquareTerminalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const SettingsNavigationTriggers = () => {
|
||||
return (
|
||||
<div className={"flex flex-col w-52 shrink-0 items-center"}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger
|
||||
value={"general"}
|
||||
icon={SlidersHorizontalIcon}
|
||||
title={"General"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"network"}
|
||||
icon={NetworkIcon}
|
||||
title={"Network"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"security"}
|
||||
icon={ShieldIcon}
|
||||
title={"Security"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"ssh"}
|
||||
icon={SquareTerminalIcon}
|
||||
title={"SSH"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"advanced"}
|
||||
icon={BoltIcon}
|
||||
title={"Advanced"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"troubleshooting"}
|
||||
icon={LifeBuoyIcon}
|
||||
title={"Troubleshooting"}
|
||||
/>
|
||||
<VerticalTabs.Trigger
|
||||
value={"about"}
|
||||
icon={InfoIcon}
|
||||
title={"About"}
|
||||
/>
|
||||
</VerticalTabs.List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user