diff --git a/client/ui-wails/frontend/src/assets/fonts/InterVariable.ttf b/client/ui-wails/frontend/src/assets/fonts/InterVariable.ttf new file mode 100644 index 000000000..4ab79e010 Binary files /dev/null and b/client/ui-wails/frontend/src/assets/fonts/InterVariable.ttf differ diff --git a/client/ui-wails/frontend/src/assets/fonts/JetBrainsMonoVariable.ttf b/client/ui-wails/frontend/src/assets/fonts/JetBrainsMonoVariable.ttf new file mode 100644 index 000000000..b60e77f5d Binary files /dev/null and b/client/ui-wails/frontend/src/assets/fonts/JetBrainsMonoVariable.ttf differ diff --git a/client/ui-wails/frontend/src/assets/logos/fonts/inter.ttf b/client/ui-wails/frontend/src/assets/logos/fonts/inter.ttf new file mode 100644 index 000000000..e72470871 Binary files /dev/null and b/client/ui-wails/frontend/src/assets/logos/fonts/inter.ttf differ diff --git a/client/ui-wails/frontend/src/assets/logos/netbird-full.svg b/client/ui-wails/frontend/src/assets/logos/netbird-full.svg new file mode 100644 index 000000000..f925d5761 --- /dev/null +++ b/client/ui-wails/frontend/src/assets/logos/netbird-full.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/client/ui-wails/frontend/src/assets/logos/netbird.svg b/client/ui-wails/frontend/src/assets/logos/netbird.svg new file mode 100644 index 000000000..6254931c6 --- /dev/null +++ b/client/ui-wails/frontend/src/assets/logos/netbird.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/client/ui-wails/frontend/src/components/PlaceholderHeader.tsx b/client/ui-wails/frontend/src/components/PlaceholderHeader.tsx new file mode 100644 index 000000000..e390f9cd5 --- /dev/null +++ b/client/ui-wails/frontend/src/components/PlaceholderHeader.tsx @@ -0,0 +1,8 @@ +export default function PlaceholderHeader() { + return ( +
+ ); +} diff --git a/client/ui-wails/frontend/src/globals.css b/client/ui-wails/frontend/src/globals.css new file mode 100644 index 000000000..283464560 --- /dev/null +++ b/client/ui-wails/frontend/src/globals.css @@ -0,0 +1,20 @@ +@font-face { + font-family: "Inter Variable"; + font-style: normal; + font-weight: 100 900; + src: url("./assets/fonts/InterVariable.ttf") format("truetype"); +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; +} + +body { + @apply bg-nb-gray font-sans text-nb-gray-200 antialiased; +} diff --git a/client/ui-wails/frontend/src/screens/Debug.tsx b/client/ui-wails/frontend/src/screens/Debug.tsx new file mode 100644 index 000000000..929e4325f --- /dev/null +++ b/client/ui-wails/frontend/src/screens/Debug.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +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(null); + const [error, setError] = useState(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 ( +
+

Debug bundle

+ + + + + + {upload && ( + setUploadUrl(e.target.value)} + /> + )} + setLogFiles(Number(e.target.value))} + /> +
+ +
+
+ + {error &&

{error}

} + + {result && ( + + {result.path && ( +

+ Path:{" "} + {result.path} +

+ )} + {result.uploadedKey && ( +

+ Uploaded key:{" "} + {result.uploadedKey} +

+ )} + {result.uploadFailureReason && ( +

+ Upload failed: {result.uploadFailureReason} +

+ )} +
+ )} +
+ ); +} diff --git a/client/ui-wails/frontend/src/screens/LoginUrl.tsx b/client/ui-wails/frontend/src/screens/LoginUrl.tsx new file mode 100644 index 000000000..71c8e88a1 --- /dev/null +++ b/client/ui-wails/frontend/src/screens/LoginUrl.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { ExternalLink } from "lucide-react"; +import { Button } from "../components/Button"; + +export default function LoginUrl() { + const [url, setUrl] = useState(""); + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.split("?")[1] ?? ""); + setUrl(params.get("url") ?? ""); + }, []); + + if (!url) { + return ( +
+ No login URL provided. +
+ ); + } + + return ( +
+

Continue in your browser

+

+ Open the following URL to finish signing in. +

+ +

{url}

+
+ ); +} diff --git a/client/ui-wails/frontend/src/screens/Networks.tsx b/client/ui-wails/frontend/src/screens/Networks.tsx new file mode 100644 index 000000000..ea3bd055e --- /dev/null +++ b/client/ui-wails/frontend/src/screens/Networks.tsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +import { Button } from "../components/Button"; +import { Tabs } from "../components/Tabs"; + +export default function Networks() { + const [routes, setRoutes] = useState([]); + const [error, setError] = useState(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"), [routes]); + + return ( +
+
+

Networks

+ +
+ + {error && ( +

{error}

+ )} + +
+ , + }, + { + value: "overlap", + label: `Overlapping (${overlapping.length})`, + content: , + }, + { + value: "exit", + label: `Exit-node (${exitNodes.length})`, + content: , + }, + ]} + /> +
+
+ ); +} + +function NetworkList({ + routes, + onToggle, + onSetAll, +}: { + routes: Network[]; + onToggle: (id: string, selected: boolean) => void; + onSetAll: (ids: string[], on: boolean) => void; +}) { + if (routes.length === 0) { + return

No networks.

; + } + const ids = routes.map((r) => r.id); + return ( +
+
+ + +
+
    + {routes.map((r) => ( +
  • + onToggle(r.id, r.selected)} + className="mt-1 h-4 w-4 accent-netbird" + /> +
    +

    {r.id}

    +

    {r.range}

    + {r.domains.length > 0 && ( +

    + {r.domains.join(", ")} +

    + )} +
    +
  • + ))} +
+
+ ); +} + +function filterOverlapping(routes: Network[]): Network[] { + const byRange = new Map(); + 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; +} diff --git a/client/ui-wails/frontend/src/screens/Peers.tsx b/client/ui-wails/frontend/src/screens/Peers.tsx new file mode 100644 index 000000000..f1522ca87 --- /dev/null +++ b/client/ui-wails/frontend/src/screens/Peers.tsx @@ -0,0 +1,211 @@ +import { useMemo, useState } from "react"; +import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react"; +import { useStatus } from "../hooks/useStatus"; +import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +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(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 ( +
+
+

+ Peers + + {status?.peers?.length ?? 0} + +

+
+ setFilter(e.target.value)} + /> +
+
+ + {peers.length === 0 ? ( + + {status?.peers?.length === 0 + ? "No peers visible from this client." + : "No peers match the filter."} + + ) : ( +
    + {peers.map((p) => ( + setExpanded(expanded === p.pubKey ? null : p.pubKey)} + /> + ))} +
+ )} +
+ ); +} + +function PeerRow({ + peer, + expanded, + onToggle, +}: { + peer: PeerStatus; + expanded: boolean; + onToggle: () => void; +}) { + return ( +
  • + + + {expanded && } +
  • + ); +} + +function PeerDetails({ peer }: { peer: PeerStatus }) { + return ( +
    + + + + + + + {peer.relayed && ( + + )} + {peer.networks.length > 0 && ( + + )} +
    + ); +} + +function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
    + {label} + + {value} + +
    + ); +} + +function ChevronIcon({ expanded }: { expanded: boolean }) { + const Icon = expanded ? ChevronDown : ChevronRight; + return ; +} + +function StateBadge({ state }: { state: string }) { + const cls = "h-2 w-2 rounded-full shrink-0"; + switch (state) { + case "Connected": + return ; + case "Connecting": + return ; + case "Idle": + return ; + default: + return ; + } +} + +function RouteIcon({ relayed, connected }: { relayed: boolean; connected: boolean }) { + if (!connected) { + return ; + } + if (relayed) { + return ( + + Relayed + + ); + } + return ( + + P2P + + ); +} + +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`; +} diff --git a/client/ui-wails/frontend/src/screens/Profiles.tsx b/client/ui-wails/frontend/src/screens/Profiles.tsx new file mode 100644 index 000000000..3a1035afa --- /dev/null +++ b/client/ui-wails/frontend/src/screens/Profiles.tsx @@ -0,0 +1,173 @@ +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { Plus, RefreshCw } from "lucide-react"; +import { + Profiles as ProfilesSvc, + Connection, +} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +import { Button } from "../components/Button"; +import { Input } from "../components/Input"; +import { Card } from "../components/Card"; + +export default function Profiles() { + const [username, setUsername] = useState(""); + const [profiles, setProfiles] = useState([]); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + + const refresh = useCallback(async () => { + try { + const u = username || (await ProfilesSvc.Username()); + if (!username) setUsername(u); + const list = await ProfilesSvc.List(u); + setProfiles(list); + setError(null); + } catch (e) { + setError(String(e)); + } + }, [username]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const select = async (name: string) => { + try { + await ProfilesSvc.Switch({ profileName: name, username }); + 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 refresh(); + } catch (e) { + setError(String(e)); + } + }; + + return ( +
    +
    +

    Profiles

    +
    + + +
    +
    + + {error &&

    {error}

    } + +
    + {profiles.map((p) => ( + + select(p.name)} + className="h-4 w-4 accent-netbird" + /> +
    +

    {p.name}

    + {p.isActive &&

    Active

    } +
    + + +
    + ))} + {profiles.length === 0 && ( +

    No profiles.

    + )} +
    + + {adding && ( + setAdding(false)} + onAdded={async () => { + setAdding(false); + await refresh(); + }} + /> + )} +
    + ); +} + +function AddDialog({ + username, + onClose, + onAdded, +}: { + username: string; + onClose: () => void; + onAdded: () => void; +}) { + const [name, setName] = useState(""); + const [err, setErr] = useState(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 ( +
    +
    +

    New profile

    + setName(e.target.value)} + /> + {err &&

    {err}

    } +
    + + +
    +
    +
    + ); +} diff --git a/client/ui-wails/frontend/src/screens/QuickActions.tsx b/client/ui-wails/frontend/src/screens/QuickActions.tsx new file mode 100644 index 000000000..749b4bf8a --- /dev/null +++ b/client/ui-wails/frontend/src/screens/QuickActions.tsx @@ -0,0 +1,40 @@ +import { CheckCircle2, Circle, Loader2, Power } from "lucide-react"; +import { useStatus } from "../hooks/useStatus"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +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 ( +
    + +

    {state}

    + {connected ? ( + + ) : ( + + )} +
    + ); +} + +function Icon({ state }: { state: string }) { + const cls = "h-12 w-12"; + switch (state) { + case "Connected": + return ; + case "Connecting": + return ; + default: + return ; + } +} diff --git a/client/ui-wails/frontend/src/screens/Settings.tsx b/client/ui-wails/frontend/src/screens/Settings.tsx new file mode 100644 index 000000000..3781611b6 --- /dev/null +++ b/client/ui-wails/frontend/src/screens/Settings.tsx @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Settings as SettingsSvc, + Profiles as ProfilesSvc, +} 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 { 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: K, v: Config[K]) => void; +} + +export default function Settings() { + const [username, setUsername] = useState(""); + const [profile, setProfile] = useState(""); + const [cfg, setCfg] = useState(null); + const [error, setError] = useState(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, + 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
    Loading…
    ; + } + + const ctx: Ctx = { cfg, setField }; + + return ( +
    +
    +

    Settings

    + +
    + {error &&

    {error}

    } +
    + }, + { value: "net", label: "Network", content: }, + { value: "ssh", label: "SSH", content: }, + ]} + /> +
    +
    + ); +} + +function ConnectionTab({ cfg, setField }: Ctx) { + return ( +
    + setField("managementUrl", e.target.value)} + /> + setField("preSharedKey", e.target.value)} + /> + setField("interfaceName", e.target.value)} + /> +
    + setField("wireguardPort", Number(e.target.value))} + /> + setField("mtu", Number(e.target.value))} + /> +
    + setField("rosenpassEnabled", v)} + label="Rosenpass (post-quantum)" + /> + setField("rosenpassPermissive", v)} + label="Rosenpass permissive mode" + /> +
    + ); +} + +function NetworkTab({ cfg, setField }: Ctx) { + return ( +
    + setField("networkMonitor", v)} + label="Network monitor" + /> + setField("disableDns", v)} + label="Disable DNS" + /> + setField("disableClientRoutes", v)} + label="Disable client routes" + /> + setField("disableServerRoutes", v)} + label="Disable server routes" + /> + setField("blockLanAccess", v)} + label="Block LAN access" + /> + setField("blockInbound", v)} + label="Block inbound connections" + /> +
    + ); +} + +function SSHTab({ cfg, setField }: Ctx) { + return ( +
    + setField("serverSshAllowed", v)} + label="Server SSH allowed" + /> + setField("enableSshRoot", v)} + label="SSH root login" + /> + setField("enableSshSftp", v)} + label="SFTP" + /> + setField("enableSshLocalPortForwarding", v)} + label="Local port forwarding" + /> + setField("enableSshRemotePortForwarding", v)} + label="Remote port forwarding" + /> + setField("disableSshAuth", v)} + label="Disable SSH auth" + /> + setField("sshJwtCacheTtl", Number(e.target.value))} + /> +
    + ); +} diff --git a/client/ui-wails/frontend/src/screens/Status.tsx b/client/ui-wails/frontend/src/screens/Status.tsx new file mode 100644 index 000000000..a666f755c --- /dev/null +++ b/client/ui-wails/frontend/src/screens/Status.tsx @@ -0,0 +1,172 @@ +import { CheckCircle2, Circle, Loader2, AlertTriangle, Power } from "lucide-react"; +import { useStatus } from "../hooks/useStatus"; +import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; +import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js"; +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 connState = status?.status ?? "Disconnected"; + const connected = connState === "Connected"; + const connecting = connState === "Connecting"; + + const toggleState: ConnectionState = + connected ? ConnectionState.Connected + : connecting ? ConnectionState.Connecting + : ConnectionState.Disconnected; + + const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error); + const disconnect = () => Connection.Down().catch(console.error); + const toggleConnection = () => (connected ? disconnect() : connect()); + + return ( +
    +
    +
    + +
    +

    {connState}

    +

    + {status?.local.fqdn || "—"} +

    +
    +
    +
    + + +
    +
    + + {error && ( +
    + + {error} +
    + )} + +
    + + + + +
    + + +

    + Recent events +

    + {(() => { + const events = dedupEvents(status?.events ?? []).slice(0, 8); + if (events.length === 0) { + return

    No recent events.

    ; + } + return ( +
      + {events.map((e, i) => ( +
    • + + {e.severity} + + + {e.userMessage || e.message} + +
    • + ))} +
    + ); + })()} +
    + +
    + +
    +
    + ); +} + +function StateIcon({ state }: { state: string }) { + const cls = "h-7 w-7"; + switch (state) { + case "Connected": + return ; + case "Connecting": + return ; + case "Error": + return ; + default: + return ; + } +} + +function InfoCard({ label, value }: { label: string; value: string }) { + return ( + +

    {label}

    +

    {value}

    +
    + ); +} + +// 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(); + 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 ( + +
    +

    {label}

    + +
    +

    + {link?.url || "—"} +

    + {link?.error && ( +

    {link.error}

    + )} +
    + ); +} diff --git a/client/ui-wails/frontend/src/screens/Update.tsx b/client/ui-wails/frontend/src/screens/Update.tsx new file mode 100644 index 000000000..04d9eb245 --- /dev/null +++ b/client/ui-wails/frontend/src/screens/Update.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; + +const TIMEOUT_MS = 15 * 60 * 1000; + +export default function Update() { + const [done, setDone] = useState(false); + const [error, setError] = useState(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 ( +
    +
    + {done ? ( +

    Update complete

    + ) : error ? ( +

    {error}

    + ) : ( + <> + +

    Updating…

    +

    + Please don't close this window. +

    + + )} +
    +
    + ); +}