mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-30 11:31:29 +02:00
add peers, networks and exit node list (wip)
This commit is contained in:
@@ -69,8 +69,8 @@
|
||||
"nav.peers.description": "{online} von {total} online",
|
||||
"nav.resources.title": "Ressourcen",
|
||||
"nav.resources.description": "{active} von {total} aktiv",
|
||||
"nav.exitNode.title": "Exit-Node {location}",
|
||||
"nav.exitNode.flagAlt": "{country}",
|
||||
"nav.exitNode.title": "Exit-Node",
|
||||
"nav.exitNode.none": "{total} verfügbar",
|
||||
|
||||
"header.openSettings": "Einstellungen öffnen",
|
||||
"header.togglePanel": "Seitenleiste umschalten",
|
||||
@@ -297,6 +297,19 @@
|
||||
"peers.filter.offline": "Offline",
|
||||
"peers.empty": "Keine Peers entsprechen den aktuellen Filtern.",
|
||||
|
||||
"networks.search.placeholder": "Nach Netzwerk, Bereich oder Domain suchen",
|
||||
"networks.filter.all": "Alle",
|
||||
"networks.filter.selected": "Ausgewählt",
|
||||
"networks.filter.overlapping": "Überlappend",
|
||||
"networks.empty": "Keine Netzwerke entsprechen den aktuellen Filtern.",
|
||||
"networks.selected": "Ausgewählt",
|
||||
"networks.unselected": "Nicht ausgewählt",
|
||||
|
||||
"exitNodes.search.placeholder": "Exit Nodes suchen",
|
||||
"exitNodes.empty": "Keine Exit Nodes verfügbar.",
|
||||
"exitNodes.active": "Aktiv",
|
||||
"exitNodes.inactive": "Inaktiv",
|
||||
|
||||
"quickActions.connect": "Verbinden",
|
||||
"quickActions.disconnect": "Trennen",
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
"nav.peers.description": "{online} of {total} Online",
|
||||
"nav.resources.title": "Resources",
|
||||
"nav.resources.description": "{active} of {total} Active",
|
||||
"nav.exitNode.title": "Exit Node {location}",
|
||||
"nav.exitNode.flagAlt": "{country}",
|
||||
"nav.exitNode.title": "Exit Node",
|
||||
"nav.exitNode.none": "{total} available",
|
||||
|
||||
"header.openSettings": "Open settings",
|
||||
"header.togglePanel": "Toggle side panel",
|
||||
@@ -313,6 +313,19 @@
|
||||
"peers.filter.offline": "Offline",
|
||||
"peers.empty": "No peers match the current filters.",
|
||||
|
||||
"networks.search.placeholder": "Search by network, range or domain",
|
||||
"networks.filter.all": "All",
|
||||
"networks.filter.selected": "Selected",
|
||||
"networks.filter.overlapping": "Overlapping",
|
||||
"networks.empty": "No networks match the current filters.",
|
||||
"networks.selected": "Selected",
|
||||
"networks.unselected": "Not selected",
|
||||
|
||||
"exitNodes.search.placeholder": "Search exit nodes",
|
||||
"exitNodes.empty": "No exit nodes available.",
|
||||
"exitNodes.active": "Active",
|
||||
"exitNodes.inactive": "Inactive",
|
||||
|
||||
"quickActions.connect": "Connect",
|
||||
"quickActions.disconnect": "Disconnect",
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
"nav.peers.description": "{online} / {total} online",
|
||||
"nav.resources.title": "Erőforrások",
|
||||
"nav.resources.description": "{active} / {total} aktív",
|
||||
"nav.exitNode.title": "Kilépő csomópont: {location}",
|
||||
"nav.exitNode.flagAlt": "{country}",
|
||||
"nav.exitNode.title": "Kilépő csomópont",
|
||||
"nav.exitNode.none": "{total} elérhető",
|
||||
|
||||
"header.openSettings": "Beállítások megnyitása",
|
||||
"header.togglePanel": "Oldalsó panel váltása",
|
||||
@@ -297,6 +297,19 @@
|
||||
"peers.filter.offline": "Offline",
|
||||
"peers.empty": "Egyetlen társ sem felel meg a jelenlegi szűrőknek.",
|
||||
|
||||
"networks.search.placeholder": "Keresés hálózat, tartomány vagy domain alapján",
|
||||
"networks.filter.all": "Összes",
|
||||
"networks.filter.selected": "Kiválasztott",
|
||||
"networks.filter.overlapping": "Átfedő",
|
||||
"networks.empty": "Egyetlen hálózat sem felel meg a jelenlegi szűrőknek.",
|
||||
"networks.selected": "Kiválasztva",
|
||||
"networks.unselected": "Nincs kiválasztva",
|
||||
|
||||
"exitNodes.search.placeholder": "Keresés a kilépő csomópontok között",
|
||||
"exitNodes.empty": "Nincs elérhető kilépő csomópont.",
|
||||
"exitNodes.active": "Aktív",
|
||||
"exitNodes.inactive": "Inaktív",
|
||||
|
||||
"quickActions.connect": "Csatlakozás",
|
||||
"quickActions.disconnect": "Bontás",
|
||||
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
|
||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||
import { Navigation, NavSection } from "@/layouts/Navigation.tsx";
|
||||
import { useViewMode } from "@/lib/viewMode";
|
||||
import { Peers } from "@/modules/peers/Peers";
|
||||
import { Networks } from "@/modules/networks/Networks";
|
||||
import { ExitNodes } from "@/modules/exit-nodes/ExitNodes";
|
||||
import { NetworksProvider } from "@/modules/networks/NetworksContext";
|
||||
|
||||
export const Main = () => {
|
||||
const { viewMode } = useViewMode();
|
||||
const isAdvanced = viewMode === "advanced";
|
||||
const [section, setSection] = useState<NavSection>("peers");
|
||||
|
||||
return (
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
{/* Fixed-width column for the connection switch — same in both
|
||||
default and advanced view so the NetBird logo doesn't shift
|
||||
horizontally when the window grows. In default view the
|
||||
inner row is 348px so the column fills it; in advanced view
|
||||
the column sits on the left with Peers in the remaining
|
||||
space. */}
|
||||
<div className={"flex flex-col items-center shrink-0 w-[348px]"}>
|
||||
<ConnectionStatusSwitch />
|
||||
<NetworksProvider>
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
{/* Fixed-width column for the connection switch. Navigation
|
||||
is rendered absolutely at the bottom in advanced view so
|
||||
it doesn't reshape the column and shift the switch up. */}
|
||||
<div
|
||||
className={
|
||||
"relative flex flex-col items-center shrink-0 w-[348px]"
|
||||
}
|
||||
>
|
||||
<ConnectionStatusSwitch />
|
||||
{isAdvanced && (
|
||||
<div className={"absolute inset-x-0 bottom-0 px-1"}>
|
||||
<Navigation
|
||||
active={section}
|
||||
onSelect={setSection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<MainRightSide>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "exitNode" && <ExitNodes />}
|
||||
</MainRightSide>
|
||||
)}
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<MainRightSide>
|
||||
<Peers />
|
||||
</MainRightSide>
|
||||
)}
|
||||
</div>
|
||||
</NetworksProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,42 +1,74 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
||||
import { Layers3Icon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import deFlag from "@/assets/flags/1x1/de.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { useNetworks } from "@/modules/networks/NetworksContext";
|
||||
|
||||
export type NavSection = "peers" | "networks" | "exitNode";
|
||||
|
||||
type Props = {
|
||||
peersActive?: boolean;
|
||||
onPeersClick?: () => void;
|
||||
active: NavSection;
|
||||
onSelect: (section: NavSection) => void;
|
||||
};
|
||||
|
||||
export const Navigation = ({ peersActive = false, onPeersClick }: Props) => {
|
||||
export const Navigation = ({ active, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { networkRoutes, exitNodes, activeExitNode } = useNetworks();
|
||||
|
||||
const peerCounts = useMemo(() => {
|
||||
const peers = status?.peers ?? [];
|
||||
const online = peers.filter((p) => p.connStatus === "Connected").length;
|
||||
return { online, total: peers.length };
|
||||
}, [status?.peers]);
|
||||
|
||||
const networkCounts = useMemo(
|
||||
() => ({
|
||||
active: networkRoutes.filter((r) => r.selected).length,
|
||||
total: networkRoutes.length,
|
||||
}),
|
||||
[networkRoutes],
|
||||
);
|
||||
|
||||
const exitNodeDescription = activeExitNode
|
||||
? activeExitNode.id
|
||||
: t("nav.exitNode.none", { total: exitNodes.length });
|
||||
|
||||
return (
|
||||
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
||||
<CardNavItem
|
||||
icon={MonitorSmartphoneIcon}
|
||||
title={t("nav.peers.title")}
|
||||
description={t("nav.peers.description", { online: 17, total: 25 })}
|
||||
active={peersActive}
|
||||
onClick={onPeersClick}
|
||||
description={t("nav.peers.description", peerCounts)}
|
||||
active={active === "peers"}
|
||||
onClick={() => onSelect("peers")}
|
||||
/>
|
||||
<CardNavItem
|
||||
icon={Layers3Icon}
|
||||
title={t("nav.resources.title")}
|
||||
description={t("nav.resources.description", { active: 13, total: 16 })}
|
||||
description={t("nav.resources.description", networkCounts)}
|
||||
iconSize={14}
|
||||
active={active === "networks"}
|
||||
onClick={() => onSelect("networks")}
|
||||
/>
|
||||
<CardNavItem
|
||||
iconNode={
|
||||
<img
|
||||
src={deFlag}
|
||||
alt={t("nav.exitNode.flagAlt", { country: "Germany" })}
|
||||
className={
|
||||
"h-6 w-6 rounded-full border-[3px] border-nb-gray-850 shrink-0"
|
||||
}
|
||||
<SquareArrowUpRight
|
||||
size={14}
|
||||
className={cn(
|
||||
"transition-colors duration-150 rotate-45",
|
||||
active === "exitNode"
|
||||
? "text-nb-gray-200"
|
||||
: "text-nb-gray-400",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
title={t("nav.exitNode.title", { location: "Berlin" })}
|
||||
description={"100.92.14.37"}
|
||||
title={t("nav.exitNode.title")}
|
||||
description={exitNodeDescription}
|
||||
active={active === "exitNode"}
|
||||
onClick={() => onSelect("exitNode")}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
|
||||
67
client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx
Normal file
67
client/ui/frontend/src/modules/exit-nodes/ExitNodes.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { useNetworks } from "@/modules/networks/NetworksContext";
|
||||
import { ExitNodesList } from "./ExitNodesList";
|
||||
|
||||
export const ExitNodes = () => {
|
||||
const { t } = useTranslation();
|
||||
const { exitNodes, toggleExitNode } = useNetworks();
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const matches = exitNodes.filter((r) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
r.id.toLowerCase().includes(q) ||
|
||||
r.range.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
return matches.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [exitNodes, search]);
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||
<div className={"flex flex-col gap-3 px-6"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("exitNodes.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx
Normal file
61
client/ui/frontend/src/modules/exit-nodes/ExitNodesList.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
|
||||
const dotClass = (selected: boolean): string =>
|
||||
selected ? "bg-green-400" : "bg-nb-gray-500";
|
||||
|
||||
type Props = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
export const ExitNodesList = ({ data, onToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
||||
{t("exitNodes.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={"flex flex-col"}>
|
||||
{data.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 cursor-pointer",
|
||||
dotClass(n.selected),
|
||||
)}
|
||||
title={
|
||||
n.selected
|
||||
? t("exitNodes.active")
|
||||
: t("exitNodes.inactive")
|
||||
}
|
||||
/>
|
||||
<CopyToClipboard message={n.id} className={"min-w-0 flex-1"}>
|
||||
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
|
||||
{n.id}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboard
|
||||
message={n.range}
|
||||
className={cn("ml-auto shrink-0", "relative left-2.5")}
|
||||
>
|
||||
<span className={"text-xs font-mono text-nb-gray-400"}>
|
||||
{n.range}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
36
client/ui/frontend/src/modules/networks/NetworkFilters.tsx
Normal file
36
client/ui/frontend/src/modules/networks/NetworkFilters.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SwitchItem } from "@/components/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
|
||||
export type NetworkFilter = "all" | "selected" | "overlapping";
|
||||
|
||||
type Props = {
|
||||
value: NetworkFilter;
|
||||
onChange: (value: NetworkFilter) => void;
|
||||
counts: Record<NetworkFilter, number>;
|
||||
};
|
||||
|
||||
export const NetworkFilters = ({ value, onChange, counts }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const filters: { value: NetworkFilter; label: string }[] = [
|
||||
{ value: "all", label: t("networks.filter.all") },
|
||||
{ value: "selected", label: t("networks.filter.selected") },
|
||||
{ value: "overlapping", label: t("networks.filter.overlapping") },
|
||||
];
|
||||
|
||||
return (
|
||||
<SwitchItemGroup
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as NetworkFilter)}
|
||||
className={"w-full"}
|
||||
>
|
||||
{filters.map((f) => (
|
||||
<SwitchItem key={f.value} value={f.value} className={"flex-1"}>
|
||||
{f.label}
|
||||
<span className={"tabular-nums"}>({counts[f.value]})</span>
|
||||
</SwitchItem>
|
||||
))}
|
||||
</SwitchItemGroup>
|
||||
);
|
||||
};
|
||||
107
client/ui/frontend/src/modules/networks/Networks.tsx
Normal file
107
client/ui/frontend/src/modules/networks/Networks.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { NetworkFilters, NetworkFilter } from "./NetworkFilters";
|
||||
import { NetworksList } from "./NetworksList";
|
||||
import { useNetworks } from "./NetworksContext";
|
||||
|
||||
const collectOverlapping = (routes: { id: string; range: string; domains: string[] }[]): Set<string> => {
|
||||
const byRange = new Map<string, string[]>();
|
||||
for (const r of routes) {
|
||||
if (r.domains.length > 0) continue;
|
||||
const arr = byRange.get(r.range) ?? [];
|
||||
arr.push(r.id);
|
||||
byRange.set(r.range, arr);
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const ids of byRange.values()) {
|
||||
if (ids.length > 1) ids.forEach((id) => out.add(id));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { networkRoutes, toggleNetwork } = useNetworks();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const overlappingIds = useMemo(
|
||||
() => collectOverlapping(networkRoutes),
|
||||
[networkRoutes],
|
||||
);
|
||||
|
||||
const counts = useMemo<Record<NetworkFilter, number>>(
|
||||
() => ({
|
||||
all: networkRoutes.length,
|
||||
selected: networkRoutes.filter((r) => r.selected).length,
|
||||
overlapping: overlappingIds.size,
|
||||
}),
|
||||
[networkRoutes, overlappingIds],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const matches = networkRoutes.filter((r) => {
|
||||
if (filter === "selected" && !r.selected) return false;
|
||||
if (filter === "overlapping" && !overlappingIds.has(r.id)) return false;
|
||||
if (q) {
|
||||
const haystack = [r.id, r.range, ...r.domains]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return matches.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [networkRoutes, search, filter, overlappingIds]);
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||
<div className={"flex flex-col gap-3 px-6"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("networks.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<NetworkFilters
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
counts={counts}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<NetworksList data={filtered} onToggle={toggleNetwork} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
126
client/ui/frontend/src/modules/networks/NetworksContext.tsx
Normal file
126
client/ui/frontend/src/modules/networks/NetworksContext.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
|
||||
// A range is treated as an exit-node candidate when any of its CIDRs is a
|
||||
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
|
||||
// comma-joined range string for one peer.
|
||||
export const isDefaultRoute = (range: string): boolean =>
|
||||
range.split(",").some((part) => {
|
||||
const trimmed = part.trim();
|
||||
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
|
||||
});
|
||||
|
||||
type NetworksContextValue = {
|
||||
routes: Network[];
|
||||
networkRoutes: Network[];
|
||||
exitNodes: Network[];
|
||||
activeExitNode: Network | null;
|
||||
refresh: () => Promise<void>;
|
||||
toggleNetwork: (id: string, selected: boolean) => Promise<void>;
|
||||
toggleExitNode: (id: string, selected: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
const NetworksContext = createContext<NetworksContextValue | null>(null);
|
||||
|
||||
export const useNetworks = () => {
|
||||
const ctx = useContext(NetworksContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNetworks must be used inside NetworksProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [routes, setRoutes] = useState<Network[]>([]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const list = await NetworksSvc.List();
|
||||
setRoutes(list);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const toggleNetwork = useCallback(
|
||||
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) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
// Exit nodes are mutually exclusive — Select with append=false clears any
|
||||
// other selection before activating the new one.
|
||||
const toggleExitNode = useCallback(
|
||||
async (id: string, selected: boolean) => {
|
||||
try {
|
||||
if (selected) {
|
||||
await NetworksSvc.Deselect({
|
||||
networkIds: [id],
|
||||
append: false,
|
||||
all: false,
|
||||
});
|
||||
} else {
|
||||
await NetworksSvc.Select({
|
||||
networkIds: [id],
|
||||
append: false,
|
||||
all: false,
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
const value = useMemo<NetworksContextValue>(() => {
|
||||
const networkRoutes = routes.filter((r) => !isDefaultRoute(r.range));
|
||||
const exitNodes = routes.filter((r) => isDefaultRoute(r.range));
|
||||
const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
|
||||
return {
|
||||
routes,
|
||||
networkRoutes,
|
||||
exitNodes,
|
||||
activeExitNode,
|
||||
refresh,
|
||||
toggleNetwork,
|
||||
toggleExitNode,
|
||||
};
|
||||
}, [routes, refresh, toggleNetwork, toggleExitNode]);
|
||||
|
||||
return (
|
||||
<NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>
|
||||
);
|
||||
};
|
||||
57
client/ui/frontend/src/modules/networks/NetworksList.tsx
Normal file
57
client/ui/frontend/src/modules/networks/NetworksList.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
|
||||
const dotClass = (selected: boolean): string =>
|
||||
selected ? "bg-green-400" : "bg-nb-gray-500";
|
||||
|
||||
type Props = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
export const NetworksList = ({ data, onToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
||||
{t("networks.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={"flex flex-col"}>
|
||||
{data.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => onToggle(n.id, n.selected)}
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 cursor-pointer",
|
||||
dotClass(n.selected),
|
||||
)}
|
||||
title={n.selected ? t("networks.selected") : t("networks.unselected")}
|
||||
/>
|
||||
<CopyToClipboard message={n.id} className={"min-w-0 flex-1"}>
|
||||
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
|
||||
{n.id}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboard
|
||||
message={n.range}
|
||||
className={cn("ml-auto shrink-0", "relative left-2.5")}
|
||||
>
|
||||
<span className={"text-xs font-mono text-nb-gray-400"}>
|
||||
{n.range}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
48
client/ui/frontend/src/modules/peers/peers.md
Normal file
48
client/ui/frontend/src/modules/peers/peers.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Peers — info missing in PeersList.tsx
|
||||
|
||||
`PeersList.tsx` currently shows only: `connStatus` (dot), `fqdn`, `ip`.
|
||||
|
||||
`screens/Peers.tsx` additionally surfaces the following fields from `PeerStatus`:
|
||||
|
||||
## Row chrome (collapsed)
|
||||
- `peer.relayed` — Network (relayed, yellow) vs Zap (P2P, green) icon, gated on `connStatus === "Connected"`.
|
||||
- `peer.rosenpassEnabled` — ShieldCheck icon when true.
|
||||
- `peer.latencyMs` — `"{n} ms"` on the right when Connected and > 0.
|
||||
|
||||
## Top-level controls
|
||||
- Filter input — matches against `fqdn`, `ip`, and each entry in `networks`.
|
||||
- Peer count — `status.peers.length` next to the title.
|
||||
- Expand/collapse per row (chevron).
|
||||
|
||||
## Expanded details panel
|
||||
- `peer.pubKey` — Public key (mono).
|
||||
- `peer.lastHandshakeUnix` — Last handshake (relative time).
|
||||
- `peer.connStatusUpdateUnix` — Status since (relative time).
|
||||
- `peer.bytesRx` / `peer.bytesTx` — formatted B/KB/MB/GB.
|
||||
- `peer.localIceCandidateType` + `peer.localIceCandidateEndpoint` — Local candidate.
|
||||
- `peer.remoteIceCandidateType` + `peer.remoteIceCandidateEndpoint` — Remote candidate.
|
||||
- `peer.relayAddress` — shown only when `peer.relayed`.
|
||||
- `peer.networks` — joined list, shown when non-empty.
|
||||
|
||||
## `PeerStatus` interface (from `@bindings/services/models.js`)
|
||||
```ts
|
||||
interface PeerStatus {
|
||||
ip: string;
|
||||
pubKey: string;
|
||||
connStatus: string; // "Connected" | "Connecting" | "Idle" | ...
|
||||
connStatusUpdateUnix: number;
|
||||
relayed: boolean;
|
||||
localIceCandidateType: string;
|
||||
remoteIceCandidateType: string;
|
||||
localIceCandidateEndpoint: string;
|
||||
remoteIceCandidateEndpoint: string;
|
||||
fqdn: string;
|
||||
bytesRx: number;
|
||||
bytesTx: number;
|
||||
latencyMs: number;
|
||||
relayAddress: string;
|
||||
lastHandshakeUnix: number;
|
||||
rosenpassEnabled: boolean;
|
||||
networks: string[];
|
||||
}
|
||||
```
|
||||
@@ -1,171 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
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) => isDefaultRoute(r.range)),
|
||||
[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>
|
||||
);
|
||||
}
|
||||
|
||||
// range is the merged display string from the daemon, e.g. "0.0.0.0/0",
|
||||
// "::/0", or "0.0.0.0/0, ::/0" when a v4 exit node has a paired v6 entry.
|
||||
function isDefaultRoute(range: string): boolean {
|
||||
return range.split(",").some((part) => {
|
||||
const trimmed = part.trim();
|
||||
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import type { PeerStatus } from "@bindings/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<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`;
|
||||
}
|
||||
Reference in New Issue
Block a user