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', + }, + }, })