[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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist/assets/
|
||||
.vite/
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
Vendored
+27
@@ -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 |
Vendored
+32
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')!)
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user