[feat](maestro): serve the public hub app from an embedded bundle

The browser hub is a status/metadata reader for an instance, separate from the operator console and its owner cookies. Build it as its own Preact bundle at /hub/ outside the owner gate, embed it via //go:embed all:hub/dist (with a committed index placeholder so a clean checkout still builds), and apply a strict CSP because it shares the console origin. The Docker build stages the hub build alongside the console; dist assets remain ignored.
This commit is contained in:
2026-10-19 02:40:26 +01:00
parent b7fa29849d
commit ad670f30ac
35 changed files with 4211 additions and 0 deletions
+3
View File
@@ -2,6 +2,9 @@
src/frontend/node_modules/
src/frontend/dist/
src/frontend/.vite/
src/hub/node_modules/
src/hub/dist/
src/hub/.vite/
# Block compiled native Go binary targets and testing outputs
src/Maestro
+11
View File
@@ -22,6 +22,15 @@ RUN bun ci
COPY src/frontend/ ./
RUN bun run build
# Stage 1b: Build the PUBLIC hub SPA bundle (separate bundle, served at /hub/
# outside the owner gate; browser-edge contract §10.5).
FROM oven/bun:1.3.9-alpine AS hub-builder
WORKDIR /app
COPY src/hub/package.json src/hub/bun.lock ./
RUN bun ci
COPY src/hub/ ./
RUN bun run build
# Stage 2: Build the static Go binary
FROM golang:1.26-alpine AS go-builder
WORKDIR /app
@@ -31,6 +40,8 @@ RUN go mod download
COPY src/ .
# Copy the built UI into the frontend folder so go:embed finds it
COPY --from=frontend-builder /app/dist ./frontend/dist
# Copy the built hub SPA into hub/dist so go:embed all:hub/dist finds it
COPY --from=hub-builder /app/dist ./hub/dist
# Build a statically linked binary, stripping debug symbols (-s -w) for smaller size
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
RUN apk add --no-cache ca-certificates
+11
View File
@@ -26,6 +26,15 @@ RUN bun ci
COPY src/frontend/ ./
RUN bun run build
# Stage 1b: Build the PUBLIC hub SPA bundle (separate bundle, served at /hub/
# outside the owner gate; browser-edge contract §10.5).
FROM oven/bun:1.3.9-alpine AS hub-builder
WORKDIR /app
COPY src/hub/package.json src/hub/bun.lock ./
RUN bun ci
COPY src/hub/ ./
RUN bun run build
# Stage 2: Build the static Go binary
FROM golang:1.26-alpine AS go-builder
WORKDIR /app
@@ -34,6 +43,8 @@ RUN go mod download
COPY src/ .
# Copy the built UI into the frontend folder so go:embed finds it
COPY --from=frontend-builder /app/dist ./frontend/dist
# Copy the built hub SPA into hub/dist so go:embed all:hub/dist finds it
COPY --from=hub-builder /app/dist ./hub/dist
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .
# Stage 3: Debug-final image (alpine base, not scratch, for shell access)
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist/assets/
.vite/
+251
View File
@@ -0,0 +1,251 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "hub",
"dependencies": {
"preact": "^10.29.3",
},
"devDependencies": {
"@preact/preset-vite": "^2.10.5",
"@types/node": "^24.13.2",
"typescript": "~6.0.2",
"vite": "^8.1.1",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-annotate-as-pure": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="],
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
"@babel/plugin-syntax-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/plugin-transform-react-jsx": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="],
"@babel/plugin-transform-react-jsx-development": ["@babel/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="],
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ=="],
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="],
"@preact/preset-vite": ["@preact/[email protected]", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="],
"@prefresh/babel-plugin": ["@prefresh/[email protected]", "", { "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0" } }, "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA=="],
"@prefresh/core": ["@prefresh/[email protected]", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ=="],
"@prefresh/utils": ["@prefresh/[email protected]", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="],
"@prefresh/vite": ["@prefresh/[email protected]", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA=="],
"@rolldown/binding-android-arm64": ["@rolldown/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg=="],
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="],
"babel-plugin-transform-hook-names": ["[email protected]", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="],
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="],
"boolbase": ["[email protected]", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="],
"caniuse-lite": ["[email protected]", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="],
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"css-select": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["[email protected]", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"electron-to-chromium": ["[email protected]", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="],
"entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"he": ["[email protected]", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"kolorist": ["[email protected]", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"node-html-parser": ["[email protected]", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="],
"node-releases": ["[email protected]", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="],
"nth-check": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["[email protected]", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
"preact": ["[email protected]", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
"rolldown": ["[email protected]", "", { "dependencies": { "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-x64-msvc": "1.2.13" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ=="],
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"simple-code-frame": ["[email protected]", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="],
"source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["[email protected]", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"stack-trace": ["[email protected]", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="],
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
"vite": ["[email protected]", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
"vite-prerender-plugin": ["[email protected]", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zimmerframe": ["[email protected]", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@prefresh/vite/@rollup/pluginutils": ["@rollup/[email protected]", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@prefresh/vite/@rollup/pluginutils/picomatch": ["[email protected]", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
}
}
+27
View File
@@ -0,0 +1,27 @@
<!--
Copyright (C) 2026 Dcntrlized
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Hub">
<rect width="32" height="32" rx="7" fill="#101318"/>
<circle cx="16" cy="16" r="3.2" fill="#e556a4"/>
<circle cx="16" cy="6.5" r="2.2" fill="#e8ecf2"/>
<circle cx="25.5" cy="16" r="2.2" fill="#e8ecf2"/>
<circle cx="16" cy="25.5" r="2.2" fill="#e8ecf2"/>
<circle cx="6.5" cy="16" r="2.2" fill="#e8ecf2"/>
<g stroke="#e556a4" stroke-width="1.6">
<path d="M16 13.2V8.7M18.8 16h4.5M16 18.8v4.5M13.2 16H8.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<!--
Copyright (C) 2026 Dcntrlized
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/hub/favicon.svg" />
<title>Hub</title>
<script type="module" crossorigin src="/hub/assets/index-CLg9ZpTb.js"></script>
<link rel="stylesheet" crossorigin href="/hub/assets/index-BXFB8T1y.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<!--
Copyright (C) 2026 Dcntrlized
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/hub/favicon.svg" />
<title>Hub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{
"name": "hub",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.29.3"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.5",
"@types/node": "^24.13.2",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+27
View File
@@ -0,0 +1,27 @@
<!--
Copyright (C) 2026 Dcntrlized
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Hub">
<rect width="32" height="32" rx="7" fill="#101318"/>
<circle cx="16" cy="16" r="3.2" fill="#e556a4"/>
<circle cx="16" cy="6.5" r="2.2" fill="#e8ecf2"/>
<circle cx="25.5" cy="16" r="2.2" fill="#e8ecf2"/>
<circle cx="16" cy="25.5" r="2.2" fill="#e8ecf2"/>
<circle cx="6.5" cy="16" r="2.2" fill="#e8ecf2"/>
<g stroke="#e556a4" stroke-width="1.6">
<path d="M16 13.2V8.7M18.8 16h4.5M16 18.8v4.5M13.2 16H8.7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+132
View File
@@ -0,0 +1,132 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { JSX } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { DataProvider, Shell, ThemeProvider, useData } from './components/shell';
import { matchPath, replace, subscribe, href, type Route } from './lib/router';
import { instanceDisplayName } from './lib/format';
import { InstancesScreen } from './screens/instances';
import { InstanceHomeScreen } from './screens/instance-home';
import { ServicesScreen } from './screens/services';
import { ServiceDetailScreen } from './screens/service-detail';
import { TrustedScreen } from './screens/trusted';
import { SettingsScreen } from './screens/settings';
import { NotFoundScreen } from './screens/not-found';
export function App() {
return (
<ThemeProvider>
<DataProvider>
<RouterContent />
</DataProvider>
</ThemeProvider>
);
}
function RouterContent() {
const { profile } = useData();
const [route, setRoute] = useState<Route>(() => matchPath(window.location.pathname));
useEffect(() => subscribe(() => setRoute(matchPath(window.location.pathname))), []);
// Redirects (§3.1): bare root and bare /local replace (never a history entry).
// Targets must carry the frozen /hub/ mount prefix — a bare "/instances"
// would leave the hub bundle and land on the operator console's path space.
useEffect(() => {
if (route.name === 'redirect-root') {
replace(href('/instances'));
} else if (route.name === 'redirect-local') {
replace(href('/instances/local/services'));
}
}, [route]);
// document.title per route (§3.1 column; WCAG 2.4.2).
useEffect(() => {
setDocumentTitle(route, profile);
}, [route, profile]);
// Focus moves to the route h1 on client-side navigation (§3.2).
useEffect(() => {
if (route.name === 'redirect-root' || route.name === 'redirect-local') return;
const raf = requestAnimationFrame(() => {
const h1 = document.querySelector('main h1');
if (h1 instanceof HTMLElement) h1.focus();
});
return () => cancelAnimationFrame(raf);
}, [route]);
const screen = renderScreen(route);
return <Shell route={route}>{screen}</Shell>;
}
function renderScreen(route: Route): JSX.Element {
switch (route.name) {
case 'redirect-root':
return <InstancesScreen />;
case 'redirect-local':
return <InstanceHomeScreen />;
case 'instances':
return <InstancesScreen />;
case 'services':
return <ServicesScreen />;
case 'service-detail':
return <ServiceDetailScreen serviceName={route.serviceName} />;
case 'trusted':
return <TrustedScreen />;
case 'settings':
return <SettingsScreen />;
default:
return <NotFoundScreen />;
}
}
function setDocumentTitle(route: Route, profile: ReturnType<typeof useData>['profile']): void {
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name, 'instance')
: 'instance';
switch (route.name) {
case 'instances':
document.title = 'Hub';
break;
case 'services':
document.title = profile
? `Services — ${instanceName} · Hub`
: 'Loading… · Hub';
break;
case 'service-detail': {
const service = profile?.services?.find(s => s.service_name === route.serviceName);
const serviceName = service?.title || route.serviceName;
document.title = profile ? `${serviceName} — ${instanceName} · Hub` : 'Loading… · Hub';
break;
}
case 'trusted':
document.title = profile
? `Trusted partners — ${instanceName} · Hub`
: 'Loading… · Hub';
break;
case 'settings':
document.title = 'Settings · Hub';
break;
case 'not-found':
document.title = 'Page not found · Hub';
break;
default:
document.title = 'Hub';
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { JSX } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { CopyControl } from './ui';
/**
* Instance details dialog (§4.2): role=dialog aria-modal, focus trap,
* Escape + ✕ + backdrop close, focus returns to the invoker. Rows: Name / URL
* (window.location.origin) / Fingerprint (mono + Copy).
*/
export function DetailsDialog({
open,
name,
fingerprint,
onClose,
}: {
open: boolean;
name: string;
fingerprint?: string;
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const origin = typeof window !== 'undefined' ? window.location.origin : '';
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
if (!dialog) return;
// Focus the dialog on open.
dialog.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key !== 'Tab') return;
// Simple focus trap: keep Tab cycling within the dialog.
const focusables = dialog.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
class="dialog-backdrop"
onClick={(e: JSX.TargetedMouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="details-dialog-title"
tabindex={-1}
onClick={(e: JSX.TargetedMouseEvent<HTMLDivElement>) => e.stopPropagation()}
>
<div class="dialog-head">
<div id="details-dialog-title" class="dialog-title">
{name}
</div>
<button type="button" class="icon-btn" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
</div>
<dl class="detail-list">
<div class="detail-row">
<dt class="detail-label">Name</dt>
<dd class="detail-value">{name}</dd>
</div>
<div class="detail-row">
<dt class="detail-label">URL</dt>
<dd class="detail-value">
<CopyControl text={origin} />
</dd>
</div>
<div class="detail-row">
<dt class="detail-label">Fingerprint</dt>
<dd class="detail-value">
{fingerprint ? <CopyControl text={fingerprint} /> : <span class="muted">—</span>}
</dd>
</div>
</dl>
</div>
</div>
);
}
function CloseIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { JSX } from 'preact';
import { useId, useRef, useState } from 'preact/hooks';
import { CopyControl, Chip } from './ui';
import { PartnerStatus } from './status';
import type { TrustedInstance } from '../lib/types';
let idCounter = 0;
const nextId = () => `partner-${++idCounter}`;
/**
* A trusted-partner row (§4.5). Tapping opens an inline disclosure explaining
* browsers can't browse partners; Escape inside collapses and returns focus to
* the row button. No focus trap and no focus movement on open — the panel is
* adjacent DOM and reading order continues naturally.
*/
export function PartnerRow({ partner }: { partner: TrustedInstance }) {
const [open, setOpen] = useState(false);
const rowId = useId() || nextId();
const panelId = `${rowId}-panel`;
const titleId = `${rowId}-title`;
const btnRef = useRef<HTMLButtonElement | null>(null);
const name = partner.name || partner.url;
const toggle = () => setOpen(o => !o);
const close = () => {
setOpen(false);
btnRef.current?.focus();
};
const onKeyDown = (e: JSX.TargetedKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
close();
}
};
return (
<div>
<button
type="button"
ref={btnRef}
class="partner-row"
aria-expanded={open}
aria-controls={panelId}
aria-label={`${name} — trusted partner`}
onClick={toggle}
>
<span class="partner-icon" aria-hidden="true">
<LinkIcon />
</span>
<span class="partner-body">
<span class="partner-head">
<span class="partner-name">{name}</span>
{partner.status ? <PartnerStatus status={partner.status} /> : null}
</span>
<span class="partner-url mono">{partner.url}</span>
{partner.scopes && partner.scopes.length > 0 ? (
<span class="partner-scopes">{partner.scopes.join(', ')}</span>
) : null}
<span class="partner-meta">
<Chip soft>trusted</Chip>
</span>
</span>
<span class="partner-chevron" aria-hidden="true">
<ChevronIcon />
</span>
</button>
{open ? (
<div
id={panelId}
class="disclosure"
role="region"
aria-labelledby={titleId}
onKeyDown={onKeyDown}
>
<div id={titleId} class="disclosure-title">
Can't browse partners from the browser
</div>
<div class="disclosure-body">
A browser page may only read the instance that serves it, so this
hub can't fetch {name}’s profile. Open its address directly, or use
the Hub app on a phone.
</div>
<div class="mono tiny muted">{partner.url}</div>
<div class="disclosure-actions">
<CopyControl text={partner.url} />
<button type="button" class="btn btn-ghost small" onClick={close}>
Close
</button>
</div>
</div>
) : null}
</div>
);
}
function LinkIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
function ChevronIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="m9 18 6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { PublicService } from '../lib/types';
import { href } from '../lib/router';
import { linkClick } from './shell';
import { serviceTone, shortVersion } from '../lib/format';
import { Chip, Dot } from './ui';
/**
* Read-only service card (§4.3). The card is a real <a> to the detail route;
* the status dot is decorative (aria-hidden) — the status repeats as chip text
* and in an accessible description so it is never colour-only.
*/
export function ServiceCard({ service }: { service: PublicService }) {
const title = service.title || service.service_name;
const tone = serviceTone(service.status);
const isDb = service.capabilities?.includes('database') ?? false;
const detailHref = href(`/instances/local/services/${encodeURIComponent(service.service_name)}`);
return (
<a
class="card card-link service-card"
href={detailHref}
aria-label={`${title} — status: ${service.status}`}
onClick={linkClick(detailHref)}
>
<div class="service-card-head">
<span class="service-card-icon" aria-hidden="true">
{isDb ? <DbIcon /> : <BoxIcon />}
</span>
<span class="service-card-title">{title}</span>
<Dot tone={tone} />
</div>
<div class="service-card-name mono">{service.service_name}</div>
{service.description ? <div class="service-card-desc">{service.description}</div> : null}
<div class="service-card-chips">
{service.version ? (
<Chip soft title={service.version}>{`v${shortVersion(service.version)}`}</Chip>
) : null}
{isDb ? <Chip soft>database</Chip> : null}
<Chip soft>{service.status}</Chip>
</div>
</a>
);
}
function BoxIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M3.27 6.96 12 12.01l8.73-5.05" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 22.08V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function DbIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" stroke-width="2" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" stroke="currentColor" stroke-width="2" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" stroke="currentColor" stroke-width="2" />
</svg>
);
}
+531
View File
@@ -0,0 +1,531 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { JSX } from 'preact';
import { createContext } from 'preact';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { fetchPublicProfile, type FetchResult } from '../lib/api';
import { backOrReplace, href, push, type Route } from '../lib/router';
import type { DataState, InstancePublicProfile, SectionState } from '../lib/types';
import { IconButton } from './ui';
/* ------------------------------------------------------------------ */
/* Theme */
/* ------------------------------------------------------------------ */
export type Theme = 'light' | 'dark';
const ThemeCtx = createContext<{ theme: Theme; toggle: () => void }>({
theme: 'light',
toggle: () => {},
});
export function useTheme() {
return useContext(ThemeCtx);
}
function effectiveTheme(): Theme {
try {
const stored = localStorage.getItem('settings.theme');
if (stored === 'light' || stored === 'dark') return stored;
} catch {
/* ignore storage errors */
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
export function ThemeProvider({ children }: { children: JSX.Element }) {
const [theme, setTheme] = useState<Theme>(effectiveTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
try {
localStorage.setItem('settings.theme', theme);
} catch {
/* ignore storage errors */
}
}, [theme]);
const toggle = useCallback(() => {
setTheme(t => (t === 'dark' ? 'light' : 'dark'));
}, []);
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
}
/* ------------------------------------------------------------------ */
/* Data polling (§4.0) */
/* ------------------------------------------------------------------ */
interface CachedProfile {
fetchedAt: number;
instance: InstancePublicProfile['instance'];
sections: { services: SectionState; trusted: SectionState };
}
const CACHE_KEY = 'hub.profile';
const BACKOFF = [5000, 15000, 30000, 60000];
const PROBE_TIMEOUT = 10000;
const initialData: DataState = {
profile: null,
status: 'disconnected',
probing: false,
error: null,
lastProbedAt: null,
lastProbeFailed: false,
offline: false,
sections: null,
};
function readCache(): CachedProfile | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw) as CachedProfile;
} catch {
return null;
}
}
function writeCache(c: CachedProfile): void {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch {
/* ignore */
}
}
interface DataApi extends DataState {
refresh: () => void;
announce: (msg: string) => void;
announcement: string;
}
const DataCtx = createContext<DataApi>({
...initialData,
refresh: () => {},
announce: () => {},
announcement: '',
});
export function useData(): DataApi {
return useContext(DataCtx);
}
export function DataProvider({ children }: { children: JSX.Element }) {
const [state, setState] = useState<DataState>(() => {
const cached = readCache();
if (!cached) return initialData;
return {
...initialData,
profile: {
instance: cached.instance,
services: cached.sections.services.value as never,
trusted_instances: cached.sections.trusted.value as never,
},
sections: cached.sections,
};
});
const [announcement, setAnnouncement] = useState('');
// Refs for the polling loop (avoid stale closures).
const inFlightRef = useRef(false);
const failuresRef = useRef(0);
const statusRef = useRef<DataState['status']>('disconnected');
const timerRef = useRef<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const profileRef = useRef<InstancePublicProfile | null>(null);
const sectionsRef = useRef<{ services: SectionState; trusted: SectionState } | null>(null);
const announce = useCallback((msg: string) => {
setAnnouncement(msg);
}, []);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const schedule = useCallback(
(delay: number) => {
clearTimer();
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
if (document.visibilityState === 'visible') {
void runOnce();
}
}, delay);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[clearTimer],
);
const updateFromResult = useCallback(
(res: FetchResult) => {
const prevStatus = statusRef.current;
failuresRef.current = 0;
statusRef.current = 'connected';
profileRef.current = res.profile;
sectionsRef.current = res.sections;
writeCache({
fetchedAt: Date.now(),
instance: res.profile.instance,
sections: res.sections,
});
setState(s => ({
...s,
profile: res.profile,
sections: res.sections,
status: 'connected',
error: null,
lastProbeFailed: false,
offline: false,
lastProbedAt: Date.now(),
probing: false,
}));
if (prevStatus !== 'connected') {
announce('Instance connected');
}
},
[announce],
);
const handleFailure = useCallback(
(message: string, offline: boolean) => {
const prevStatus = statusRef.current;
failuresRef.current += 1;
const f = failuresRef.current;
// Hysteresis: connected→flaky requires two consecutive failures.
if (prevStatus === 'connected' && f >= 2) {
statusRef.current = 'flaky';
}
const newStatus = statusRef.current;
setState(s => ({
...s,
status: newStatus,
error: message,
lastProbeFailed: true,
offline,
lastProbedAt: Date.now(),
probing: false,
}));
if (prevStatus !== newStatus && !offline) {
announce(`Instance ${newStatus}`);
}
},
[announce],
);
const runOnce = useCallback(async () => {
if (inFlightRef.current) return;
// Offline: don't burn a probe; report offline and wait for `online`.
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
handleFailure("You're offline.", true);
schedule(5000);
return;
}
inFlightRef.current = true;
abortRef.current = new AbortController();
setState(s => ({ ...s, probing: true }));
try {
const res = await fetchPublicProfile(window.location.origin, PROBE_TIMEOUT);
updateFromResult(res);
} catch (e) {
const msg =
e instanceof Error
? taxonomyMessage(e.message)
: 'Instance unreachable — the instance is not answering.';
handleFailure(msg, false);
} finally {
inFlightRef.current = false;
abortRef.current = null;
// Schedule the next probe per backoff.
const f = failuresRef.current;
const delay = f === 0 ? 5000 : BACKOFF[Math.min(f - 1, BACKOFF.length - 1)];
schedule(delay);
}
}, [updateFromResult, handleFailure, schedule]);
// Boot + events.
useEffect(() => {
void runOnce();
const onFocus = () => {
if (document.visibilityState === 'visible') void runOnce();
};
const onVis = () => {
if (document.visibilityState === 'visible') {
void runOnce();
} else {
clearTimer();
}
};
const onOnline = () => void runOnce();
const onOffline = () => {
handleFailure("You're offline.", true);
clearTimer();
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVis);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
clearTimer();
};
}, [runOnce, handleFailure, clearTimer]);
// Manual refresh (shared in-flight guard with polling).
const refresh = useCallback(() => {
void runOnce();
}, [runOnce]);
const value = useMemo<DataApi>(
() => ({
...state,
refresh,
announce,
announcement,
}),
[state, refresh, announce, announcement],
);
return (
<DataCtx.Provider value={value}>
{children}
<div class="visually-hidden" role="status" aria-live="polite">
{announcement}
</div>
</DataCtx.Provider>
);
}
/** Map a raw fetch error message onto the taxonomy banner copy (§4.0). */
function taxonomyMessage(raw: string): string {
if (raw.includes('has no /api/instance')) {
return 'This instance did not return a public profile.';
}
if (raw.includes('HTTP 401') || raw.includes('HTTP 403')) {
return 'This profile is not reachable from your network.';
}
if (raw.includes('HTTP ')) {
return 'That address is not a Dcntrlized instance.';
}
return 'Instance unreachable — the instance is not answering.';
}
/** Derive the exact taxonomy banner copy from an error (for screens). */
export function taxonomyCopy(msg: string | null, status?: number): string {
if (!msg) return 'Instance unreachable — the instance is not answering.';
if (status === 401 || status === 403) {
return 'This profile is not reachable from your network.';
}
return taxonomyMessage(msg);
}
/* ------------------------------------------------------------------ */
/* Shell layout */
/* ------------------------------------------------------------------ */
export function Shell({
route,
children,
}: {
route: Route;
children: JSX.Element;
}) {
const { probing, refresh } = useData();
const inFlight = probing;
const isInstances = route.name === 'instances';
return (
<div class="app-shell">
<a class="skip-link" href="#main-content">
Skip to content
</a>
<aside class="rail">
<div class="rail-brand">Hub</div>
<a
class="rail-link"
href={href('/instances')}
aria-current={isInstances ? 'page' : undefined}
onClick={linkClick(href('/instances'))}
>
Hub
</a>
<a
class="rail-link"
href={href('/settings')}
aria-current={route.name === 'settings' ? 'page' : undefined}
onClick={linkClick(href('/settings'))}
>
Settings
</a>
</aside>
<div class="shell-main-wrap">
<header class="topbar">
<a class="topbar-brand" href={href('/instances')} onClick={linkClick(href('/instances'))}>
Hub
</a>
<div class="spacer" />
{isInstances ? (
<>
<IconButton label="Refresh" onClick={refresh} disabled={inFlight}>
<RefreshIcon />
</IconButton>
<IconButton label="Settings" onClick={() => push(href('/settings'))}>
<SettingsIcon />
</IconButton>
</>
) : null}
</header>
<main id="main-content" tabindex={-1}>
<div class="shell-body">{children}</div>
</main>
</div>
</div>
);
}
export function linkClick(target: string) {
return (e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
// Only intercept plain left-clicks (middle-click / ctrl+click stay native).
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
push(target);
};
}
/* ------------------------------------------------------------------ */
/* Instance-scope header + tab strip */
/* ------------------------------------------------------------------ */
export function TabStrip({ active }: { active: 'services' | 'trusted' }) {
const { refresh, probing } = useData();
return (
<nav class="tabstrip" aria-label="Instance sections">
<a
class="tab"
href={href('/instances/local/services')}
aria-current={active === 'services' ? 'page' : undefined}
onClick={linkClick(href('/instances/local/services'))}
>
Services
</a>
<a
class="tab"
href={href('/instances/local/trusted')}
aria-current={active === 'trusted' ? 'page' : undefined}
onClick={linkClick(href('/instances/local/trusted'))}
>
Trusted
</a>
<div class="spacer" />
<IconButton label="Refresh" onClick={refresh} disabled={probing}>
<RefreshIcon />
</IconButton>
</nav>
);
}
export function BackChevron({ target, label }: { target: string; label: string }) {
// A real <a href> (middle-click/new-tab safe); the click handler applies the
// one back rule (§3.1.1) only for plain left-clicks.
return (
<a
class="icon-btn"
href={target}
aria-label={label}
onClick={(e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
backOrReplace(target);
}}
>
<BackIcon />
</a>
);
}
export function InfoButton({ onClick, label = 'Instance details' }: { onClick: () => void; label?: string }) {
return (
<IconButton label={label} onClick={onClick}>
<InfoIcon />
</IconButton>
);
}
function RefreshIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M21 12a9 9 0 1 1-2.64-6.36" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function SettingsIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
function BackIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="m15 18-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function InfoIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
<path d="M12 16v-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="8" r="1" fill="currentColor" />
</svg>
);
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { InstanceStatus } from '../lib/types';
import {
capitalize,
instanceTone,
partnerOk,
serviceTone,
type Tone,
} from '../lib/format';
import { Dot } from './ui';
/**
* Status pill: `*-soft` fill + 6 px status dot + INK word (§6.2). The word is
* visible text (the accessible name); colour is never the only signal.
*/
export function StatusPill({ status }: { status: string }) {
const tone = serviceTone(status);
return (
<span class={`pill pill-${tone}`}>
<Dot tone={tone} />
<span>{capitalize(status)}</span>
</span>
);
}
/** Partner status: ok (online/authorized) or muted. */
export function PartnerStatus({ status }: { status: string }) {
const ok = partnerOk(status);
return (
<span class="row">
<Dot tone={ok ? 'ok' : 'muted'} />
<span class={`tiny ${ok ? 'ink-soft' : 'muted'}`}>{capitalize(status)}</span>
</span>
);
}
/** Instance connectivity indicator: wifi icon + status word + aria-label. */
export function InstanceStatusBadge({ status }: { status: InstanceStatus }) {
const tone = instanceTone(status);
return (
<span
class="row"
role="img"
aria-label={`Instance status: ${status}`}
title={`Instance status: ${status}`}
>
<WifiIcon tone={tone} />
<span class={`tiny ${tone === 'ok' ? 'ink-soft' : tone === 'warn' ? 'ink-soft' : 'muted'}`}>
{status}
</span>
</span>
);
}
function WifiIcon({ tone }: { tone: Tone }) {
const color = tone === 'ok' ? 'var(--dn-ok)' : tone === 'warn' ? 'var(--dn-warn)' : 'var(--dn-bad)';
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.55a11 11 0 0 1 14.08 0"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M1.42 9a16 16 0 0 1 21.16 0"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8.53 16.11a6 6 0 0 1 6.95 0"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="20" r="1" fill={color} />
</svg>
);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { ComponentChildren, JSX } from 'preact';
import { useCallback, useRef, useState } from 'preact/hooks';
import type { Tone } from '../lib/format';
/** Decorative icon slot. */
export type IconSlot = JSX.Element | null;
export function Card({ className = '', children }: { className?: string; children: ComponentChildren }) {
return <div className={`card ${className}`}>{children}</div>;
}
export function EmptyState({
icon,
title,
subtitle,
}: {
icon?: IconSlot;
title: string;
subtitle?: string;
}) {
return (
<div class="empty">
{icon ? <div class="empty-icon">{icon}</div> : null}
<div class="empty-title">{title}</div>
{subtitle ? <div class="empty-subtitle">{subtitle}</div> : null}
</div>
);
}
export function LoadingView({ label = 'Loading…' }: { label?: string }) {
return (
<div class="loading" role="status">
<div class="spinner" aria-hidden="true" />
<div>{label}</div>
</div>
);
}
/**
* Error/offline banner. `details` (e.g. raw HTTP status) renders inside an
* optional <details> so transport detail never becomes the primary message.
*/
export function Banner({
message,
onRetry,
offline = false,
details,
retryLabel = 'Retry',
}: {
message: string;
onRetry?: () => void;
offline?: boolean;
details?: string;
retryLabel?: string;
}) {
return (
<div class={`banner ${offline ? 'banner-offline' : ''}`} role="status">
<div class="banner-title">{message}</div>
{onRetry ? (
<div class="banner-row">
<button type="button" class="btn btn-ghost small" onClick={onRetry}>
{retryLabel}
</button>
</div>
) : null}
{details ? (
<details>
<summary class="banner-details">Details</summary>
<div class="banner-details mono">{details}</div>
</details>
) : null}
</div>
);
}
export type DotTone = Tone | 'muted';
export function Dot({ tone }: { tone: DotTone }) {
return <span class={`dot dot-${tone}`} aria-hidden="true" />;
}
export function Chip({
children,
soft = false,
title,
}: {
children: ComponentChildren;
soft?: boolean;
title?: string;
}) {
return (
<span class={`chip ${soft ? 'chip-soft' : ''}`} title={title}>
{children}
</span>
);
}
export function IconButton({
label,
onClick,
disabled,
children,
className = 'icon-btn',
}: {
label: string;
onClick?: () => void;
disabled?: boolean;
children: ComponentChildren;
className?: string;
}) {
return (
<button
type="button"
class={className}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
/**
* Clipboard Copy control (§4.0). Feature-detects navigator.clipboard; on
* absence (plain-HTTP origins) selects the rendered mono value instead and
* says "Select and copy the address" — never a lying "Copied".
*/
export function CopyControl({ text }: { text: string }) {
const codeRef = useRef<HTMLElement | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const onCopy = useCallback(() => {
const target = codeRef.current;
if (!target) return;
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setFeedback('Copied');
setCopied(true);
window.setTimeout(() => {
setCopied(false);
setFeedback(null);
}, 2000);
})
.catch(() => {
selectText(target);
setFeedback('Select and copy the address');
});
} else {
selectText(target);
setFeedback('Select and copy the address');
}
}, [text]);
return (
<span class="row">
<code ref={codeRef} class="mono">
{text}
</code>
<button type="button" class="btn btn-ghost small" onClick={onCopy}>
{copied ? 'Copied' : 'Copy'}
</button>
{feedback && !copied ? <span class="copy-feedback">{feedback}</span> : null}
</span>
);
}
function selectText(el: HTMLElement): void {
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(el);
sel.addRange(range);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type {
InstancePublicProfile,
PublicService,
SectionState,
TrustedInstance,
} from './types';
export type ProfileErrorCode =
| 'ERR_NOT_INSTANCE'
| 'ERR_UNREACHABLE'
| 'ERR_OLD_MAESTRO';
export class ProfileError extends Error {
code: ProfileErrorCode;
/** Raw HTTP status when a non-2xx response was received (may be 0). */
status: number;
constructor(code: ProfileErrorCode, message: string, status = 0) {
super(message);
this.code = code;
this.status = status;
}
}
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null;
const isArray = (v: unknown): v is unknown[] => Array.isArray(v);
/**
* Fetch and parse a Maestro public profile (GET /api/instance).
*
* Error taxonomy (§4.0):
* - fetch rejects / abort (10 s timeout) → ERR_UNREACHABLE,
* - clean 404 → ERR_OLD_MAESTRO,
* - non-2xx / non-JSON / wrong shape / empty instance.name → ERR_NOT_INSTANCE
* (401/403 are distinguished by callers via `error.status`).
*
* The raw body is returned alongside the normalized profile so callers can
* read section *presence* before null-normalization (§4.0).
*/
export interface FetchResult {
profile: InstancePublicProfile;
/** Section presence per the raw body (drives private/empty states). */
sections: {
services: SectionState;
trusted: SectionState;
};
}
function sectionState(key: string, body: Record<string, unknown>): SectionState {
if (!(key in body)) {
return { presence: 'absent', value: [] };
}
const raw = body[key];
if (raw === null) {
return { presence: 'null', value: [] };
}
if (isArray(raw)) {
return {
presence: raw.length === 0 ? 'empty' : 'present',
value: raw as PublicService[] | TrustedInstance[],
};
}
// Malformed-but-present: treat as empty for safety.
return { presence: 'empty', value: [] };
}
export async function fetchPublicProfile(
baseUrl: string,
timeoutMs = 10000,
): Promise<FetchResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res: Response;
try {
res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/instance`, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
} catch {
throw new ProfileError('ERR_UNREACHABLE', `cannot reach ${baseUrl}`);
} finally {
clearTimeout(timer);
}
if (res.status === 404) {
throw new ProfileError('ERR_OLD_MAESTRO', `${baseUrl} has no /api/instance`, 404);
}
if (!res.ok) {
throw new ProfileError('ERR_NOT_INSTANCE', `HTTP ${res.status} from ${baseUrl}`, res.status);
}
let body: unknown;
try {
body = await res.json();
} catch {
throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} sent non-JSON`);
}
if (!isRecord(body) || !isRecord(body.instance)) {
throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} is not an instance`);
}
const inst = body.instance as Record<string, unknown>;
if (typeof inst.name !== 'string' || inst.name === '') {
throw new ProfileError('ERR_NOT_INSTANCE', `${baseUrl} is not an instance`);
}
return {
profile: {
instance: body.instance as unknown as InstancePublicProfile['instance'],
services: normalizeSection<PublicService>(body.services),
trusted_instances: normalizeSection<TrustedInstance>(body.trusted_instances),
},
sections: {
services: sectionState('services', body),
trusted: sectionState('trusted_instances', body),
},
};
}
function normalizeSection<T>(v: unknown): T[] | null {
return isArray(v) ? (v as T[]) : null;
}
/** Number of listed services when the array is present (not private/null). */
export function serviceCount(sections: SectionState): number | null {
return sections.presence === 'present' || sections.presence === 'empty'
? sections.value.length
: null;
}
/** Number of listed partners when the array is present (not private/null). */
export function partnerCount(sections: SectionState): number | null {
return sections.presence === 'present' || sections.presence === 'empty'
? sections.value.length
: null;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { InstanceStatus } from './types';
/** The rendered status word is exactly the runtime set (§4.6 vocabulary). */
export const statusWord = (s: InstanceStatus): string => s;
/** Capitalize a raw wire status for display (e.g. "running" → "Running"). */
export const capitalize = (s: string): string =>
s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
/** Map a service status to a semantic dot tone (ok | warn | bad). */
export type Tone = 'ok' | 'warn' | 'bad';
export function serviceTone(status: string): Tone {
if (status === 'running') return 'ok';
if (status === 'stopped') return 'warn';
return 'bad';
}
/** Map instance connectivity to a semantic dot tone. */
export function instanceTone(status: InstanceStatus): Tone {
if (status === 'connected') return 'ok';
if (status === 'flaky') return 'warn';
return 'bad';
}
/** Map a partner status to ok (online/authorized) or muted otherwise. */
export function partnerOk(status: string): boolean {
return status === 'online' || status === 'authorized';
}
/** Pluralize a noun: (1, 'service') → "1 service". */
export function plural(n: number, singular: string): string {
return `${n} ${n === 1 ? singular : `${singular}s`}`;
}
/** The profile display name: title || name, else a fallback word. */
export function instanceDisplayName(
title: string | undefined,
name: string | undefined,
fallback = 'instance',
): string {
return title || name || fallback;
}
/**
* Version chips stay on one line: image digests can be 64-char hashes, so a
* long version is shortened for the card (the full value stays available as a
* `title` tooltip and on the service detail page).
*/
export function shortVersion(version: string): string {
return version.length > 16 ? `${version.slice(0, 12)}…` : version;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Path-based History-API router (§3.1). Routes live under the frozen SPA mount
* point `/hub/`. Every navigation writes `history.state = { hubDepth: n }` so
* the in-app back rule (§3.1.1) can tell whether a previous in-app entry
* exists below the current one.
*/
export type Route =
| { name: 'redirect-root' }
| { name: 'instances' }
| { name: 'redirect-local' }
| { name: 'services' }
| { name: 'service-detail'; serviceName: string }
| { name: 'trusted' }
| { name: 'settings' }
| { name: 'not-found' };
/** Strip a leading `/hub` so matching works in dev (no prefix) and prod. */
function strip(p: string): string {
if (p.startsWith('/hub')) {
p = p.slice(4) || '/';
}
return p;
}
export function matchPath(rawPath: string): Route {
const p = strip(rawPath);
if (p === '/' || p === '') return { name: 'redirect-root' };
if (p === '/instances') return { name: 'instances' };
if (p === '/instances/local') return { name: 'redirect-local' };
if (p === '/instances/local/services') return { name: 'services' };
if (p === '/instances/local/trusted') return { name: 'trusted' };
if (p === '/settings') return { name: 'settings' };
if (p.startsWith('/instances/local/services/')) {
const rest = p.slice('/instances/local/services/'.length);
if (rest && !rest.includes('/')) {
return { name: 'service-detail', serviceName: decodeURIComponent(rest) };
}
}
return { name: 'not-found' };
}
type Listener = () => void;
const listeners = new Set<Listener>();
export function subscribe(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
function emit(): void {
listeners.forEach(fn => fn());
}
window.addEventListener('popstate', emit);
function currentDepth(): number {
const s: unknown = history.state;
if (s && typeof s === 'object') {
const d = (s as { hubDepth?: unknown }).hubDepth;
if (typeof d === 'number') return d;
}
return 0;
}
/** pushState: a new in-app entry (depth + 1). Used by tab/forward links. */
export function push(target: string): void {
history.pushState({ hubDepth: currentDepth() + 1 }, '', target);
emit();
}
/** replaceState: keep the current depth (redirects, defaults). */
export function replace(target: string): void {
history.replaceState({ hubDepth: currentDepth() }, '', target);
emit();
}
/**
* The one back rule (§3.1.1): if a previous in-app entry exists below this
* one, browser Back unwinds it; otherwise replace to the target so the
* chevron never exits the app and Back leaves it as the user arrived.
*/
export function backOrReplace(target: string): void {
if (currentDepth() >= 1) {
history.back();
} else {
replace(target);
}
}
/** href (prefixed) for a canonical path. */
export function href(path: string): string {
return `/hub${path === '/' ? '' : path}`;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/** Mirrors Maestro spec.yaml `InstanceIdentity` (hub-app M1 schema). */
export interface InstanceIdentity {
name: string;
title?: string;
description?: string;
fingerprint?: string;
}
/** Mirrors `PublicService` — no ids, IPs, or ports ever cross the wire. */
export interface PublicService {
service_name: string;
title?: string;
description?: string;
status: string;
version?: string;
capabilities?: string[];
has_auth?: boolean;
image?: string;
}
/** Mirrors `TrustedInstance`. Rev 9 retired federation, so the public profile's
* `trusted_instances` array is always empty; the type is kept for wire
* compatibility with older hub builds. */
export interface TrustedInstance {
name: string;
url: string;
fingerprint: string;
status: string;
scopes?: string[];
}
/** GET /api/instance — the one unauthenticated read surface (hub-app D2). */
export interface InstancePublicProfile {
instance: InstanceIdentity;
services?: PublicService[] | null;
trusted_instances?: TrustedInstance[] | null;
}
/**
* Connectivity health of the serving instance, shown by the WiFi icon.
* The rendered status vocabulary is exactly this set (§4.6).
*/
export type InstanceStatus = 'connected' | 'flaky' | 'disconnected';
/** A section of the profile can be absent (privacy off), null, empty or data. */
export type SectionPresence = 'absent' | 'null' | 'empty' | 'present';
export interface SectionState {
presence: SectionPresence;
/** Normalized value: [] when null/absent/empty, else the array. */
value: PublicService[] | TrustedInstance[];
}
/**
* Shell-level profile state. `profile` is the last successful decode;
* `status` is the hysteresis-buffered connectivity; `error` is the last
* failure message when a probe failed; `probing`/`lastProbedAt` drive the
* "Connecting…" neutral label and the in-flight guard.
*/
export interface DataState {
profile: InstancePublicProfile | null;
status: InstanceStatus;
probing: boolean;
error: string | null;
lastProbedAt: number | null;
/** Whether the last probe failed (drives the separate failure sentence). */
lastProbeFailed: boolean;
offline: boolean;
/** Last detected section presence (drives private/empty states). */
sections: { services: SectionState; trusted: SectionState } | null;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { render } from 'preact'
import './styles.css'
import { App } from './app.tsx'
render(<App />, document.getElementById('root')!)
+28
View File
@@ -0,0 +1,28 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ServicesScreen } from './services';
/**
* Instance home (§4.2). The bare `/hub/instances/local` route carries no tab
* in the URL, so the app redirects it (replace, never a history entry) to the
* Services tab — native's initialRouteName. This screen renders that tab so
* the redirect target is never blank during the transition.
*/
export function InstanceHomeScreen() {
return <ServicesScreen />;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { useData, linkClick } from '../components/shell';
import { Banner } from '../components/ui';
import { InstanceStatusBadge } from '../components/status';
import { href } from '../lib/router';
import { plural, instanceDisplayName } from '../lib/format';
/**
* Instances home (§4.1): one card for the same-origin instance, plus the
* static "Other instances can't be added here" peers panel. The card is a
* real <a> to /hub/instances/local.
*/
export function InstancesScreen() {
const { profile, status, error, offline, probing, lastProbedAt, sections, refresh } = useData();
const name = profile ? instanceDisplayName(profile.instance.title, profile.instance.name) : 'this instance';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const connecting = probing && lastProbedAt === null;
// Count only present, non-null sections (§4.0): an absent key (privacy off)
// or a null value (store absent) is not counted; a present [] counts as 0.
const servicesCount =
sections && (sections.services.presence === 'present' || sections.services.presence === 'empty')
? sections.services.value.length
: null;
const partnersCount =
sections && (sections.trusted.presence === 'present' || sections.trusted.presence === 'empty')
? sections.trusted.value.length
: null;
const summary = [
servicesCount !== null ? plural(servicesCount, 'service') : null,
partnersCount !== null ? plural(partnersCount, 'partner') : null,
].filter(Boolean);
return (
<div class="stack">
<h1 class="page-h1" tabindex={-1}>
Hub
</h1>
{offline ? (
<Banner message="You're offline." offline />
) : error ? (
<Banner message={error} onRetry={refresh} />
) : null}
<a
class="card card-link instance-card"
href={href('/instances/local')}
onClick={linkClick(href('/instances/local'))}
>
<span class="instance-card-icon" aria-hidden="true">
<ServerIcon />
</span>
<span class="instance-card-body">
<span class="instance-card-title">{name}</span>
<div class="instance-card-url mono">{origin}</div>
{summary.length > 0 ? <div class="instance-card-summary">{summary.join(' · ')}</div> : null}
</span>
<span class="instance-card-status">
{connecting ? <span class="tiny muted">Connecting…</span> : <InstanceStatusBadge status={status} />}
</span>
</a>
<div class="panel">
<div class="h2">Other instances can't be added here</div>
<p class="mt-2 small muted">
This hub is served by the instance it describes, and a browser page
may only read that one instance. To browse other instances, use the
Hub app on a phone, or open the other instance's own hub address
directly.
</p>
</div>
</div>
);
}
function ServerIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<rect x="2" y="2" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" />
<rect x="2" y="14" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" />
<path d="M6 6h.01M6 18h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { JSX } from 'preact';
import { href, push } from '../lib/router';
/** Not found (§4.7): a static route with no data dependency. */
export function NotFoundScreen() {
const target = href('/instances');
return (
<div class="empty">
<div class="empty-icon" aria-hidden="true">
<MissingIcon />
</div>
<h1 class="empty-title" tabindex={-1}>
Page not found
</h1>
<div class="empty-subtitle">There's no page at this address.</div>
<a
class="btn btn-primary mt-4"
href={target}
onClick={(e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
push(target);
}}
>
Go to Hub
</a>
</div>
);
}
function MissingIcon() {
return (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none">
<path
d="m6 9 6 6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { JSX } from 'preact';
import { BackChevron, TabStrip, useData } from '../components/shell';
import { Banner, EmptyState, LoadingView, Card } from '../components/ui';
import { StatusPill } from '../components/status';
import { instanceDisplayName } from '../lib/format';
import { href, push } from '../lib/router';
import type { PublicService } from '../lib/types';
/**
* Service detail (§4.4): read-only all public fields. Rows use a <dl>; the
* status pill is soft fill + status dot + ink word. Not-found when the probe
* succeeded but no such service_name exists. An xl-only <aside> context pane
* (real landmarks, rendered after the rows in DOM) holds the "all services"
* nav — the only copy of those links.
*/
export function ServiceDetailScreen({ serviceName }: { serviceName: string }) {
const { profile, error, offline, probing, lastProbedAt } = useData();
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'instance';
const loading = probing && lastProbedAt === null;
const services: PublicService[] = profile?.services ?? [];
const service = services.find(s => s.service_name === serviceName);
const title = service?.title || serviceName;
const asideId = 'detail-context-pane';
const origin = typeof window !== 'undefined' ? window.location.origin : '';
let body: JSX.Element;
if (loading && !profile) {
body = <LoadingView label="Loading service…" />;
} else if (offline && !profile) {
body = (
<>
<Banner message="You're offline." offline />
<EmptyState title="Service not found" subtitle="It may have stopped or been removed from the instance." />
</>
);
} else if (error && !profile) {
body = (
<>
<Banner message={error} />
<EmptyState title="Service not found" subtitle="It may have stopped or been removed from the instance." />
</>
);
} else if (!service) {
body = (
<>
{error ? <Banner message={error} /> : null}
{offline ? <Banner message="You're offline." offline /> : null}
<EmptyState
title="Service not found"
subtitle="It may have stopped or been removed from the instance."
/>
<div class="mt-4">
<a class="btn" href={href('/instances/local/services')} onClick={goToServices}>
All services
</a>
</div>
</>
);
} else {
const isDb = service.capabilities?.includes('database') ?? false;
body = (
<div class="detail-layout">
<div class="detail-main">
{error ? <Banner message={error} /> : null}
{offline ? <Banner message="You're offline." offline /> : null}
<Card>
<div class="row">
<span class="service-card-icon" aria-hidden="true">
{isDb ? <DbIcon /> : <BoxIcon />}
</span>
<span class="service-card-title flex-1">{title}</span>
<StatusPill status={service.status} />
</div>
{service.description ? <p class="mt-2 small ink-soft">{service.description}</p> : null}
</Card>
<Card className="mt-2">
<dl class="detail-list">
<Row label="Service name" value={service.service_name} mono />
<Row label="Version" value={service.version} />
<Row label="Image" value={service.image} mono />
<Row label="Status" value={service.status} />
<Row label="Has auth" value={service.has_auth ? 'Yes' : 'No'} />
<Row label="Capabilities" value={service.capabilities?.join(', ') || 'None'} />
</dl>
</Card>
</div>
<aside class="context-pane" aria-labelledby={asideId}>
<div id={asideId} class="h2">
Services on this instance
</div>
<nav aria-label="All services on this instance" class="context-nav">
{services.map(s => (
<a
key={s.service_name}
href={href(`/instances/local/services/${encodeURIComponent(s.service_name)}`)}
onClick={goToServices}
aria-current={s.service_name === serviceName ? 'page' : undefined}
>
{s.title || s.service_name}
</a>
))}
</nav>
<dl class="detail-list context-identity">
<div class="detail-row">
<dt class="detail-label">Instance</dt>
<dd class="detail-value">{instanceName}</dd>
</div>
<div class="detail-row">
<dt class="detail-label">Origin</dt>
<dd class="detail-value mono">{origin}</dd>
</div>
</dl>
</aside>
</div>
);
}
return (
<div>
<div class="instance-header">
<BackChevron target={href('/instances/local/services')} label="Back to services" />
<h1 class="page-h1 flex-1" tabindex={-1}>
{title}
</h1>
</div>
<TabStrip active="services" />
{body}
</div>
);
}
function Row({
label,
value,
mono = false,
}: {
label: string;
value?: string;
mono?: boolean;
}) {
return (
<div class="detail-row">
<dt class="detail-label">{label}</dt>
<dd class={`detail-value ${mono ? 'mono' : ''}`}>{value || '—'}</dd>
</div>
);
}
function goToServices(e: JSX.TargetedMouseEvent<HTMLAnchorElement>) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
push(e.currentTarget.getAttribute('href')!);
}
function BoxIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path
d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M3.27 6.96 12 12.01l8.73-5.05" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 22.08V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
}
function DbIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" stroke-width="2" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" stroke="currentColor" stroke-width="2" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" stroke="currentColor" stroke-width="2" />
</svg>
);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { useState } from 'preact/hooks';
import type { JSX } from 'preact';
import { BackChevron, InfoButton, TabStrip, useData } from '../components/shell';
import { Banner, EmptyState, LoadingView } from '../components/ui';
import { DetailsDialog } from '../components/details-dialog';
import { ServiceCard } from '../components/service-card';
import { instanceDisplayName, plural } from '../lib/format';
import { href } from '../lib/router';
/**
* Services tab (§4.3). Section presence drives the state: absent → private
* note; null/empty → native empty copy; present → the service grid.
*/
export function ServicesScreen() {
const { profile, error, offline, probing, lastProbedAt, sections, refresh } = useData();
const [detailsOpen, setDetailsOpen] = useState(false);
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'instance';
const presence = sections?.services.presence ?? null;
const services = profile?.services ?? [];
const loading = probing && lastProbedAt === null;
const info = profile?.instance;
const header = (
<div class="instance-header">
<BackChevron target={href('/instances')} label="Back to instances" />
<h1 class="page-h1 flex-1" tabindex={-1}>
{instanceName}
</h1>
<InfoButton onClick={() => setDetailsOpen(true)} />
</div>
);
let body: JSX.Element;
if (loading && !profile) {
body = <LoadingView label="Loading services…" />;
} else if (offline && !profile) {
body = (
<>
<Banner message="You're offline." offline />
<EmptyState title="No running services to show" subtitle="Hidden infrastructure is never advertised; add a service on the instance to browse it here." />
</>
);
} else if (error && !profile) {
body = (
<>
<Banner message={error} onRetry={refresh} />
<EmptyState title="No running services to show" subtitle="Hidden infrastructure is never advertised; add a service on the instance to browse it here." />
</>
);
} else {
// Ready (or flaky with cached profile): banner on top if degraded.
const errorBanner =
error && profile ? <Banner message={error} /> : null;
const offlineBanner = offline && profile ? <Banner message="You're offline." offline /> : null;
if (presence === 'absent') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="Service list is private" subtitle="This instance publishes its identity but keeps its service list private." />
</>
);
} else if (presence === null || presence === 'null' || presence === 'empty') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="No running services to show" subtitle="Hidden infrastructure is never advertised; add a service on the instance to browse it here." />
</>
);
} else {
body = (
<>
{errorBanner}
{offlineBanner}
<div class="small-caps mb-2">{`${instanceName} — ${plural(services.length, 'service')}`}</div>
<div class="service-grid">
{services.map(s => (
<ServiceCard key={s.service_name} service={s} />
))}
</div>
</>
);
}
}
return (
<div class="shell-content">
{header}
<TabStrip active="services" />
{body}
<DetailsDialog
open={detailsOpen}
name={info ? instanceDisplayName(info.title, info.name) : instanceName}
fingerprint={info?.fingerprint}
onClose={() => setDetailsOpen(false)}
/>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BackChevron, useData, useTheme } from '../components/shell';
import { Card } from '../components/ui';
import { instanceDisplayName } from '../lib/format';
import { href } from '../lib/router';
/**
* Settings (§4.6): Connection (honest — origin is the instance), Appearance
* (Dark theme switch), About Hub (exact copy + runtime status line + separate
* failure sentence) and the AGPL footer. The status line renders only the
* runtime vocabulary connected|flaky|disconnected.
*/
export function SettingsScreen() {
const { theme, toggle } = useTheme();
const { profile, status, lastProbeFailed, offline } = useData();
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const name = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'this instance';
const failureSentence = offline
? "You're offline."
: lastProbeFailed
? 'Last check failed: the instance is not answering.'
: null;
return (
<div>
<div class="instance-header">
<BackChevron target={href('/instances')} label="Back" />
<h1 class="page-h1 flex-1" tabindex={-1}>
Settings
</h1>
</div>
<div class="settings-stack">
<Card>
<div class="settings-card-title">Connection</div>
<p class="small muted">
This hub is served by the instance it reads — there is nothing to
configure.
</p>
<div class="mono tiny muted mt-2">{origin}</div>
</Card>
<Card>
<div class="settings-card-title">Appearance</div>
<div class="setting-row">
<label class="setting-label" for="theme-switch">
Dark theme
</label>
<input
id="theme-switch"
class="switch"
type="checkbox"
role="switch"
checked={theme === 'dark'}
onChange={() => toggle()}
/>
</div>
</Card>
<Card>
<div class="settings-card-title">About Hub</div>
<p class="small muted">
Read-only window onto your Dcntrlized instances. The public profile
is unauthenticated by design — nothing here holds credentials.
</p>
<div class="status-line">
{name} · {status}
</div>
{failureSentence ? <div class="status-line">{failureSentence}</div> : null}
</Card>
<p class="settings-footer">AGPL-3.0 — Dcntrlized</p>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Copyright (C) 2026 Dcntrlized
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { useState } from 'preact/hooks';
import type { JSX } from 'preact';
import { BackChevron, InfoButton, TabStrip, useData } from '../components/shell';
import { Banner, EmptyState, LoadingView } from '../components/ui';
import { DetailsDialog } from '../components/details-dialog';
import { PartnerRow } from '../components/partner-row';
import { instanceDisplayName } from '../lib/format';
import { href } from '../lib/router';
/**
* Trusted partners tab (§4.5). Section presence drives the state: absent →
* private note; null/empty → "No trusted partners yet". On web the register is
* empty by design, so rows are the advertised `trusted_instances` only.
*/
export function TrustedScreen() {
const { profile, error, offline, probing, lastProbedAt, sections } = useData();
const [detailsOpen, setDetailsOpen] = useState(false);
const instanceName = profile
? instanceDisplayName(profile.instance.title, profile.instance.name)
: 'instance';
const presence = sections?.trusted.presence ?? null;
const partners = profile?.trusted_instances ?? [];
const loading = probing && lastProbedAt === null;
const info = profile?.instance;
const header = (
<div class="instance-header">
<BackChevron target={href('/instances')} label="Back to instances" />
<h1 class="page-h1 flex-1" tabindex={-1}>
{instanceName}
</h1>
<InfoButton onClick={() => setDetailsOpen(true)} />
</div>
);
let body: JSX.Element;
if (loading && !profile) {
body = <LoadingView label="Loading trusted partners…" />;
} else if (offline && !profile) {
body = (
<>
<Banner message="You're offline." offline />
<EmptyState title="No trusted partners yet" subtitle="These are instances the connected instance trusts — the list fills in automatically as this instance authorizes partners." />
</>
);
} else if (error && !profile) {
body = (
<>
<Banner message={error} />
<EmptyState title="No trusted partners yet" subtitle="These are instances the connected instance trusts — the list fills in automatically as this instance authorizes partners." />
</>
);
} else {
const errorBanner = error && profile ? <Banner message={error} /> : null;
const offlineBanner = offline && profile ? <Banner message="You're offline." offline /> : null;
if (presence === 'absent') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="Trusted list is private" subtitle="This instance publishes its identity but keeps its trusted list private." />
</>
);
} else if (presence === null || presence === 'null' || presence === 'empty') {
body = (
<>
{errorBanner}
{offlineBanner}
<EmptyState title="No trusted partners yet" subtitle="These are instances the connected instance trusts — the list fills in automatically as this instance authorizes partners." />
</>
);
} else {
body = (
<>
{errorBanner}
{offlineBanner}
<div class="small-caps mb-2">Trusted partners</div>
<p class="small muted mb-2">
These are instances this instance trusts. Browsers can't open them
from here — see the note below.
</p>
<div class="partner-list">
{partners.map(p => (
<PartnerRow key={p.url || p.name} partner={p} />
))}
</div>
</>
);
}
}
return (
<div>
{header}
<TabStrip active="trusted" />
{body}
<DetailsDialog
open={detailsOpen}
name={info ? instanceDisplayName(info.title, info.name) : instanceName}
fingerprint={info?.fingerprint}
onClose={() => setDetailsOpen(false)}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"module": "esnext",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [preact()],
base: '/hub/',
build: {
outDir: 'dist',
emptyOutDir: true,
},
})
+101
View File
@@ -0,0 +1,101 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"embed"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
)
// embeddedHub carries the public hub SPA bundle. It is a SEPARATE bundle from
// the owner console (//go:embed all:frontend/dist): the hub is served at /hub/
// outside the owner gate and must never be merged into the operator app.
//
// The physical hub/dist/index.html placeholder is committed so a clean checkout
// still builds; the Dockerfile builds hub/ into hub/dist alongside the console
// (browser-edge contract §10.5).
//
//go:embed all:hub/dist
var embeddedHub embed.FS
// hubContentSecurityPolicy is the strict F13 policy from the browser-edge
// contract §10.5 (RC16). /hub/ shares Maestro's origin with the owner-gated
// /api/* routes and the owner cookies are Path=/, so a hub-bundle XSS could
// otherwise issue credentialed same-origin calls to the owner API.
// `script-src 'self'` (no unsafe-inline) blocks injected inline scripts and
// event handlers; `style-src 'self'` also blocks inline style attributes, so
// the bundle must use classes only; object-src/base-uri/frame-ancestors close
// the plugin/base/framing pivots.
const hubContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; connect-src 'self'; object-src 'none'; " +
"base-uri 'none'; frame-ancestors 'none'; form-action 'none'"
// newHubHandler returns the handler for the public hub SPA. The caller strips
// the "/hub/" prefix, so the wrapped filesystem is rooted at hub/dist.
func newHubHandler() (http.Handler, error) {
sub, err := fs.Sub(embeddedHub, "hub/dist")
if err != nil {
return nil, err
}
return hubHeaders(spaFallback(sub)), nil
}
// hubHeaders applies the mandatory F13 response headers before any hub asset
// (HTML or hashed asset) is served.
func hubHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", hubContentSecurityPolicy)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
// spaFallback serves the requested file when it exists, otherwise index.html,
// so every /hub/ deep link is reload-survivable and the SPA owns 404 rendering.
// index.html is written directly rather than delegated to http.FileServer,
// which would 301 any "/index.html" path to "./" and break deep links.
func spaFallback(fsys fs.FS) http.Handler {
files := http.FileServer(http.FS(fsys))
index, indexErr := fs.ReadFile(fsys, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" {
name = "index.html"
}
if name != "index.html" && fs.ValidPath(name) {
if f, err := fsys.Open(name); err == nil {
info, statErr := f.Stat()
_ = f.Close()
if statErr == nil && !info.IsDir() {
files.ServeHTTP(w, r)
return
}
}
}
if indexErr != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(index)))
_, _ = w.Write(index)
})
}