mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-17 14:19:54 +00:00
Merge branch 'ui-refactor' into ui-refactor-ui
This commit is contained in:
110
client/ui/frontend/src/screens/Debug.tsx
Normal file
110
client/ui/frontend/src/screens/Debug.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Debug.tsx
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
========
|
||||
import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Debug.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import { Switch } from "../components/Switch";
|
||||
import { Card } from "../components/Card";
|
||||
|
||||
export default function Debug() {
|
||||
const [anonymize, setAnonymize] = useState(true);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
const [upload, setUpload] = useState(false);
|
||||
const [uploadUrl, setUploadUrl] = useState("");
|
||||
const [logFiles, setLogFiles] = useState(0);
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<DebugBundleResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await DebugSvc.Bundle({
|
||||
anonymize,
|
||||
systemInfo,
|
||||
uploadUrl: upload ? uploadUrl : "",
|
||||
logFileCount: logFiles,
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<h1 className="text-xl font-semibold">Debug bundle</h1>
|
||||
|
||||
<Card className="space-y-4">
|
||||
<Switch
|
||||
checked={anonymize}
|
||||
onChange={setAnonymize}
|
||||
label="Anonymize"
|
||||
description="Replace IPs and identifiers in the bundle."
|
||||
/>
|
||||
<Switch
|
||||
checked={systemInfo}
|
||||
onChange={setSystemInfo}
|
||||
label="Include system information"
|
||||
/>
|
||||
<Switch
|
||||
checked={upload}
|
||||
onChange={setUpload}
|
||||
label="Upload on create"
|
||||
/>
|
||||
{upload && (
|
||||
<Input
|
||||
label="Upload URL"
|
||||
value={uploadUrl}
|
||||
onChange={(e) => setUploadUrl(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label="Log file count"
|
||||
type="number"
|
||||
value={logFiles}
|
||||
onChange={(e) => setLogFiles(Number(e.target.value))}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<Button onClick={run} disabled={running}>
|
||||
{running ? "Generating…" : "Create bundle"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
{result.path && (
|
||||
<p className="text-sm">
|
||||
<span className="text-nb-gray-500">Path:</span>{" "}
|
||||
<span className="font-mono">{result.path}</span>
|
||||
</p>
|
||||
)}
|
||||
{result.uploadedKey && (
|
||||
<p className="text-sm">
|
||||
<span className="text-nb-gray-500">Uploaded key:</span>{" "}
|
||||
<span className="font-mono">{result.uploadedKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{result.uploadFailureReason && (
|
||||
<p className="text-sm text-red-500">
|
||||
Upload failed: {result.uploadFailureReason}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
client/ui/frontend/src/screens/Networks.tsx
Normal file
167
client/ui/frontend/src/screens/Networks.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Networks.tsx
|
||||
import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
========
|
||||
import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Networks.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Tabs } from "../components/Tabs";
|
||||
|
||||
export default function Networks() {
|
||||
const [routes, setRoutes] = useState<Network[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await NetworksSvc.List();
|
||||
setRoutes(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const toggle = async (id: string, selected: boolean) => {
|
||||
try {
|
||||
if (selected) {
|
||||
await NetworksSvc.Deselect({ networkIds: [id], append: false, all: false });
|
||||
} else {
|
||||
await NetworksSvc.Select({ networkIds: [id], append: true, all: false });
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const setAll = async (ids: string[], on: boolean) => {
|
||||
try {
|
||||
if (on) {
|
||||
await NetworksSvc.Select({ networkIds: ids, append: false, all: true });
|
||||
} else {
|
||||
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: true });
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const overlapping = useMemo(() => filterOverlapping(routes), [routes]);
|
||||
const exitNodes = useMemo(
|
||||
() => routes.filter((r) => r.range === "0.0.0.0/0" || r.range === "::/0"),
|
||||
[routes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Networks</h1>
|
||||
<Button variant="secondary" size="sm" onClick={refresh} disabled={loading}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} strokeWidth={1.5} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-2 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
value: "all",
|
||||
label: `All (${routes.length})`,
|
||||
content: <NetworkList routes={routes} onToggle={toggle} onSetAll={setAll} />,
|
||||
},
|
||||
{
|
||||
value: "overlap",
|
||||
label: `Overlapping (${overlapping.length})`,
|
||||
content: <NetworkList routes={overlapping} onToggle={toggle} onSetAll={setAll} />,
|
||||
},
|
||||
{
|
||||
value: "exit",
|
||||
label: `Exit-node (${exitNodes.length})`,
|
||||
content: <NetworkList routes={exitNodes} onToggle={toggle} onSetAll={setAll} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkList({
|
||||
routes,
|
||||
onToggle,
|
||||
onSetAll,
|
||||
}: {
|
||||
routes: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
onSetAll: (ids: string[], on: boolean) => void;
|
||||
}) {
|
||||
if (routes.length === 0) {
|
||||
return <p className="p-4 text-sm text-nb-gray-500">No networks.</p>;
|
||||
}
|
||||
const ids = routes.map((r) => r.id);
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 gap-2 border-b border-nb-gray-200 px-4 py-2 dark:border-nb-gray-800">
|
||||
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, true)}>
|
||||
Select all
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, false)}>
|
||||
Deselect all
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex-1 overflow-auto divide-y divide-nb-gray-200 dark:divide-nb-gray-800">
|
||||
{routes.map((r) => (
|
||||
<li key={r.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={r.selected}
|
||||
onChange={() => onToggle(r.id, r.selected)}
|
||||
className="mt-1 h-4 w-4 accent-netbird"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{r.id}</p>
|
||||
<p className="truncate font-mono text-xs text-nb-gray-500">{r.range}</p>
|
||||
{r.domains.length > 0 && (
|
||||
<p className="mt-0.5 truncate text-xs text-nb-gray-500">
|
||||
{r.domains.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function filterOverlapping(routes: Network[]): Network[] {
|
||||
const byRange = new Map<string, Network[]>();
|
||||
for (const r of routes) {
|
||||
if (r.domains.length > 0) continue;
|
||||
const arr = byRange.get(r.range) ?? [];
|
||||
arr.push(r);
|
||||
byRange.set(r.range, arr);
|
||||
}
|
||||
const out: Network[] = [];
|
||||
for (const arr of byRange.values()) {
|
||||
if (arr.length > 1) out.push(...arr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
215
client/ui/frontend/src/screens/Peers.tsx
Normal file
215
client/ui/frontend/src/screens/Peers.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
|
||||
import { useStatus } from "../hooks/useStatus";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Peers.tsx
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
========
|
||||
import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Peers.tsx
|
||||
import { Card } from "../components/Card";
|
||||
import { Input } from "../components/Input";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export default function Peers() {
|
||||
const { status } = useStatus();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const peers = useMemo(() => {
|
||||
const all = status?.peers ?? [];
|
||||
if (!filter.trim()) return all;
|
||||
const q = filter.trim().toLowerCase();
|
||||
return all.filter(
|
||||
(p) =>
|
||||
p.fqdn.toLowerCase().includes(q) ||
|
||||
p.ip.toLowerCase().includes(q) ||
|
||||
p.networks.some((n) => n.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [status?.peers, filter]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">
|
||||
Peers
|
||||
<span className="ml-2 text-sm font-normal text-nb-gray-500">
|
||||
{status?.peers?.length ?? 0}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="w-64">
|
||||
<Input
|
||||
placeholder="Filter by FQDN / IP / network"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{peers.length === 0 ? (
|
||||
<Card className="text-sm text-nb-gray-500">
|
||||
{status?.peers?.length === 0
|
||||
? "No peers visible from this client."
|
||||
: "No peers match the filter."}
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="flex-1 divide-y divide-nb-gray-200 overflow-auto rounded-lg border border-nb-gray-200 dark:divide-nb-gray-800 dark:border-nb-gray-800">
|
||||
{peers.map((p) => (
|
||||
<PeerRow
|
||||
key={p.pubKey}
|
||||
peer={p}
|
||||
expanded={expanded === p.pubKey}
|
||||
onToggle={() => setExpanded(expanded === p.pubKey ? null : p.pubKey)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PeerRow({
|
||||
peer,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
peer: PeerStatus;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-nb-gray-50 dark:hover:bg-nb-gray-940"
|
||||
>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
<StateBadge state={peer.connStatus} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{peer.fqdn || "—"}</p>
|
||||
<p className="truncate font-mono text-xs text-nb-gray-500">{peer.ip}</p>
|
||||
</div>
|
||||
<RouteIcon relayed={peer.relayed} connected={peer.connStatus === "Connected"} />
|
||||
{peer.rosenpassEnabled && (
|
||||
<ShieldCheck className="h-4 w-4 text-green-500" strokeWidth={1.5} />
|
||||
)}
|
||||
<span className="w-16 text-right text-xs text-nb-gray-500">
|
||||
{peer.connStatus === "Connected" && peer.latencyMs > 0
|
||||
? `${peer.latencyMs} ms`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && <PeerDetails peer={peer} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PeerDetails({ peer }: { peer: PeerStatus }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 bg-nb-gray-50 px-12 py-3 text-xs dark:bg-nb-gray-940">
|
||||
<Detail label="Public key" value={peer.pubKey} mono />
|
||||
<Detail label="Last handshake" value={fmtRelative(peer.lastHandshakeUnix)} />
|
||||
<Detail label="Status since" value={fmtRelative(peer.connStatusUpdateUnix)} />
|
||||
<Detail
|
||||
label="Bytes rx / tx"
|
||||
value={`${fmtBytes(peer.bytesRx)} / ${fmtBytes(peer.bytesTx)}`}
|
||||
/>
|
||||
<Detail
|
||||
label="Local candidate"
|
||||
value={
|
||||
peer.localIceCandidateType
|
||||
? `${peer.localIceCandidateType} (${peer.localIceCandidateEndpoint || "—"})`
|
||||
: "—"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<Detail
|
||||
label="Remote candidate"
|
||||
value={
|
||||
peer.remoteIceCandidateType
|
||||
? `${peer.remoteIceCandidateType} (${peer.remoteIceCandidateEndpoint || "—"})`
|
||||
: "—"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
{peer.relayed && (
|
||||
<Detail label="Relay" value={peer.relayAddress || "—"} mono />
|
||||
)}
|
||||
{peer.networks.length > 0 && (
|
||||
<Detail label="Networks" value={peer.networks.join(", ")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-0 gap-2">
|
||||
<span className="shrink-0 text-nb-gray-500">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-nb-gray-700 dark:text-nb-gray-200",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
||||
const Icon = expanded ? ChevronDown : ChevronRight;
|
||||
return <Icon className="h-4 w-4 shrink-0 text-nb-gray-400" strokeWidth={1.5} />;
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: string }) {
|
||||
const cls = "h-2 w-2 rounded-full shrink-0";
|
||||
switch (state) {
|
||||
case "Connected":
|
||||
return <span className={cn(cls, "bg-green-500")} title="Connected" />;
|
||||
case "Connecting":
|
||||
return <span className={cn(cls, "bg-netbird animate-pulse")} title="Connecting" />;
|
||||
case "Idle":
|
||||
return <span className={cn(cls, "bg-yellow-500")} title="Idle" />;
|
||||
default:
|
||||
return <span className={cn(cls, "bg-nb-gray-400")} title={state || "Disconnected"} />;
|
||||
}
|
||||
}
|
||||
|
||||
function RouteIcon({ relayed, connected }: { relayed: boolean; connected: boolean }) {
|
||||
if (!connected) {
|
||||
return <span className="w-4 shrink-0" />;
|
||||
}
|
||||
if (relayed) {
|
||||
return (
|
||||
<Network
|
||||
className="h-4 w-4 text-yellow-600"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<title>Relayed</title>
|
||||
</Network>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Zap className="h-4 w-4 text-green-600" strokeWidth={1.5}>
|
||||
<title>P2P</title>
|
||||
</Zap>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function fmtRelative(unixSec: number): string {
|
||||
if (!unixSec) return "—";
|
||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
|
||||
if (ageSec < 60) return `${ageSec}s ago`;
|
||||
if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`;
|
||||
if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h ago`;
|
||||
return `${Math.floor(ageSec / 86400)}d ago`;
|
||||
}
|
||||
179
client/ui/frontend/src/screens/Profiles.tsx
Normal file
179
client/ui/frontend/src/screens/Profiles.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Profiles as ProfilesSvc,
|
||||
Connection,
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Profiles.tsx
|
||||
} from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
========
|
||||
} from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Profiles.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import { Card } from "../components/Card";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
export default function Profiles() {
|
||||
const { username, loaded, refresh: refreshProfile, switchProfile } = useProfile();
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!username) return;
|
||||
try {
|
||||
const list = await ProfilesSvc.List(username);
|
||||
setProfiles(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) refresh();
|
||||
}, [loaded, refresh]);
|
||||
|
||||
const select = async (name: string) => {
|
||||
try {
|
||||
await switchProfile(name);
|
||||
await Connection.Up({ profileName: name, username });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const deregister = async (name: string) => {
|
||||
try {
|
||||
await Connection.Logout({ profileName: name, username });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (name: string) => {
|
||||
if (name === "default") return;
|
||||
try {
|
||||
await ProfilesSvc.Remove({ profileName: name, username });
|
||||
await refreshProfile();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Profiles</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={refresh}>
|
||||
<RefreshCw className="h-3.5 w-3.5" strokeWidth={1.5} /> Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAdding(true)}>
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={1.5} /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{profiles.map((p) => (
|
||||
<Card key={p.name} className="flex items-center gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="active-profile"
|
||||
checked={p.isActive}
|
||||
onChange={() => select(p.name)}
|
||||
className="h-4 w-4 accent-netbird"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{p.name}</p>
|
||||
{p.isActive && <p className="text-xs text-nb-gray-500">Active</p>}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => deregister(p.name)}>
|
||||
Deregister
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={p.name === "default"}
|
||||
onClick={() => remove(p.name)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="text-sm text-nb-gray-500">No profiles.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<AddDialog
|
||||
username={username}
|
||||
onClose={() => setAdding(false)}
|
||||
onAdded={async () => {
|
||||
setAdding(false);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDialog({
|
||||
username,
|
||||
onClose,
|
||||
onAdded,
|
||||
}: {
|
||||
username: string;
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
await ProfilesSvc.Add({ profileName: name.trim(), username });
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="w-80 rounded-lg border border-nb-gray-200 bg-white p-4 shadow-lg dark:border-nb-gray-800 dark:bg-nb-gray-925"
|
||||
>
|
||||
<h2 className="mb-3 text-base font-semibold">New profile</h2>
|
||||
<Input
|
||||
autoFocus
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
{err && <p className="mt-2 text-xs text-red-500">{err}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
client/ui/frontend/src/screens/QuickActions.tsx
Normal file
44
client/ui/frontend/src/screens/QuickActions.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CheckCircle2, Circle, Loader2, Power } from "lucide-react";
|
||||
import { useStatus } from "../hooks/useStatus";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/QuickActions.tsx
|
||||
import { Connection } from "@bindings/services";
|
||||
========
|
||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/QuickActions.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export default function QuickActions() {
|
||||
const { status } = useStatus();
|
||||
const state = status?.status ?? "Disconnected";
|
||||
const connected = state === "Connected";
|
||||
const connecting = state === "Connecting";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
|
||||
<Icon state={state} />
|
||||
<p className="text-lg font-medium">{state}</p>
|
||||
{connected ? (
|
||||
<Button variant="secondary" onClick={() => Connection.Down()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => Connection.Up({ profileName: "", username: "" })} disabled={connecting}>
|
||||
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ state }: { state: string }) {
|
||||
const cls = "h-12 w-12";
|
||||
switch (state) {
|
||||
case "Connected":
|
||||
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
|
||||
case "Connecting":
|
||||
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
|
||||
default:
|
||||
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
|
||||
}
|
||||
}
|
||||
251
client/ui/frontend/src/screens/Settings.tsx
Normal file
251
client/ui/frontend/src/screens/Settings.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Settings as SettingsSvc,
|
||||
Profiles as ProfilesSvc,
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Settings.tsx
|
||||
} from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
========
|
||||
} from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Settings.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Input } from "../components/Input";
|
||||
import { Switch } from "../components/Switch";
|
||||
import { Tabs } from "../components/Tabs";
|
||||
|
||||
interface Ctx {
|
||||
cfg: Config;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [profile, setProfile] = useState("");
|
||||
const [cfg, setCfg] = useState<Config | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const u = await ProfilesSvc.Username();
|
||||
const active = await ProfilesSvc.GetActive();
|
||||
const profileName = active.profileName || "default";
|
||||
setUsername(u);
|
||||
setProfile(profileName);
|
||||
const c = await SettingsSvc.GetConfig({ profileName, username: u });
|
||||
setCfg(c);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setField: Ctx["setField"] = (k, v) => {
|
||||
setCfg((c) => (c ? { ...c, [k]: v } : c));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!cfg) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
profileName: profile,
|
||||
username,
|
||||
managementUrl: cfg.managementUrl,
|
||||
adminUrl: cfg.adminUrl,
|
||||
interfaceName: cfg.interfaceName,
|
||||
wireguardPort: cfg.wireguardPort,
|
||||
mtu: cfg.mtu,
|
||||
preSharedKey: cfg.preSharedKey,
|
||||
disableAutoConnect: cfg.disableAutoConnect,
|
||||
serverSshAllowed: cfg.serverSshAllowed,
|
||||
rosenpassEnabled: cfg.rosenpassEnabled,
|
||||
rosenpassPermissive: cfg.rosenpassPermissive,
|
||||
disableNotifications: cfg.disableNotifications,
|
||||
lazyConnectionEnabled: cfg.lazyConnectionEnabled,
|
||||
blockInbound: cfg.blockInbound,
|
||||
networkMonitor: cfg.networkMonitor,
|
||||
disableClientRoutes: cfg.disableClientRoutes,
|
||||
disableServerRoutes: cfg.disableServerRoutes,
|
||||
disableDns: cfg.disableDns,
|
||||
disableIpv6: cfg.disableIpv6,
|
||||
blockLanAccess: cfg.blockLanAccess,
|
||||
enableSshRoot: cfg.enableSshRoot,
|
||||
enableSshSftp: cfg.enableSshSftp,
|
||||
enableSshLocalPortForwarding: cfg.enableSshLocalPortForwarding,
|
||||
enableSshRemotePortForwarding: cfg.enableSshRemotePortForwarding,
|
||||
disableSshAuth: cfg.disableSshAuth,
|
||||
sshJwtCacheTtl: cfg.sshJwtCacheTtl,
|
||||
});
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!cfg) {
|
||||
return <div className="p-6 text-sm text-nb-gray-500">Loading…</div>;
|
||||
}
|
||||
|
||||
const ctx: Ctx = { cfg, setField };
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-nb-gray-200 px-6 py-3 dark:border-nb-gray-800">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="px-6 py-2 text-sm text-red-500">{error}</p>}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ value: "conn", label: "Connection", content: <ConnectionTab {...ctx} /> },
|
||||
{ value: "net", label: "Network", content: <NetworkTab {...ctx} /> },
|
||||
{ value: "ssh", label: "SSH", content: <SSHTab {...ctx} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionTab({ cfg, setField }: Ctx) {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-4 p-6">
|
||||
<Input
|
||||
label="Management URL"
|
||||
value={cfg.managementUrl}
|
||||
onChange={(e) => setField("managementUrl", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Pre-shared key"
|
||||
type="password"
|
||||
value={cfg.preSharedKey}
|
||||
onChange={(e) => setField("preSharedKey", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Interface name"
|
||||
value={cfg.interfaceName}
|
||||
onChange={(e) => setField("interfaceName", e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="WireGuard port"
|
||||
type="number"
|
||||
value={cfg.wireguardPort}
|
||||
onChange={(e) => setField("wireguardPort", Number(e.target.value))}
|
||||
/>
|
||||
<Input
|
||||
label="MTU"
|
||||
type="number"
|
||||
value={cfg.mtu}
|
||||
onChange={(e) => setField("mtu", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cfg.rosenpassEnabled}
|
||||
onChange={(v) => setField("rosenpassEnabled", v)}
|
||||
label="Rosenpass (post-quantum)"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.rosenpassPermissive}
|
||||
onChange={(v) => setField("rosenpassPermissive", v)}
|
||||
label="Rosenpass permissive mode"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkTab({ cfg, setField }: Ctx) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-4 p-6">
|
||||
<Switch
|
||||
checked={cfg.networkMonitor}
|
||||
onChange={(v) => setField("networkMonitor", v)}
|
||||
label="Network monitor"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableDns}
|
||||
onChange={(v) => setField("disableDns", v)}
|
||||
label="Disable DNS"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableClientRoutes}
|
||||
onChange={(v) => setField("disableClientRoutes", v)}
|
||||
label="Disable client routes"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableServerRoutes}
|
||||
onChange={(v) => setField("disableServerRoutes", v)}
|
||||
label="Disable server routes"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableIpv6}
|
||||
onChange={(v) => setField("disableIpv6", v)}
|
||||
label="Disable IPv6 overlay addressing"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.blockLanAccess}
|
||||
onChange={(v) => setField("blockLanAccess", v)}
|
||||
label="Block LAN access"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.blockInbound}
|
||||
onChange={(v) => setField("blockInbound", v)}
|
||||
label="Block inbound connections"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SSHTab({ cfg, setField }: Ctx) {
|
||||
return (
|
||||
<div className="grid max-w-xl gap-4 p-6">
|
||||
<Switch
|
||||
checked={cfg.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
label="Server SSH allowed"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
label="SSH root login"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
label="SFTP"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshLocalPortForwarding}
|
||||
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
|
||||
label="Local port forwarding"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.enableSshRemotePortForwarding}
|
||||
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
|
||||
label="Remote port forwarding"
|
||||
/>
|
||||
<Switch
|
||||
checked={cfg.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", v)}
|
||||
label="Disable SSH auth"
|
||||
/>
|
||||
<Input
|
||||
label="JWT cache TTL (seconds)"
|
||||
type="number"
|
||||
value={cfg.sshJwtCacheTtl}
|
||||
onChange={(e) => setField("sshJwtCacheTtl", Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
client/ui/frontend/src/screens/Status.tsx
Normal file
202
client/ui/frontend/src/screens/Status.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useStatus } from "../hooks/useStatus";
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Status.tsx
|
||||
import { Connection } from "@bindings/services";
|
||||
import type { SystemEvent } from "@bindings/services/models.js";
|
||||
========
|
||||
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||
import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Status.tsx
|
||||
import { Button } from "../components/Button";
|
||||
import { Card } from "../components/Card";
|
||||
import { cn } from "../lib/cn";
|
||||
import { NetBirdConnectToggle, ConnectionState } from "../components/NetBirdConnectToggle";
|
||||
|
||||
export default function Status() {
|
||||
const { status, error } = useStatus();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const connState = status?.status ?? "Disconnected";
|
||||
const connected = connState === "Connected";
|
||||
const connecting = connState === "Connecting";
|
||||
// The daemon reports "NeedsLogin" on a fresh install or after a session
|
||||
// expires; "SessionExpired" once a previously good session lapses. In both
|
||||
// cases Connect would fail without a fresh SSO login.
|
||||
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired";
|
||||
// Always offer Login while we aren't Connected — including Connecting,
|
||||
// because a stuck Login on the daemon leaves us in Connecting forever and
|
||||
// the user has no other way out. Disconnect is the manual unstick path.
|
||||
const showLogin = !connected;
|
||||
|
||||
<<<<<<<< HEAD:client/ui/frontend/src/screens/Status.tsx
|
||||
const toggleState: ConnectionState =
|
||||
connected ? ConnectionState.Connected
|
||||
: connecting ? ConnectionState.Connecting
|
||||
: ConnectionState.Disconnected;
|
||||
|
||||
========
|
||||
const login = () => navigate("/login");
|
||||
>>>>>>>> ui-refactor:client/ui/frontend/src/pages/Status.tsx
|
||||
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
|
||||
const disconnect = () => Connection.Down().catch(console.error);
|
||||
const toggleConnection = () => (connected ? disconnect() : connect());
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StateIcon state={connState} />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold leading-none">{connState}</h1>
|
||||
<p className="mt-1 text-sm text-nb-gray-500">
|
||||
{status?.local.fqdn || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{needsLogin ? (
|
||||
<Button onClick={login}>
|
||||
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={connect} disabled={connected || connecting}>
|
||||
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
|
||||
</Button>
|
||||
)}
|
||||
{showLogin && !needsLogin && (
|
||||
<Button onClick={login} variant="secondary">
|
||||
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={disconnect} variant="secondary" disabled={!connected}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4" strokeWidth={1.5} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoCard label="Local IP" value={status?.local.ip || "—"} />
|
||||
<InfoCard label="Peers" value={String(status?.peers?.length ?? 0)} />
|
||||
<LinkCard label="Management" link={status?.management} />
|
||||
<LinkCard label="Signal" link={status?.signal} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-semibold text-nb-gray-700 dark:text-nb-gray-200">
|
||||
Recent events
|
||||
</h2>
|
||||
{(() => {
|
||||
const events = dedupEvents(status?.events ?? []).slice(0, 8);
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-nb-gray-500">No recent events.</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{events.map((e, i) => (
|
||||
<li key={`${e.id}-${i}`} className="flex gap-2">
|
||||
<span className="shrink-0 font-mono text-xs text-nb-gray-500">
|
||||
{e.severity}
|
||||
</span>
|
||||
<span className="text-nb-gray-700 dark:text-nb-gray-200">
|
||||
{e.userMessage || e.message}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center bg-nb-gray p-10">
|
||||
<NetBirdConnectToggle state={toggleState} onClick={toggleConnection} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateIcon({ state }: { state: string }) {
|
||||
const cls = "h-7 w-7";
|
||||
switch (state) {
|
||||
case "Connected":
|
||||
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
|
||||
case "Connecting":
|
||||
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
|
||||
case "Error":
|
||||
return <AlertTriangle className={cn(cls, "text-red-500")} strokeWidth={1.5} />;
|
||||
default:
|
||||
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
|
||||
}
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
|
||||
<p className="mt-1 truncate font-mono text-sm">{value}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// dedupEvents collapses repeated daemon events that carry the same logical
|
||||
// content. The daemon emits one "new_version_available" event per check tick,
|
||||
// so its 10-event ring buffer fills with duplicates after a quiet hour. Same
|
||||
// goes for periodic "DNS unreachable" or "auth retry" events. We key by
|
||||
// message + a small set of identity-bearing metadata fields and keep the
|
||||
// newest occurrence (the events array is already in publish order).
|
||||
function dedupEvents(events: SystemEvent[]): SystemEvent[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SystemEvent[] = [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const md = e.metadata ?? {};
|
||||
const key = [
|
||||
e.severity,
|
||||
e.category,
|
||||
e.userMessage || e.message,
|
||||
md["new_version_available"] ?? "",
|
||||
md["enforced"] ?? "",
|
||||
].join("|");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[dedup]", { key, event: e });
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.unshift(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function LinkCard({
|
||||
label,
|
||||
link,
|
||||
}: {
|
||||
label: string;
|
||||
link?: { url: string; connected: boolean; error?: string };
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
link?.connected ? "bg-green-500" : "bg-nb-gray-400",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-nb-gray-600 dark:text-nb-gray-300">
|
||||
{link?.url || "—"}
|
||||
</p>
|
||||
{link?.error && (
|
||||
<p className="mt-1 truncate text-xs text-red-500">{link.error}</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
61
client/ui/frontend/src/screens/Update.tsx
Normal file
61
client/ui/frontend/src/screens/Update.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Update as UpdateSvc } from "@bindings/services";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export default function Update() {
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e)));
|
||||
|
||||
const start = Date.now();
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - start > TIMEOUT_MS) {
|
||||
setError("Update timed out.");
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await UpdateSvc.GetInstallerResult();
|
||||
if (r.success) {
|
||||
setDone(true);
|
||||
clearInterval(timer);
|
||||
} else if (r.errorMsg) {
|
||||
setError(r.errorMsg);
|
||||
clearInterval(timer);
|
||||
}
|
||||
} catch {
|
||||
// installer not finished yet
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
{done ? (
|
||||
<h1 className="text-xl font-semibold text-green-500">Update complete</h1>
|
||||
) : error ? (
|
||||
<h1 className="text-xl font-semibold text-red-500">{error}</h1>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
|
||||
<h1 className="text-xl font-semibold">Updating…</h1>
|
||||
<p className="mt-1 text-sm text-nb-gray-500">
|
||||
Please don't close this window.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user