From 9355b4cc43a3817bd247f4c61005c28338a63baa Mon Sep 17 00:00:00 2001 From: Anton Liashkevich Date: Wed, 20 May 2026 19:04:22 -0400 Subject: [PATCH] add React admin UI with routing and live API wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 6 pages (Dashboard, Services, Nodes, Sessions, Pool, Config) backed by the HTTP API from PRs 1–3. Shared Layout component eliminates the copy-pasted sidebar from each draft HTML page. Stack name persists in localStorage and is editable from the sidebar footer. Topology and Events nav items are rendered as disabled stubs pending their backend PRs. --- members/nullnet-server/ui/package-lock.json | 90 ++++- members/nullnet-server/ui/package.json | 4 +- members/nullnet-server/ui/src/App.css | 185 +-------- members/nullnet-server/ui/src/App.tsx | 141 ++----- .../nullnet-server/ui/src/StackContext.tsx | 37 ++ .../ui/src/components/Layout.tsx | 123 ++++++ members/nullnet-server/ui/src/hooks/useApi.ts | 55 +++ members/nullnet-server/ui/src/index.css | 368 +++++++++++++----- members/nullnet-server/ui/src/main.tsx | 2 +- .../nullnet-server/ui/src/pages/Config.tsx | 29 ++ .../nullnet-server/ui/src/pages/Dashboard.tsx | 159 ++++++++ members/nullnet-server/ui/src/pages/Nodes.tsx | 136 +++++++ members/nullnet-server/ui/src/pages/Pool.tsx | 89 +++++ .../nullnet-server/ui/src/pages/Services.tsx | 170 ++++++++ .../nullnet-server/ui/src/pages/Sessions.tsx | 99 +++++ members/nullnet-server/ui/src/types.ts | 47 +++ members/nullnet-server/ui/vite.config.ts | 5 + 17 files changed, 1336 insertions(+), 403 deletions(-) create mode 100644 members/nullnet-server/ui/src/StackContext.tsx create mode 100644 members/nullnet-server/ui/src/components/Layout.tsx create mode 100644 members/nullnet-server/ui/src/hooks/useApi.ts create mode 100644 members/nullnet-server/ui/src/pages/Config.tsx create mode 100644 members/nullnet-server/ui/src/pages/Dashboard.tsx create mode 100644 members/nullnet-server/ui/src/pages/Nodes.tsx create mode 100644 members/nullnet-server/ui/src/pages/Pool.tsx create mode 100644 members/nullnet-server/ui/src/pages/Services.tsx create mode 100644 members/nullnet-server/ui/src/pages/Sessions.tsx create mode 100644 members/nullnet-server/ui/src/types.ts diff --git a/members/nullnet-server/ui/package-lock.json b/members/nullnet-server/ui/package-lock.json index 861d51e..9c3cedb 100644 --- a/members/nullnet-server/ui/package-lock.json +++ b/members/nullnet-server/ui/package-lock.json @@ -8,8 +8,10 @@ "name": "ui", "version": "0.0.0", "dependencies": { + "@types/react-router-dom": "^5.3.3", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -862,6 +864,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -883,7 +891,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -899,6 +906,27 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -1306,6 +1334,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1325,7 +1366,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2364,6 +2404,44 @@ "react": "^19.2.6" } }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -2421,6 +2499,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/members/nullnet-server/ui/package.json b/members/nullnet-server/ui/package.json index fe53cf8..93cb0c4 100644 --- a/members/nullnet-server/ui/package.json +++ b/members/nullnet-server/ui/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@types/react-router-dom": "^5.3.3", "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/members/nullnet-server/ui/src/App.css b/members/nullnet-server/ui/src/App.css index f90339d..46454ad 100644 --- a/members/nullnet-server/ui/src/App.css +++ b/members/nullnet-server/ui/src/App.css @@ -1,184 +1 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} +/* unused — styles live in index.css */ diff --git a/members/nullnet-server/ui/src/App.tsx b/members/nullnet-server/ui/src/App.tsx index a66b5ef..cef5157 100644 --- a/members/nullnet-server/ui/src/App.tsx +++ b/members/nullnet-server/ui/src/App.tsx @@ -1,122 +1,25 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' - -function App() { - const [count, setCount] = useState(0) +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { StackProvider } from './StackContext'; +import Dashboard from './pages/Dashboard'; +import Services from './pages/Services'; +import Nodes from './pages/Nodes'; +import Sessions from './pages/Sessions'; +import Pool from './pages/Pool'; +import Config from './pages/Config'; +export default function App() { return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } - -export default App diff --git a/members/nullnet-server/ui/src/StackContext.tsx b/members/nullnet-server/ui/src/StackContext.tsx new file mode 100644 index 0000000..779c9f8 --- /dev/null +++ b/members/nullnet-server/ui/src/StackContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useState } from 'react'; + +interface StackContextValue { + stack: string; + setStack: (s: string) => void; + editing: boolean; + setEditing: (e: boolean) => void; +} + +const StackContext = createContext({ + stack: 'my-stack', + setStack: () => {}, + editing: false, + setEditing: () => {}, +}); + +const STORAGE_KEY = 'nullnet_stack'; + +export function StackProvider({ children }: { children: React.ReactNode }) { + const [stack, setStackState] = useState(() => localStorage.getItem(STORAGE_KEY) ?? 'my-stack'); + const [editing, setEditing] = useState(false); + + function setStack(s: string) { + setStackState(s); + localStorage.setItem(STORAGE_KEY, s); + } + + return ( + + {children} + + ); +} + +export function useStack() { + return useContext(StackContext); +} diff --git a/members/nullnet-server/ui/src/components/Layout.tsx b/members/nullnet-server/ui/src/components/Layout.tsx new file mode 100644 index 0000000..4a9fb15 --- /dev/null +++ b/members/nullnet-server/ui/src/components/Layout.tsx @@ -0,0 +1,123 @@ +import { NavLink } from 'react-router-dom'; +import { useStack } from '../StackContext'; +import { useApi } from '../hooks/useApi'; +import type { SessionJson } from '../types'; + +type Page = 'dashboard' | 'services' | 'nodes' | 'sessions' | 'pool' | 'config'; + +interface Props { + page: Page; + topbarRight?: React.ReactNode; + children: React.ReactNode; +} + +const NAV = [ + { + group: 'Overview', + items: [ + { id: 'dashboard', icon: '⊞', label: 'Dashboard', to: '/' }, + { id: 'topology', icon: '⬡', label: 'Topology', to: null }, + ], + }, + { + group: 'State', + items: [ + { id: 'services', icon: '◈', label: 'Services', to: '/services' }, + { id: 'sessions', icon: '⌾', label: 'Sessions', to: '/sessions', live: true }, + { id: 'nodes', icon: '◉', label: 'Nodes', to: '/nodes' }, + { id: 'pool', icon: '▦', label: 'Pool', to: '/pool' }, + ], + }, + { + group: 'Ops', + items: [ + { id: 'events', icon: '≡', label: 'Events', to: null }, + { id: 'config', icon: '⚙', label: 'Config', to: '/config' }, + ], + }, +]; + +export default function Layout({ page, topbarRight, children }: Props) { + const { stack, setStack, editing, setEditing } = useStack(); + const { data: sessions } = useApi('/api/sessions', 5000); + + const sessionCount = sessions?.length ?? null; + + return ( + <> + + +
+
+
+ nullnet + · + {page.charAt(0).toUpperCase() + page.slice(1)} +
+
+ {topbarRight} + {stack} +
+
+ {children} +
+ + ); +} diff --git a/members/nullnet-server/ui/src/hooks/useApi.ts b/members/nullnet-server/ui/src/hooks/useApi.ts new file mode 100644 index 0000000..63499d7 --- /dev/null +++ b/members/nullnet-server/ui/src/hooks/useApi.ts @@ -0,0 +1,55 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface ApiState { + data: T | null; + loading: boolean; + error: string | null; +} + +export function useApi(url: string, refreshMs?: number): ApiState & { refetch: () => void } { + const [state, setState] = useState>({ data: null, loading: true, error: null }); + + const load = useCallback(async () => { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: T = await res.json(); + setState({ data, loading: false, error: null }); + } catch (e) { + setState(prev => ({ ...prev, loading: false, error: String(e) })); + } + }, [url]); + + useEffect(() => { + load(); + if (!refreshMs) return; + const id = setInterval(load, refreshMs); + return () => clearInterval(id); + }, [load, refreshMs]); + + return { ...state, refetch: load }; +} + +export function useApiText(url: string, refreshMs?: number): { text: string | null; loading: boolean; error: string | null } { + const [state, setState] = useState<{ text: string | null; loading: boolean; error: string | null }>({ text: null, loading: true, error: null }); + + const load = useCallback(async () => { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + setState({ text, loading: false, error: null }); + } catch (e) { + setState(prev => ({ ...prev, loading: false, error: String(e) })); + } + }, [url]); + + useEffect(() => { + load(); + if (!refreshMs) return; + const id = setInterval(load, refreshMs); + return () => clearInterval(id); + }, [load, refreshMs]); + + return state; +} diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index 5fb3313..c8347e6 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -1,111 +1,289 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} +@import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@300;400;500&display=swap'); -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } +/* ── Custom Properties ── */ +:root { + --bg: #030508; + --g1: rgba(255,255,255,.04); + --g2: rgba(255,255,255,.07); + --gb: rgba(255,255,255,.07); + --gb2: rgba(255,255,255,.12); + --blue: #5b9cf6; + --blue-g: rgba(91,156,246,.12); + --cyan: #38d9e0; + --green: #34d399; + --green-g: rgba(52,211,153,.10); + --red: #f87171; + --red-g: rgba(248,113,113,.10); + --purple: #a78bfa; + --purple-g: rgba(167,139,250,.10); + --amber: #fbbf24; + --amber-g: rgba(251,191,36,.10); + --t0: rgba(255,255,255,.92); + --t1: rgba(255,255,255,.48); + --t2: rgba(255,255,255,.22); + --t3: rgba(255,255,255,.07); } -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} +/* ── Reset ── */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } +/* ── Base ── */ body { - margin: 0; + background: var(--bg); + background-image: + radial-gradient(ellipse at 18% 22%, rgba(59,130,246,.07) 0%, transparent 55%), + radial-gradient(ellipse at 82% 78%, rgba(139,92,246,.05) 0%, transparent 55%); + color: var(--t0); + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 13px; + line-height: 1.6; + min-height: 100vh; + display: flex; } +#root { display: flex; width: 100%; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +/* ── Glass ── */ +.glass { + background: var(--g1); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + border: 1px solid var(--gb); + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 8px 32px rgba(0,0,0,.35); } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } +/* ── Sidebar ── */ +.sidebar { + width: 192px; + min-height: 100vh; + background: rgba(3,5,8,.92); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-right: 1px solid var(--gb); + position: fixed; + top: 0; left: 0; bottom: 0; + display: flex; + flex-direction: column; + z-index: 100; } -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } +.logo { padding: 22px 18px 16px; border-bottom: 1px solid var(--t3); } +.logo-name { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; letter-spacing: -.02em; color: var(--t0); } +.logo-sub { font-size: 10px; color: var(--t2); margin-top: 3px; letter-spacing: .04em; } +.nav { padding: 12px 8px; flex: 1; } +.nav-group { margin-bottom: 18px; } +.nav-group-label { font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: var(--t2); padding: 0 10px; margin-bottom: 4px; } +.nav-a { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: 8px; + color: var(--t1); text-decoration: none; + font-size: 12px; font-weight: 500; + transition: all .15s; margin-bottom: 1px; } -p { - margin: 0; +.nav-a:hover { color: var(--t0); background: var(--g2); } +.nav-a.active { color: var(--t0); background: rgba(91,156,246,.12); box-shadow: inset 0 0 0 1px rgba(91,156,246,.2); } +.nav-a.disabled { opacity: .35; pointer-events: none; cursor: default; } +.nav-icon { font-size: 13px; width: 16px; text-align: center; flex-shrink: 0; } +.nav-count { margin-left: auto; font-size: 10px; color: var(--t2); font-family: 'JetBrains Mono', monospace; } +.nav-count.live { color: var(--green); animation: gp 2s infinite; } +.foot { padding: 14px 18px; border-top: 1px solid var(--t3); font-size: 10px; color: var(--t2); } +.foot-row { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; } +.foot-stack { margin-top: 6px; font-size: 9px; color: var(--t2); display: flex; align-items: center; gap: 5px; } +.foot-stack-name { color: var(--t1); font-family: 'JetBrains Mono', monospace; } +.foot-stack input { + background: transparent; border: none; border-bottom: 1px solid var(--t3); + color: var(--t1); font-family: 'JetBrains Mono', monospace; font-size: 9px; + outline: none; width: 100px; padding: 1px 2px; } +.foot-stack input:focus { border-color: rgba(91,156,246,.4); } + +/* ── Dots ── */ +.dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; } +.dot-g { background: var(--green); box-shadow: 0 0 6px rgba(52,211,153,.7); } +.dot-a { background: var(--amber); box-shadow: 0 0 5px rgba(251,191,36,.7); animation: gp 1.8s infinite; } +.dot-dim { background: var(--t2); } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); +/* ── Main layout ── */ +.main { margin-left: 192px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; } +.topbar { + height: 48px; + background: rgba(3,5,8,.7); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--t3); + display: flex; align-items: center; justify-content: space-between; + padding: 0 28px; + position: sticky; top: 0; z-index: 50; + flex-shrink: 0; } +.topbar-path { font-size: 12px; color: var(--t1); display: flex; align-items: center; gap: 8px; } +.topbar-path .pg { color: var(--t0); font-weight: 500; } +.topbar-right { display: flex; align-items: center; gap: 12px; } +.pill { background: rgba(91,156,246,.1); border: 1px solid rgba(91,156,246,.2); color: var(--blue); padding: 3px 10px; font-size: 10px; border-radius: 20px; letter-spacing: .06em; } +.live-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); animation: gp 2s infinite; display: inline-block; } +.live-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--t1); } +.content { padding: 28px; flex: 1; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +/* ── Cards ── */ +.card { + background: var(--g1); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--gb); + box-shadow: inset 0 1px 0 rgba(255,255,255,.07), 0 8px 32px rgba(0,0,0,.3); + border-radius: 14px; + overflow: hidden; + margin-bottom: 16px; } +.card-head { padding: 14px 18px; border-bottom: 1px solid var(--t3); display: flex; align-items: center; justify-content: space-between; } +.card-label { font-size: 10px; color: var(--t1); letter-spacing: .06em; font-weight: 600; text-transform: uppercase; } +.card-action { font-size: 11px; color: var(--blue); text-decoration: none; opacity: .8; transition: opacity .1s; } +.card-action:hover { opacity: 1; } + +/* ── Tables ── */ +.tbl { width: 100%; border-collapse: collapse; } +.tbl th { padding: 10px 16px; text-align: left; font-size: 10px; color: var(--t2); border-bottom: 1px solid var(--t3); letter-spacing: .06em; font-weight: 600; text-transform: uppercase; white-space: nowrap; } +.tbl td { padding: 11px 16px; font-size: 12px; border-bottom: 1px solid rgba(255,255,255,.03); } +.tbl tr:last-child td { border-bottom: none; } +.tbl tbody tr { transition: background .12s; } +.tbl tbody tr:hover td { background: rgba(255,255,255,.025); } + +/* ── Expandable rows (services) ── */ +.expand-inner { display: none; padding: 16px 20px 20px; background: rgba(0,0,0,.2); border-top: 1px solid var(--t3); } +.expand-inner.open { display: block; } +.expand-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.exp-card { background: rgba(255,255,255,.03); border: 1px solid var(--t3); border-radius: 10px; overflow: hidden; } +.exp-head { padding: 8px 12px; border-bottom: 1px solid var(--t3); font-size: 10px; color: var(--t2); letter-spacing: .06em; font-weight: 600; text-transform: uppercase; } +.sub-tbl { width: 100%; border-collapse: collapse; } +.sub-tbl th { padding: 5px 10px; font-size: 9px; color: var(--t2); border-bottom: 1px solid var(--t3); text-align: left; letter-spacing: .05em; } +.sub-tbl td { padding: 7px 10px; font-size: 11px; border-bottom: 1px solid rgba(255,255,255,.03); } +.sub-tbl tr:last-child td { border-bottom: none; } +.sub-tbl tbody tr:hover td { background: rgba(255,255,255,.02); } + +/* ── Badges ── */ +.badge { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: 20px; font-size: 9.5px; font-weight: 600; letter-spacing: .03em; } +.b-green { background: var(--green-g); color: var(--green); border: 1px solid rgba(52,211,153,.2); } +.b-blue { background: var(--blue-g); color: var(--blue); border: 1px solid rgba(91,156,246,.2); } +.b-purple { background: var(--purple-g); color: var(--purple); border: 1px solid rgba(167,139,250,.2); } +.b-red { background: var(--red-g); color: var(--red); border: 1px solid rgba(248,113,113,.2); } +.b-amber { background: var(--amber-g); color: var(--amber); border: 1px solid rgba(251,191,36,.2); } +.b-dim { background: rgba(255,255,255,.05); color: var(--t1); border: 1px solid var(--t3); } + +/* ── Buttons ── */ +.teardown-btn { padding: 3px 10px; background: rgba(248,113,113,.08); border: 1px solid rgba(248,113,113,.2); color: var(--red); font-family: 'Plus Jakarta Sans', sans-serif; font-size: 10px; border-radius: 8px; cursor: pointer; transition: background .12s; font-weight: 500; } +.teardown-btn:hover { background: rgba(248,113,113,.16); } +.expand-btn { background: none; border: none; color: var(--t2); cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 13px; transition: color .1s; padding: 0; } +.expand-btn:hover { color: var(--t0); } + +/* ── Tags ── */ +.dep-tag { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 20px; font-size: 10px; color: var(--cyan); background: rgba(56,217,224,.07); border: 1px solid rgba(56,217,224,.15); margin-right: 4px; } + +/* ── Page headers ── */ +.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; } +.page-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 22px; letter-spacing: -.03em; margin-bottom: 6px; } +.page-sub { font-size: 12px; color: var(--t2); margin-bottom: 24px; } +.controls { display: flex; align-items: center; gap: 8px; } +.search { background: var(--g1); border: 1px solid var(--gb); color: var(--t0); font-family: 'Plus Jakarta Sans', sans-serif; font-size: 12px; padding: 7px 14px; border-radius: 10px; outline: none; width: 180px; transition: border-color .15s; backdrop-filter: blur(12px); } +.search:focus { border-color: rgba(91,156,246,.3); } +.search::placeholder { color: var(--t2); } +.filter-chip { padding: 5px 14px; border-radius: 20px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid var(--t3); color: var(--t1); background: transparent; font-family: 'Plus Jakarta Sans', sans-serif; transition: all .15s; } +.filter-chip:hover { border-color: var(--gb); color: var(--t0); } +.filter-chip.on { background: rgba(91,156,246,.1); border-color: rgba(91,156,246,.25); color: var(--blue); } + +/* ── Dashboard stats ── */ +.stats { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; margin-bottom: 24px; } +.stat { border-radius: 12px; padding: 20px 22px; position: relative; overflow: hidden; } +.stat-label { font-size: 10px; color: var(--t2); letter-spacing: .06em; margin-bottom: 10px; font-weight: 500; } +.stat-value { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 28px; letter-spacing: -.03em; line-height: 1; color: var(--t0); } +.stat-value .denom { font-size: 14px; color: var(--t1); font-weight: 700; } +.stat-sub { font-size: 10px; color: var(--t1); margin-top: 8px; } +.bot-grid { display: grid; grid-template-columns: 1.65fr 1fr; gap: 12px; margin-bottom: 12px; } +.ev { display: flex; gap: 10px; align-items: flex-start; padding: 10px 18px; border-bottom: 1px solid var(--t3); transition: background .1s; } +.ev:hover { background: rgba(255,255,255,.02); } +.ev:last-child { border-bottom: none; } +.ev-accent { width: 2px; border-radius: 1px; flex-shrink: 0; align-self: stretch; min-height: 14px; } +.ev-time { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--t2); white-space: nowrap; flex-shrink: 0; margin-top: 2px; } +.ev-text { font-size: 11.5px; color: var(--t1); flex: 1; line-height: 1.5; } +.ev-text b { color: var(--t0); font-weight: 600; } + +/* ── Sessions ── */ +.hero-row { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; } +.hero-num { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 36px; letter-spacing: -.04em; color: var(--cyan); } +.hero-label { font-size: 13px; color: var(--t1); } +.hero-split { font-size: 12px; color: var(--t2); margin-left: auto; align-self: center; } +.view-toggle { display: flex; background: rgba(255,255,255,.04); border: 1px solid var(--t3); border-radius: 9px; padding: 2px; gap: 2px; } +.vt-btn { padding: 4px 12px; border-radius: 7px; font-size: 11px; font-weight: 500; cursor: pointer; border: none; background: transparent; color: var(--t1); font-family: 'Plus Jakarta Sans', sans-serif; transition: all .15s; } +.vt-btn.on { background: rgba(91,156,246,.15); color: var(--blue); } +.age-wrap { display: flex; flex-direction: column; gap: 3px; } +.age-bar { height: 3px; background: rgba(255,255,255,.06); border-radius: 2px; overflow: hidden; width: 52px; } +.age-fill { height: 100%; border-radius: 2px; } +.inst-group { border-radius: 14px; overflow: hidden; margin-bottom: 10px; border: 1px solid var(--gb); } +.inst-head { display: flex; align-items: center; gap: 12px; padding: 11px 16px; cursor: pointer; transition: background .12s; background: rgba(255,255,255,.03); user-select: none; } +.inst-head:hover { background: rgba(255,255,255,.05); } +.inst-color-bar { width: 3px; height: 28px; border-radius: 2px; flex-shrink: 0; } +.inst-id { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 600; } +.inst-client { font-size: 11px; color: var(--t1); } +.inst-bw { font-family: 'JetBrains Mono', monospace; font-size: 10px; margin-left: auto; padding: 2px 9px; border-radius: 10px; font-weight: 500; } +.inst-created { font-size: 10px; color: var(--t2); font-family: 'JetBrains Mono', monospace; } +.inst-chevron { font-size: 12px; color: var(--t2); transition: transform .18s; flex-shrink: 0; } +.inst-chevron.open { transform: rotate(90deg); } +.inst-body { display: none; border-top: 1px solid var(--t3); } +.inst-body.open { display: block; } + +/* ── Nodes ── */ +.workspace { display: flex; flex: 1; overflow: hidden; } +.nodes-content { padding: 28px; flex: 1; overflow-y: auto; } +.nodes-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.node-card { background: var(--g1); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--gb); box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 4px 20px rgba(0,0,0,.3); border-radius: 14px; cursor: pointer; transition: all .18s; overflow: hidden; } +.node-card:hover { background: var(--g2); border-color: rgba(255,255,255,.12); } +.node-card.sel { border-color: rgba(91,156,246,.4); box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 0 0 1px rgba(91,156,246,.15), 0 8px 32px rgba(0,0,0,.4); } +.nc-head { padding: 16px 18px; display: flex; align-items: flex-start; justify-content: space-between; } +.nc-name { font-weight: 700; font-size: 14px; display: flex; align-items: center; gap: 8px; } +.nc-ip { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--cyan); margin-top: 3px; } +.nc-meta { text-align: right; font-size: 10px; color: var(--t2); line-height: 1.5; } +.nc-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0; padding: 0 18px 14px; } +.nc-stat { padding: 6px 0; } +.nc-stat-k { font-size: 10px; color: var(--t2); margin-bottom: 2px; } +.nc-stat-v { font-size: 16px; font-weight: 700; font-family: 'Syne', sans-serif; letter-spacing: -.02em; } +.nc-svcs { padding: 10px 18px; border-top: 1px solid var(--t3); display: flex; flex-wrap: wrap; gap: 5px; } +.svc-tag { font-size: 10px; padding: 2px 9px; background: rgba(255,255,255,.05); border: 1px solid var(--t3); color: var(--t1); border-radius: 20px; } +.dp { width: 300px; background: rgba(3,5,8,.9); backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px); border-left: 1px solid var(--gb); box-shadow: inset 0 1px 0 rgba(255,255,255,.07); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; } +.dp-head { padding: 16px 18px; border-bottom: 1px solid var(--t3); display: flex; align-items: center; justify-content: space-between; } +.dp-title { font-size: 13px; font-weight: 700; } +.dp-close { background: none; border: none; color: var(--t2); cursor: pointer; font-size: 18px; padding: 0; transition: color .1s; } +.dp-close:hover { color: var(--t0); } +.dp-body { padding: 18px; overflow-y: auto; flex: 1; } +.dp-sec { margin-bottom: 20px; } +.dp-sec-title { font-size: 9px; letter-spacing: .16em; text-transform: uppercase; color: var(--t2); margin-bottom: 10px; font-weight: 600; } +.dp-row { display: flex; justify-content: space-between; align-items: flex-start; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,.03); } +.dp-row:last-child { border-bottom: none; } +.dp-k { font-size: 11px; color: var(--t2); } +.dp-v { font-size: 11px; color: var(--t0); text-align: right; } +.dp-v.code { font-family: 'JetBrains Mono', monospace; color: var(--cyan); font-size: 10.5px; } +.mini-tbl { width: 100%; border-collapse: collapse; } +.mini-tbl th { font-size: 9px; color: var(--t2); padding: 4px 0; text-align: left; border-bottom: 1px solid var(--t3); letter-spacing: .05em; } +.mini-tbl td { font-size: 11px; padding: 5px 0; border-bottom: rgba(255,255,255,.03) solid 1px; } +.mini-tbl tr:last-child td { border-bottom: none; } +.empty-dp { display: flex; align-items: center; justify-content: center; flex: 1; flex-direction: column; gap: 10px; color: var(--t2); } + +/* ── Pool ── */ +.pool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; } +.pool-card { background: var(--g1); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--gb); box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 8px 32px rgba(0,0,0,.3); border-radius: 14px; padding: 22px 24px; } +.pc-label { font-size: 10px; color: var(--t2); letter-spacing: .06em; font-weight: 600; text-transform: uppercase; margin-bottom: 16px; } +.pc-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; } +.pc-num { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 30px; letter-spacing: -.04em; } +.pc-total { font-size: 13px; color: var(--t2); } +.pc-pct { font-size: 12px; color: var(--t1); } +.track { height: 6px; background: rgba(255,255,255,.06); border-radius: 4px; overflow: hidden; margin-bottom: 10px; } +.fill { height: 100%; border-radius: 4px; transition: width .5s ease; } +.pc-foot { font-size: 11px; color: var(--t2); } + +/* ── Config ── */ +.cfg-pre { font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.7; color: var(--t1); background: rgba(0,0,0,.3); border: 1px solid var(--gb); border-radius: 12px; padding: 20px 24px; overflow-x: auto; white-space: pre; } + +/* ── Animations ── */ +@keyframes gp { 0%, 100% { opacity: 1; } 50% { opacity: .3; } } +@keyframes flash { from { background: rgba(52,211,153,.08); } to { background: transparent; } } + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--t3); border-radius: 2px; } diff --git a/members/nullnet-server/ui/src/main.tsx b/members/nullnet-server/ui/src/main.tsx index bef5202..db032b7 100644 --- a/members/nullnet-server/ui/src/main.tsx +++ b/members/nullnet-server/ui/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import App from './App' createRoot(document.getElementById('root')!).render( diff --git a/members/nullnet-server/ui/src/pages/Config.tsx b/members/nullnet-server/ui/src/pages/Config.tsx new file mode 100644 index 0000000..1289b07 --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Config.tsx @@ -0,0 +1,29 @@ +import Layout from '../components/Layout'; +import { useApiText } from '../hooks/useApi'; +import { useStack } from '../StackContext'; + +export default function Config() { + const { stack } = useStack(); + const { text, loading, error } = useApiText(`/api/config/${stack}`); + + return ( + +
+
Configuration
+
Raw service configuration for stack {stack}
+ + {loading &&
Loading…
} + + {error && ( +
+ Failed to load config: {error} +
+ )} + + {text && ( +
{text}
+ )} +
+
+ ); +} diff --git a/members/nullnet-server/ui/src/pages/Dashboard.tsx b/members/nullnet-server/ui/src/pages/Dashboard.tsx new file mode 100644 index 0000000..b5be489 --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Dashboard.tsx @@ -0,0 +1,159 @@ +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import { useStack } from '../StackContext'; +import type { SessionJson, ServiceJson, NodeJson, PoolJson } from '../types'; + +export default function Dashboard() { + const { stack } = useStack(); + const { data: sessions } = useApi('/api/sessions', 5000); + const { data: services } = useApi(`/api/services/${stack}`, 5000); + const { data: nodes } = useApi('/api/nodes', 5000); + const { data: pool } = useApi('/api/pool', 5000); + + const totalSvc = services?.length ?? 0; + const onlineSvc = services?.filter(s => s.registered).length ?? 0; + const sessionCount = sessions?.length ?? 0; + const nodeCount = nodes?.length ?? 0; + const poolPct = pool ? ((pool.in_use / pool.total) * 100).toFixed(1) : '—'; + const poolUsed = pool ? `${pool.in_use.toLocaleString()} of ${pool.total.toLocaleString()} IDs` : '—'; + + return ( + + live · 5s + + } + > +
+
+
+
Services Online
+
+ {onlineSvc}/{totalSvc} +
+
{totalSvc - onlineSvc} unregistered
+
+
+
Active Sessions
+
{sessionCount}
+
isolated networks
+
+
+
Connected Nodes
+
{nodeCount}
+
agent nodes
+
+
+
Pool Used
+
+ {pool ? <>{poolPct}% : '—'} +
+
{poolUsed}
+
+
+ +
+
+
+ Topology + live graph coming soon +
+
+ + + + + + {services?.map((svc, i) => { + const total = Math.max(services.length, 1); + const x = 100 + (i / (total - 1 || 1)) * 500; + const y = i % 2 === 0 ? 80 : 160; + const color = svc.registered ? '#34d399' : '#f87171'; + const strokeColor = svc.registered ? 'rgba(52,211,153,.3)' : 'rgba(248,113,113,.2)'; + return ( + + + + {svc.name} + + {svc.registered ? `${svc.replicas.length} replica${svc.replicas.length !== 1 ? 's' : ''}` : 'unregistered'} + + + ); + })} + {!services && ( + loading topology… + )} + +
+
+ +
+
+ Recent Sessions +
+ {sessions && sessions.length > 0 ? ( + sessions.slice(0, 6).map(s => ( +
+
+ {new Date(s.created_at * 1000).toLocaleTimeString()} + + NET {s.id} — {s.service} ← {s.client_ip} + +
+ )) + ) : ( +
+ {sessions === null ? 'Loading…' : 'No active sessions'} +
+ )} +
+
+ +
+
+ Services + {onlineSvc}/{totalSvc} online +
+ + + + + + + + + + + + {services?.map(svc => ( + + + + + + + + ))} + +
NameStatusReplicasDependenciesTimeout
{svc.name} + + {svc.registered ? 'Online' : 'Offline'} + + + {svc.replicas.length} + + {svc.proxy_dependencies.map(d => ( + {d} + ))} + + {svc.timeout_secs ? `${svc.timeout_secs}s` : '—'} +
+
+
+
+ ); +} diff --git a/members/nullnet-server/ui/src/pages/Nodes.tsx b/members/nullnet-server/ui/src/pages/Nodes.tsx new file mode 100644 index 0000000..402c7b7 --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Nodes.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import type { NodeJson } from '../types'; + +export default function Nodes() { + const { data: nodes, loading } = useApi('/api/nodes', 5000); + const [selected, setSelected] = useState(null); + + const selectedNode = nodes?.find(n => n.ip === selected) ?? null; + + function select(ip: string) { + setSelected(prev => (prev === ip ? null : ip)); + } + + return ( + + {nodes?.length ?? 0} connected + live · 5s + + } + > +
+
+
Connected Nodes
+
Click a node to inspect hosted services
+ + {loading &&
Loading…
} + +
+ {nodes?.map(node => ( +
select(node.ip)} + > +
+
+
+ + {node.ip} + Online +
+
{node.ip}
+
+
+
{node.hosted_services.length} service{node.hosted_services.length !== 1 ? 's' : ''}
+
+
+ +
+
+
Services
+
{node.hosted_services.length}
+
+
+
Stacks
+
+ {new Set(node.hosted_services.map(s => s.stack)).size} +
+
+
+ + {node.hosted_services.length > 0 && ( +
+ {node.hosted_services.map(s => ( + {s.name} + ))} +
+ )} +
+ ))} + + {!loading && nodes?.length === 0 && ( +
No nodes connected
+ )} +
+
+ +
+
+ {selectedNode ? selectedNode.ip : '–'} + {selectedNode && ( + + )} +
+ + {selectedNode ? ( +
+
+
Connection
+
+ IP Address + {selectedNode.ip} +
+
+ Status + Online +
+
+ +
+
Hosted Services
+ {selectedNode.hosted_services.length > 0 ? ( + + + + + + {selectedNode.hosted_services.map(s => ( + + + + + ))} + +
ServiceStack
{s.name}{s.stack}
+ ) : ( +
No services hosted
+ )} +
+
+ ) : ( +
+ + Select a node +
+ )} +
+
+
+ ); +} diff --git a/members/nullnet-server/ui/src/pages/Pool.tsx b/members/nullnet-server/ui/src/pages/Pool.tsx new file mode 100644 index 0000000..951538d --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Pool.tsx @@ -0,0 +1,89 @@ +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import type { PoolJson } from '../types'; + +export default function Pool() { + const { data: pool, loading } = useApi('/api/pool', 5000); + + const pct = pool ? (pool.in_use / pool.total) * 100 : 0; + const pctStr = pct.toFixed(1); + const warn = pct >= 80; + + return ( + live · 5s + } + > +
+
Network ID Pool
+
Network ID allocation status
+ + {loading &&
Loading…
} + + {pool && ( +
+
+
ID Pool
+
+
+ + {pool.in_use.toLocaleString()} + + / {pool.total.toLocaleString()} +
+ {pctStr}% +
+
+
+
+
+ {pool.free.toLocaleString()} free + {warn && ⚠ above 80% threshold} +
+
+ +
+
Breakdown
+ + + + + + + + + + + + + + + + + + + +
Total capacity + {pool.total.toLocaleString()} +
In use + {pool.in_use.toLocaleString()} +
Free + {pool.free.toLocaleString()} +
Utilization + {pctStr}% +
+
+
+ )} +
+ + ); +} diff --git a/members/nullnet-server/ui/src/pages/Services.tsx b/members/nullnet-server/ui/src/pages/Services.tsx new file mode 100644 index 0000000..e78650f --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Services.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import { useStack } from '../StackContext'; +import type { ServiceJson } from '../types'; + +type Filter = 'all' | 'online' | 'offline'; + +export default function Services() { + const { stack } = useStack(); + const { data: services, loading } = useApi(`/api/services/${stack}`, 5000); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (name: string) => + setExpanded(prev => { + const next = new Set(prev); + next.has(name) ? next.delete(name) : next.add(name); + return next; + }); + + const visible = (services ?? []).filter(svc => { + if (filter === 'online' && !svc.registered) return false; + if (filter === 'offline' && svc.registered) return false; + if (query && !svc.name.toLowerCase().includes(query.toLowerCase())) return false; + return true; + }); + + return ( + live · 5s + } + > +
+
+
Services
+
+ setQuery(e.target.value)} + /> + + + +
+
+ +
+ + + + + + + + + + + + + + {loading && ( + + )} + {visible.map(svc => { + const isOpen = expanded.has(svc.name); + const totalSessions = svc.replicas.reduce((n, r) => n + r.active_sessions, 0); + return ( + <> + toggle(svc.name)} style={{ cursor: 'pointer' }}> + + + + + + + + + {isOpen && ( + + + + )} + + ); + })} + {!loading && visible.length === 0 && ( + + )} + +
NameStatusReplicasDependenciesSessionsTimeout
Loading…
+ + {svc.name} + + {svc.registered ? 'Online' : 'Offline'} + + + {svc.replicas.length} + + {svc.proxy_dependencies.length > 0 + ? svc.proxy_dependencies.map(d => {d}) + : } + + {totalSessions > 0 ? totalSessions : 0} + + {svc.timeout_secs ? `${svc.timeout_secs}s` : '—'} +
+
+
+
+
Replicas
+ {svc.replicas.length > 0 ? ( + + + {svc.replicas.some(r => r.docker_container) && } + + + {svc.replicas.map((r, i) => ( + + + + + {r.docker_container && } + + ))} + +
IPPortSessionsContainer
{r.ip}:{r.port} 0 ? 'var(--green)' : 'var(--t2)' }}>{r.active_sessions}{r.docker_container}
+ ) : ( +
No replicas
+ )} +
+
+
Config
+ + + {svc.max_networks != null && ( + + + + + )} + {Object.entries(svc.triggers).map(([port, chain]) => ( + + + + + ))} + {svc.proxy_dependencies.length > 0 && ( + + + + + )} + +
Max networks{svc.max_networks}
Trigger :{port}{chain.join(' → ')}
Dependencies{svc.proxy_dependencies.map(d => {d})}
+
+
+
+
No services match
+
+
+
+ ); +} diff --git a/members/nullnet-server/ui/src/pages/Sessions.tsx b/members/nullnet-server/ui/src/pages/Sessions.tsx new file mode 100644 index 0000000..d544895 --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Sessions.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import type { SessionJson } from '../types'; + +export default function Sessions() { + const { data: sessions, loading, refetch } = useApi('/api/sessions', 5000); + const [tearing, setTearing] = useState>(new Set()); + + async function teardown(id: number) { + if (!confirm(`Force teardown session ${id}?`)) return; + setTearing(prev => new Set(prev).add(id)); + try { + await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); + refetch(); + } finally { + setTearing(prev => { const next = new Set(prev); next.delete(id); return next; }); + } + } + + function formatTime(unix: number) { + return new Date(unix * 1000).toLocaleTimeString(); + } + + const list = sessions ?? []; + + return ( + live · 5s + } + > +
+
+ {list.length} + active isolated networks +
+ +
+
+ Active Sessions + auto-refresh 5s +
+ + + + + + + + + + + + + + + {loading && ( + + )} + {list.map(s => ( + + + + + + + + + + + ))} + {!loading && list.length === 0 && ( + + )} + +
Net IDServiceClient IPClient NetServer NetChainsCreated
Loading…
+ + {s.id} + + {s.service}{s.client_ip}{s.client_net}{s.server_net} 1 ? 'var(--amber)' : 'var(--t1)' }}> + {s.chain_depth} + + {formatTime(s.created_at)} + + +
No active sessions
+
+
+
+ ); +} diff --git a/members/nullnet-server/ui/src/types.ts b/members/nullnet-server/ui/src/types.ts new file mode 100644 index 0000000..7a53925 --- /dev/null +++ b/members/nullnet-server/ui/src/types.ts @@ -0,0 +1,47 @@ +export interface HealthJson { + status: string; +} + +export interface ReplicaJson { + ip: string; + port: number; + docker_container?: string; + active_sessions: number; +} + +export interface ServiceJson { + name: string; + registered: boolean; + replicas: ReplicaJson[]; + proxy_dependencies: string[]; + triggers: Record; + timeout_secs?: number; + max_networks?: number; +} + +export interface HostedServiceJson { + name: string; + stack: string; +} + +export interface NodeJson { + ip: string; + hosted_services: HostedServiceJson[]; +} + +export interface PoolJson { + total: number; + in_use: number; + free: number; +} + +export interface SessionJson { + id: number; + network_id: number; + client_ip: string; + client_net: string; + server_net: string; + service: string; + chain_depth: number; + created_at: number; +} diff --git a/members/nullnet-server/ui/vite.config.ts b/members/nullnet-server/ui/vite.config.ts index 8b0f57b..272737a 100644 --- a/members/nullnet-server/ui/vite.config.ts +++ b/members/nullnet-server/ui/vite.config.ts @@ -4,4 +4,9 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, })