From 490bb6fc365941ca5d32700d03ade24ce6bc1d39 Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Thu, 10 Sep 2026 20:41:25 -0400 Subject: [PATCH] =?UTF-8?q?A=20drop-in=20plugin=20for=20an=20existing=20Co?= =?UTF-8?q?ckpit=20install=20=E2=80=94=20twenty-six=20domain=20modules=20b?= =?UTF-8?q?ehind=20one=20dashboard.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BLOG.md | 2755 ++++++++++++++ LICENSE | 51 + Makefile | 423 +++ QA.md | 1074 ++++++ QUICKSTART.md | 161 + README.md | 439 +++ THIRD_PARTY.md | 311 ++ bridge/__init__.py | 118 + bridge/auth.py | 260 ++ bridge/benchmark.py | 169 + bridge/builder.py | 2071 +++++++++++ bridge/containers.py | 76 + bridge/db.py | 390 ++ bridge/fester.py | 287 ++ bridge/firewall.py | 2710 ++++++++++++++ bridge/firmware.py | 90 + bridge/fleet.py | 115 + bridge/glances.py | 303 ++ bridge/grafana.py | 469 +++ bridge/hwalert.py | 628 ++++ bridge/integrity.py | 63 + bridge/jellyfin.py | 311 ++ bridge/kata.py | 648 ++++ bridge/mesh.py | 65 + bridge/mining.py | 396 ++ bridge/modules/__init__.py | 8 + bridge/modules3p.py | 655 ++++ bridge/netsec.py | 443 +++ bridge/packages.py | 466 +++ bridge/photos.py | 346 ++ bridge/policy.py | 1512 ++++++++ bridge/prometheus.py | 526 +++ bridge/remotefs.py | 414 +++ bridge/sensors.py | 112 + bridge/themes.py | 486 +++ bridge/vault.py | 63 + cockpit-smoke-test.sh | 96 + compat/compat-manifest.json | 421 +++ docs/INSTALL.md | 190 + docs/SECURITY-HARDENING.md | 333 ++ firewall/policies/cilium-default.yaml | 78 + firewall/templates/ai-llm.sh | 414 +++ firewall/templates/cilium.sh | 344 ++ firewall/templates/no-services.sh | 1013 ++++++ firewall/templates/public-webserver.sh | 420 +++ firewall/templates/remote-admin.sh | 364 ++ firewall/templates/sysdeck-fw.sh | 479 +++ firewall/templates/vps-webserver.sh | 1016 ++++++ packaging/PKGBUILD | 77 + packaging/debian/changelog | 2638 ++++++++++++++ packaging/debian/control | 27 + packaging/debian/copyright | 1 + packaging/debian/postinst | 12 + packaging/debian/postrm | 11 + packaging/debian/rules | 15 + packaging/debian/source/format | 1 + packaging/polkit/org.sysdeck.modules3p.policy | 49 + packaging/polkit/org.sysdeck.policy | 453 +++ packaging/setup.py | 94 + packaging/sysdeck.metainfo.xml | 872 +++++ packaging/sysdeck.spec | 1502 ++++++++ plugins/sysdeck-auth/auth.js | 50 + plugins/sysdeck-auth/index.html | 69 + plugins/sysdeck-auth/manifest.json | 26 + plugins/sysdeck-benchmark/benchmark.js | 119 + plugins/sysdeck-benchmark/index.html | 69 + plugins/sysdeck-benchmark/manifest.json | 24 + plugins/sysdeck-builder/builder.js | 969 +++++ plugins/sysdeck-builder/index.html | 69 + plugins/sysdeck-builder/manifest.json | 28 + plugins/sysdeck-containers/containers.js | 146 + plugins/sysdeck-containers/index.html | 69 + plugins/sysdeck-containers/manifest.json | 30 + plugins/sysdeck-db/db.js | 324 ++ plugins/sysdeck-db/index.html | 64 + plugins/sysdeck-db/manifest.json | 37 + plugins/sysdeck-fester/fester.js | 692 ++++ plugins/sysdeck-fester/index.html | 69 + plugins/sysdeck-fester/manifest.json | 25 + plugins/sysdeck-firewall/firewall.js | 811 +++++ plugins/sysdeck-firewall/index.html | 69 + plugins/sysdeck-firewall/manifest.json | 50 + plugins/sysdeck-firmware/firmware.js | 52 + plugins/sysdeck-firmware/index.html | 69 + plugins/sysdeck-firmware/manifest.json | 25 + plugins/sysdeck-fleet/fleet.js | 103 + plugins/sysdeck-fleet/index.html | 69 + plugins/sysdeck-fleet/manifest.json | 25 + plugins/sysdeck-glances/glances.js | 388 ++ plugins/sysdeck-glances/index.html | 69 + plugins/sysdeck-glances/manifest.json | 31 + plugins/sysdeck-integrity/index.html | 69 + plugins/sysdeck-integrity/integrity.js | 100 + plugins/sysdeck-integrity/manifest.json | 25 + plugins/sysdeck-jellyfin/index.html | 64 + plugins/sysdeck-jellyfin/jellyfin.js | 254 ++ plugins/sysdeck-jellyfin/manifest.json | 30 + plugins/sysdeck-kata/index.html | 63 + plugins/sysdeck-kata/kata.js | 423 +++ plugins/sysdeck-kata/manifest.json | 36 + plugins/sysdeck-mesh/index.html | 69 + plugins/sysdeck-mesh/manifest.json | 25 + plugins/sysdeck-mesh/mesh.js | 41 + plugins/sysdeck-mining/index.html | 69 + plugins/sysdeck-mining/manifest.json | 25 + plugins/sysdeck-mining/mining.js | 477 +++ plugins/sysdeck-modules/index.html | 89 + plugins/sysdeck-modules/manifest.json | 39 + plugins/sysdeck-modules/modules.js | 334 ++ plugins/sysdeck-monitoring/index.html | 101 + plugins/sysdeck-monitoring/manifest.json | 35 + plugins/sysdeck-monitoring/monitoring.js | 344 ++ plugins/sysdeck-netsec/index.html | 69 + plugins/sysdeck-netsec/manifest.json | 27 + plugins/sysdeck-netsec/netsec.js | 262 ++ plugins/sysdeck-packages/index.html | 69 + plugins/sysdeck-packages/manifest.json | 26 + plugins/sysdeck-packages/packages.js | 209 ++ plugins/sysdeck-photos/index.html | 64 + plugins/sysdeck-photos/manifest.json | 32 + plugins/sysdeck-photos/photos.js | 252 ++ plugins/sysdeck-policy/index.html | 69 + plugins/sysdeck-policy/manifest.json | 46 + plugins/sysdeck-policy/policy.js | 1127 ++++++ plugins/sysdeck-remotefs/index.html | 64 + plugins/sysdeck-remotefs/manifest.json | 34 + plugins/sysdeck-remotefs/remotefs.js | 296 ++ plugins/sysdeck-sensors/index.html | 69 + plugins/sysdeck-sensors/manifest.json | 25 + plugins/sysdeck-sensors/sensors.js | 136 + plugins/sysdeck-services/index.html | 69 + plugins/sysdeck-services/manifest.json | 47 + plugins/sysdeck-services/services.js | 439 +++ plugins/sysdeck-themes/index.html | 69 + plugins/sysdeck-themes/manifest.json | 24 + plugins/sysdeck-themes/themes.js | 427 +++ plugins/sysdeck-vault/index.html | 69 + plugins/sysdeck-vault/manifest.json | 25 + plugins/sysdeck-vault/vault.js | 48 + prometheus/sysdeck_alerts.yml | 268 ++ prometheus/sysdeck_grafana_dashboards.yml | 14 + prometheus/sysdeck_grafana_datasources.yml | 18 + prometheus/sysdeck_scrape.yml | 168 + scripts/generate-plugins.py | 615 ++++ shared/bridge.js | 845 +++++ shared/manifest.json | 4 + shared/sysdeck.css | 167 + .../cockpit-incus/manifest.json | 15 + .../cockpit-machines/manifest.json | 15 + .../cockpit-ostree/manifest.json | 15 + sysdeck-diagnose.sh | 271 ++ tests/__init__.py | 0 tests/check_bridge_subcommands.py | 192 + tests/check_manifest_consistency.py | 327 ++ tests/check_metainfo_consistency.py | 97 + tests/test_bridge_parsers.py | 3226 +++++++++++++++++ tests/test_modules3p.py | 88 + web/.env | 1 + web/Caddyfile | 23 + web/README.md | 31 + web/bun.lock | 2012 ++++++++++ web/components.json | 21 + web/eslint.config.mjs | 50 + web/mini-services/.gitkeep | 0 web/mini-services/fester/LICENSE | 51 + web/mini-services/fester/README.md | 49 + web/mini-services/fester/api.ts | 283 ++ web/mini-services/fester/autopsy.ts | 168 + web/mini-services/fester/bun.lock | 57 + web/mini-services/fester/cause.ts | 86 + web/mini-services/fester/clock.ts | 56 + web/mini-services/fester/engine.ts | 543 +++ web/mini-services/fester/events.ts | 49 + web/mini-services/fester/index.ts | 211 ++ web/mini-services/fester/nodes.ts | 160 + web/mini-services/fester/package.json | 11 + web/mini-services/fester/scheduler.ts | 44 + web/mini-services/fester/seed.ts | 336 ++ web/mini-services/fester/store.ts | 348 ++ web/mini-services/fester/targets.ts | 68 + web/next.config.ts | 12 + web/package.json | 97 + web/postcss.config.mjs | 5 + web/prisma/schema.prisma | 332 ++ web/public/logo.svg | 29 + web/public/robots.txt | 14 + web/scripts/make-master-tarball.sh | 123 + web/src/app/api/bridge/route.ts | 80 + web/src/app/api/fester/route.ts | 79 + web/src/app/api/release/route.ts | 64 + web/src/app/api/route.ts | 5 + web/src/app/globals.css | 394 ++ web/src/app/layout.tsx | 48 + web/src/app/page.tsx | 356 ++ web/src/components/sysdeck/panels-map.tsx | 38 + web/src/components/sysdeck/panels/_stub.tsx | 26 + .../components/sysdeck/panels/authPanel.tsx | 381 ++ .../sysdeck/panels/benchmarkPanel.tsx | 282 ++ .../sysdeck/panels/builderPanel.tsx | 766 ++++ .../sysdeck/panels/containersPanel.tsx | 499 +++ web/src/components/sysdeck/panels/dbPanel.tsx | 311 ++ .../components/sysdeck/panels/festerPanel.tsx | 2898 +++++++++++++++ .../sysdeck/panels/firewallPanel.tsx | 815 +++++ .../sysdeck/panels/firmwarePanel.tsx | 306 ++ .../components/sysdeck/panels/fleetPanel.tsx | 378 ++ .../sysdeck/panels/glancesPanel.tsx | 524 +++ .../sysdeck/panels/hwalertPanel.tsx | 486 +++ .../sysdeck/panels/integrityPanel.tsx | 364 ++ .../sysdeck/panels/jellyfinPanel.tsx | 348 ++ .../components/sysdeck/panels/kataPanel.tsx | 185 + .../components/sysdeck/panels/meshPanel.tsx | 475 +++ .../components/sysdeck/panels/miningPanel.tsx | 259 ++ .../sysdeck/panels/modulesPanel.tsx | 322 ++ .../sysdeck/panels/monitoringPanel.tsx | 321 ++ .../components/sysdeck/panels/netsecPanel.tsx | 608 ++++ .../sysdeck/panels/overviewPanel.tsx | 528 +++ .../sysdeck/panels/packagesPanel.tsx | 431 +++ .../components/sysdeck/panels/photosPanel.tsx | 231 ++ .../components/sysdeck/panels/policyPanel.tsx | 554 +++ .../sysdeck/panels/remotefsPanel.tsx | 219 ++ .../sysdeck/panels/sensorsPanel.tsx | 271 ++ .../sysdeck/panels/servicesPanel.tsx | 323 ++ .../components/sysdeck/panels/themesPanel.tsx | 145 + .../components/sysdeck/panels/vaultPanel.tsx | 365 ++ web/src/components/sysdeck/query-provider.tsx | 19 + web/src/components/sysdeck/ui.tsx | 258 ++ web/src/components/ui/accordion.tsx | 66 + web/src/components/ui/alert-dialog.tsx | 157 + web/src/components/ui/alert.tsx | 66 + web/src/components/ui/aspect-ratio.tsx | 11 + web/src/components/ui/avatar.tsx | 53 + web/src/components/ui/badge.tsx | 46 + web/src/components/ui/breadcrumb.tsx | 109 + web/src/components/ui/button.tsx | 59 + web/src/components/ui/calendar.tsx | 213 ++ web/src/components/ui/card.tsx | 92 + web/src/components/ui/carousel.tsx | 241 ++ web/src/components/ui/chart.tsx | 353 ++ web/src/components/ui/checkbox.tsx | 32 + web/src/components/ui/collapsible.tsx | 33 + web/src/components/ui/command.tsx | 184 + web/src/components/ui/context-menu.tsx | 252 ++ web/src/components/ui/dialog.tsx | 143 + web/src/components/ui/drawer.tsx | 135 + web/src/components/ui/dropdown-menu.tsx | 257 ++ web/src/components/ui/form.tsx | 167 + web/src/components/ui/hover-card.tsx | 44 + web/src/components/ui/input-otp.tsx | 77 + web/src/components/ui/input.tsx | 21 + web/src/components/ui/label.tsx | 24 + web/src/components/ui/menubar.tsx | 276 ++ web/src/components/ui/navigation-menu.tsx | 168 + web/src/components/ui/pagination.tsx | 127 + web/src/components/ui/popover.tsx | 48 + web/src/components/ui/progress.tsx | 31 + web/src/components/ui/radio-group.tsx | 45 + web/src/components/ui/resizable.tsx | 56 + web/src/components/ui/scroll-area.tsx | 58 + web/src/components/ui/select.tsx | 185 + web/src/components/ui/separator.tsx | 28 + web/src/components/ui/sheet.tsx | 139 + web/src/components/ui/sidebar.tsx | 726 ++++ web/src/components/ui/skeleton.tsx | 13 + web/src/components/ui/slider.tsx | 63 + web/src/components/ui/sonner.tsx | 25 + web/src/components/ui/switch.tsx | 31 + web/src/components/ui/table.tsx | 116 + web/src/components/ui/tabs.tsx | 66 + web/src/components/ui/textarea.tsx | 18 + web/src/components/ui/toast.tsx | 129 + web/src/components/ui/toaster.tsx | 35 + web/src/components/ui/toggle-group.tsx | 73 + web/src/components/ui/toggle.tsx | 47 + web/src/components/ui/tooltip.tsx | 61 + web/src/hooks/use-mobile.ts | 19 + web/src/hooks/use-toast.ts | 194 + web/src/lib/db.ts | 13 + web/src/lib/sysdeck/bridge/auth.ts | 217 ++ web/src/lib/sysdeck/bridge/benchmark.ts | 217 ++ web/src/lib/sysdeck/bridge/builder.ts | 377 ++ web/src/lib/sysdeck/bridge/containers.ts | 196 + web/src/lib/sysdeck/bridge/db.ts | 229 ++ web/src/lib/sysdeck/bridge/fester.ts | 118 + web/src/lib/sysdeck/bridge/firewall.ts | 458 +++ web/src/lib/sysdeck/bridge/firmware.ts | 263 ++ web/src/lib/sysdeck/bridge/fleet.ts | 259 ++ web/src/lib/sysdeck/bridge/glances.ts | 415 +++ web/src/lib/sysdeck/bridge/hwalert.ts | 343 ++ web/src/lib/sysdeck/bridge/index.ts | 63 + web/src/lib/sysdeck/bridge/integrity.ts | 241 ++ web/src/lib/sysdeck/bridge/jellyfin.ts | 229 ++ web/src/lib/sysdeck/bridge/kata.ts | 140 + web/src/lib/sysdeck/bridge/mesh.ts | 353 ++ web/src/lib/sysdeck/bridge/mining.ts | 216 ++ web/src/lib/sysdeck/bridge/modules.ts | 299 ++ web/src/lib/sysdeck/bridge/monitoring.ts | 221 ++ web/src/lib/sysdeck/bridge/netsec.ts | 317 ++ web/src/lib/sysdeck/bridge/overview.ts | 161 + web/src/lib/sysdeck/bridge/packages.ts | 188 + web/src/lib/sysdeck/bridge/photos.ts | 159 + web/src/lib/sysdeck/bridge/policy.ts | 160 + web/src/lib/sysdeck/bridge/remotefs.ts | 181 + web/src/lib/sysdeck/bridge/sensors.ts | 207 ++ web/src/lib/sysdeck/bridge/services.ts | 243 ++ web/src/lib/sysdeck/bridge/shared.ts | 79 + web/src/lib/sysdeck/bridge/themes.ts | 41 + web/src/lib/sysdeck/bridge/vault.ts | 245 ++ web/src/lib/sysdeck/client.ts | 76 + web/src/lib/sysdeck/registry.ts | 71 + web/src/lib/sysdeck/types.ts | 58 + web/src/lib/utils.ts | 6 + web/tailwind.config.ts | 64 + web/tsconfig.json | 42 + worklog.md | 1760 +++++++++ 314 files changed, 82893 insertions(+) create mode 100755 BLOG.md create mode 100755 LICENSE create mode 100755 Makefile create mode 100755 QA.md create mode 100755 QUICKSTART.md create mode 100755 README.md create mode 100755 THIRD_PARTY.md create mode 100755 bridge/__init__.py create mode 100755 bridge/auth.py create mode 100755 bridge/benchmark.py create mode 100755 bridge/builder.py create mode 100755 bridge/containers.py create mode 100755 bridge/db.py create mode 100755 bridge/fester.py create mode 100755 bridge/firewall.py create mode 100755 bridge/firmware.py create mode 100755 bridge/fleet.py create mode 100755 bridge/glances.py create mode 100755 bridge/grafana.py create mode 100755 bridge/hwalert.py create mode 100755 bridge/integrity.py create mode 100755 bridge/jellyfin.py create mode 100755 bridge/kata.py create mode 100755 bridge/mesh.py create mode 100755 bridge/mining.py create mode 100755 bridge/modules/__init__.py create mode 100755 bridge/modules3p.py create mode 100755 bridge/netsec.py create mode 100755 bridge/packages.py create mode 100755 bridge/photos.py create mode 100755 bridge/policy.py create mode 100755 bridge/prometheus.py create mode 100755 bridge/remotefs.py create mode 100755 bridge/sensors.py create mode 100755 bridge/themes.py create mode 100755 bridge/vault.py create mode 100755 cockpit-smoke-test.sh create mode 100755 compat/compat-manifest.json create mode 100755 docs/INSTALL.md create mode 100755 docs/SECURITY-HARDENING.md create mode 100755 firewall/policies/cilium-default.yaml create mode 100755 firewall/templates/ai-llm.sh create mode 100755 firewall/templates/cilium.sh create mode 100755 firewall/templates/no-services.sh create mode 100755 firewall/templates/public-webserver.sh create mode 100755 firewall/templates/remote-admin.sh create mode 100755 firewall/templates/sysdeck-fw.sh create mode 100755 firewall/templates/vps-webserver.sh create mode 100755 packaging/PKGBUILD create mode 100755 packaging/debian/changelog create mode 100755 packaging/debian/control create mode 100755 packaging/debian/copyright create mode 100755 packaging/debian/postinst create mode 100755 packaging/debian/postrm create mode 100755 packaging/debian/rules create mode 100755 packaging/debian/source/format create mode 100755 packaging/polkit/org.sysdeck.modules3p.policy create mode 100755 packaging/polkit/org.sysdeck.policy create mode 100755 packaging/setup.py create mode 100755 packaging/sysdeck.metainfo.xml create mode 100755 packaging/sysdeck.spec create mode 100755 plugins/sysdeck-auth/auth.js create mode 100755 plugins/sysdeck-auth/index.html create mode 100755 plugins/sysdeck-auth/manifest.json create mode 100755 plugins/sysdeck-benchmark/benchmark.js create mode 100755 plugins/sysdeck-benchmark/index.html create mode 100755 plugins/sysdeck-benchmark/manifest.json create mode 100755 plugins/sysdeck-builder/builder.js create mode 100755 plugins/sysdeck-builder/index.html create mode 100755 plugins/sysdeck-builder/manifest.json create mode 100755 plugins/sysdeck-containers/containers.js create mode 100755 plugins/sysdeck-containers/index.html create mode 100755 plugins/sysdeck-containers/manifest.json create mode 100755 plugins/sysdeck-db/db.js create mode 100755 plugins/sysdeck-db/index.html create mode 100755 plugins/sysdeck-db/manifest.json create mode 100755 plugins/sysdeck-fester/fester.js create mode 100755 plugins/sysdeck-fester/index.html create mode 100755 plugins/sysdeck-fester/manifest.json create mode 100755 plugins/sysdeck-firewall/firewall.js create mode 100755 plugins/sysdeck-firewall/index.html create mode 100755 plugins/sysdeck-firewall/manifest.json create mode 100755 plugins/sysdeck-firmware/firmware.js create mode 100755 plugins/sysdeck-firmware/index.html create mode 100755 plugins/sysdeck-firmware/manifest.json create mode 100755 plugins/sysdeck-fleet/fleet.js create mode 100755 plugins/sysdeck-fleet/index.html create mode 100755 plugins/sysdeck-fleet/manifest.json create mode 100755 plugins/sysdeck-glances/glances.js create mode 100755 plugins/sysdeck-glances/index.html create mode 100755 plugins/sysdeck-glances/manifest.json create mode 100755 plugins/sysdeck-integrity/index.html create mode 100755 plugins/sysdeck-integrity/integrity.js create mode 100755 plugins/sysdeck-integrity/manifest.json create mode 100755 plugins/sysdeck-jellyfin/index.html create mode 100755 plugins/sysdeck-jellyfin/jellyfin.js create mode 100755 plugins/sysdeck-jellyfin/manifest.json create mode 100755 plugins/sysdeck-kata/index.html create mode 100755 plugins/sysdeck-kata/kata.js create mode 100755 plugins/sysdeck-kata/manifest.json create mode 100755 plugins/sysdeck-mesh/index.html create mode 100755 plugins/sysdeck-mesh/manifest.json create mode 100755 plugins/sysdeck-mesh/mesh.js create mode 100755 plugins/sysdeck-mining/index.html create mode 100755 plugins/sysdeck-mining/manifest.json create mode 100755 plugins/sysdeck-mining/mining.js create mode 100755 plugins/sysdeck-modules/index.html create mode 100755 plugins/sysdeck-modules/manifest.json create mode 100755 plugins/sysdeck-modules/modules.js create mode 100755 plugins/sysdeck-monitoring/index.html create mode 100755 plugins/sysdeck-monitoring/manifest.json create mode 100755 plugins/sysdeck-monitoring/monitoring.js create mode 100755 plugins/sysdeck-netsec/index.html create mode 100755 plugins/sysdeck-netsec/manifest.json create mode 100755 plugins/sysdeck-netsec/netsec.js create mode 100755 plugins/sysdeck-packages/index.html create mode 100755 plugins/sysdeck-packages/manifest.json create mode 100755 plugins/sysdeck-packages/packages.js create mode 100755 plugins/sysdeck-photos/index.html create mode 100755 plugins/sysdeck-photos/manifest.json create mode 100755 plugins/sysdeck-photos/photos.js create mode 100755 plugins/sysdeck-policy/index.html create mode 100755 plugins/sysdeck-policy/manifest.json create mode 100755 plugins/sysdeck-policy/policy.js create mode 100755 plugins/sysdeck-remotefs/index.html create mode 100755 plugins/sysdeck-remotefs/manifest.json create mode 100755 plugins/sysdeck-remotefs/remotefs.js create mode 100755 plugins/sysdeck-sensors/index.html create mode 100755 plugins/sysdeck-sensors/manifest.json create mode 100755 plugins/sysdeck-sensors/sensors.js create mode 100755 plugins/sysdeck-services/index.html create mode 100755 plugins/sysdeck-services/manifest.json create mode 100755 plugins/sysdeck-services/services.js create mode 100755 plugins/sysdeck-themes/index.html create mode 100755 plugins/sysdeck-themes/manifest.json create mode 100755 plugins/sysdeck-themes/themes.js create mode 100755 plugins/sysdeck-vault/index.html create mode 100755 plugins/sysdeck-vault/manifest.json create mode 100755 plugins/sysdeck-vault/vault.js create mode 100755 prometheus/sysdeck_alerts.yml create mode 100755 prometheus/sysdeck_grafana_dashboards.yml create mode 100755 prometheus/sysdeck_grafana_datasources.yml create mode 100755 prometheus/sysdeck_scrape.yml create mode 100755 scripts/generate-plugins.py create mode 100755 shared/bridge.js create mode 100755 shared/manifest.json create mode 100755 shared/sysdeck.css create mode 100755 standalone-plugins/cockpit-incus/manifest.json create mode 100755 standalone-plugins/cockpit-machines/manifest.json create mode 100755 standalone-plugins/cockpit-ostree/manifest.json create mode 100755 sysdeck-diagnose.sh create mode 100755 tests/__init__.py create mode 100755 tests/check_bridge_subcommands.py create mode 100755 tests/check_manifest_consistency.py create mode 100755 tests/check_metainfo_consistency.py create mode 100755 tests/test_bridge_parsers.py create mode 100755 tests/test_modules3p.py create mode 100644 web/.env create mode 100644 web/Caddyfile create mode 100644 web/README.md create mode 100644 web/bun.lock create mode 100644 web/components.json create mode 100644 web/eslint.config.mjs create mode 100644 web/mini-services/.gitkeep create mode 100755 web/mini-services/fester/LICENSE create mode 100644 web/mini-services/fester/README.md create mode 100644 web/mini-services/fester/api.ts create mode 100644 web/mini-services/fester/autopsy.ts create mode 100644 web/mini-services/fester/bun.lock create mode 100644 web/mini-services/fester/cause.ts create mode 100644 web/mini-services/fester/clock.ts create mode 100644 web/mini-services/fester/engine.ts create mode 100644 web/mini-services/fester/events.ts create mode 100644 web/mini-services/fester/index.ts create mode 100644 web/mini-services/fester/nodes.ts create mode 100644 web/mini-services/fester/package.json create mode 100644 web/mini-services/fester/scheduler.ts create mode 100644 web/mini-services/fester/seed.ts create mode 100644 web/mini-services/fester/store.ts create mode 100644 web/mini-services/fester/targets.ts create mode 100644 web/next.config.ts create mode 100644 web/package.json create mode 100644 web/postcss.config.mjs create mode 100644 web/prisma/schema.prisma create mode 100644 web/public/logo.svg create mode 100644 web/public/robots.txt create mode 100755 web/scripts/make-master-tarball.sh create mode 100644 web/src/app/api/bridge/route.ts create mode 100644 web/src/app/api/fester/route.ts create mode 100644 web/src/app/api/release/route.ts create mode 100644 web/src/app/api/route.ts create mode 100644 web/src/app/globals.css create mode 100644 web/src/app/layout.tsx create mode 100644 web/src/app/page.tsx create mode 100644 web/src/components/sysdeck/panels-map.tsx create mode 100644 web/src/components/sysdeck/panels/_stub.tsx create mode 100644 web/src/components/sysdeck/panels/authPanel.tsx create mode 100644 web/src/components/sysdeck/panels/benchmarkPanel.tsx create mode 100644 web/src/components/sysdeck/panels/builderPanel.tsx create mode 100644 web/src/components/sysdeck/panels/containersPanel.tsx create mode 100644 web/src/components/sysdeck/panels/dbPanel.tsx create mode 100644 web/src/components/sysdeck/panels/festerPanel.tsx create mode 100644 web/src/components/sysdeck/panels/firewallPanel.tsx create mode 100644 web/src/components/sysdeck/panels/firmwarePanel.tsx create mode 100644 web/src/components/sysdeck/panels/fleetPanel.tsx create mode 100644 web/src/components/sysdeck/panels/glancesPanel.tsx create mode 100644 web/src/components/sysdeck/panels/hwalertPanel.tsx create mode 100644 web/src/components/sysdeck/panels/integrityPanel.tsx create mode 100644 web/src/components/sysdeck/panels/jellyfinPanel.tsx create mode 100644 web/src/components/sysdeck/panels/kataPanel.tsx create mode 100644 web/src/components/sysdeck/panels/meshPanel.tsx create mode 100644 web/src/components/sysdeck/panels/miningPanel.tsx create mode 100644 web/src/components/sysdeck/panels/modulesPanel.tsx create mode 100644 web/src/components/sysdeck/panels/monitoringPanel.tsx create mode 100644 web/src/components/sysdeck/panels/netsecPanel.tsx create mode 100644 web/src/components/sysdeck/panels/overviewPanel.tsx create mode 100644 web/src/components/sysdeck/panels/packagesPanel.tsx create mode 100644 web/src/components/sysdeck/panels/photosPanel.tsx create mode 100644 web/src/components/sysdeck/panels/policyPanel.tsx create mode 100644 web/src/components/sysdeck/panels/remotefsPanel.tsx create mode 100644 web/src/components/sysdeck/panels/sensorsPanel.tsx create mode 100644 web/src/components/sysdeck/panels/servicesPanel.tsx create mode 100644 web/src/components/sysdeck/panels/themesPanel.tsx create mode 100644 web/src/components/sysdeck/panels/vaultPanel.tsx create mode 100644 web/src/components/sysdeck/query-provider.tsx create mode 100644 web/src/components/sysdeck/ui.tsx create mode 100644 web/src/components/ui/accordion.tsx create mode 100644 web/src/components/ui/alert-dialog.tsx create mode 100644 web/src/components/ui/alert.tsx create mode 100644 web/src/components/ui/aspect-ratio.tsx create mode 100644 web/src/components/ui/avatar.tsx create mode 100644 web/src/components/ui/badge.tsx create mode 100644 web/src/components/ui/breadcrumb.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/calendar.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/carousel.tsx create mode 100644 web/src/components/ui/chart.tsx create mode 100644 web/src/components/ui/checkbox.tsx create mode 100644 web/src/components/ui/collapsible.tsx create mode 100644 web/src/components/ui/command.tsx create mode 100644 web/src/components/ui/context-menu.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/components/ui/dropdown-menu.tsx create mode 100644 web/src/components/ui/form.tsx create mode 100644 web/src/components/ui/hover-card.tsx create mode 100644 web/src/components/ui/input-otp.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/components/ui/menubar.tsx create mode 100644 web/src/components/ui/navigation-menu.tsx create mode 100644 web/src/components/ui/pagination.tsx create mode 100644 web/src/components/ui/popover.tsx create mode 100644 web/src/components/ui/progress.tsx create mode 100644 web/src/components/ui/radio-group.tsx create mode 100644 web/src/components/ui/resizable.tsx create mode 100644 web/src/components/ui/scroll-area.tsx create mode 100644 web/src/components/ui/select.tsx create mode 100644 web/src/components/ui/separator.tsx create mode 100644 web/src/components/ui/sheet.tsx create mode 100644 web/src/components/ui/sidebar.tsx create mode 100644 web/src/components/ui/skeleton.tsx create mode 100644 web/src/components/ui/slider.tsx create mode 100644 web/src/components/ui/sonner.tsx create mode 100644 web/src/components/ui/switch.tsx create mode 100644 web/src/components/ui/table.tsx create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/components/ui/textarea.tsx create mode 100644 web/src/components/ui/toast.tsx create mode 100644 web/src/components/ui/toaster.tsx create mode 100644 web/src/components/ui/toggle-group.tsx create mode 100644 web/src/components/ui/toggle.tsx create mode 100644 web/src/components/ui/tooltip.tsx create mode 100644 web/src/hooks/use-mobile.ts create mode 100644 web/src/hooks/use-toast.ts create mode 100644 web/src/lib/db.ts create mode 100644 web/src/lib/sysdeck/bridge/auth.ts create mode 100644 web/src/lib/sysdeck/bridge/benchmark.ts create mode 100644 web/src/lib/sysdeck/bridge/builder.ts create mode 100644 web/src/lib/sysdeck/bridge/containers.ts create mode 100644 web/src/lib/sysdeck/bridge/db.ts create mode 100644 web/src/lib/sysdeck/bridge/fester.ts create mode 100644 web/src/lib/sysdeck/bridge/firewall.ts create mode 100644 web/src/lib/sysdeck/bridge/firmware.ts create mode 100644 web/src/lib/sysdeck/bridge/fleet.ts create mode 100644 web/src/lib/sysdeck/bridge/glances.ts create mode 100644 web/src/lib/sysdeck/bridge/hwalert.ts create mode 100644 web/src/lib/sysdeck/bridge/index.ts create mode 100644 web/src/lib/sysdeck/bridge/integrity.ts create mode 100644 web/src/lib/sysdeck/bridge/jellyfin.ts create mode 100644 web/src/lib/sysdeck/bridge/kata.ts create mode 100644 web/src/lib/sysdeck/bridge/mesh.ts create mode 100644 web/src/lib/sysdeck/bridge/mining.ts create mode 100644 web/src/lib/sysdeck/bridge/modules.ts create mode 100644 web/src/lib/sysdeck/bridge/monitoring.ts create mode 100644 web/src/lib/sysdeck/bridge/netsec.ts create mode 100644 web/src/lib/sysdeck/bridge/overview.ts create mode 100644 web/src/lib/sysdeck/bridge/packages.ts create mode 100644 web/src/lib/sysdeck/bridge/photos.ts create mode 100644 web/src/lib/sysdeck/bridge/policy.ts create mode 100644 web/src/lib/sysdeck/bridge/remotefs.ts create mode 100644 web/src/lib/sysdeck/bridge/sensors.ts create mode 100644 web/src/lib/sysdeck/bridge/services.ts create mode 100644 web/src/lib/sysdeck/bridge/shared.ts create mode 100644 web/src/lib/sysdeck/bridge/themes.ts create mode 100644 web/src/lib/sysdeck/bridge/vault.ts create mode 100644 web/src/lib/sysdeck/client.ts create mode 100644 web/src/lib/sysdeck/registry.ts create mode 100644 web/src/lib/sysdeck/types.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/tailwind.config.ts create mode 100644 web/tsconfig.json create mode 100755 worklog.md diff --git a/BLOG.md b/BLOG.md new file mode 100755 index 0000000..9bad89f --- /dev/null +++ b/BLOG.md @@ -0,0 +1,2755 @@ +# SysDeck — Release Notes + +Author: **Jeremy Anderson** · · + +--- + +## v0.2.0 — 2026-08-20 (Master Edition: one tarball, two editions, Fester pre-integrated) + +v0.2.0 turns SysDeck into a single distributable that ships **both** editions with **Fester vendored and wired in**. The release answers the operator's framing directly: *"fester exists as a separate repository — it deserves its own. generate a master tarball of sysdeck with fester pre-integrated."* + +### What ships in sysdeck-0.2.0-master.tar.bz2 + +- `/` — the cockpit edition, unchanged upstream layout: 26 plugins, bridge/, shared/, packaging, tests, docs. +- `/web` — the NEW **SysDeck Web Edition**: a standalone Next.js 16 console (28 bridge modules) with real `/proc` + `/sys` collectors where the host allows, and clearly badged demo datasets where backends are absent. New panels: Overview landing view, Hardware Alerts (the orphaned bridge that never had a UI), and fully-built Mesh / Vault / Hardware Auth / Firmware. +- `/web/mini-services/fester` — **Fester, vendored + pre-integrated**. Fester stays an independent project with its own version line (0.2.1); the master tarball pins a snapshot so no separate checkout is needed. + +### Fester pre-integration (the cockpit side is real now) + +- `bridge/fester.py` — the v0.0.31 systemd-listing stub is gone. 11 real subcommands against the fester REST API (stdlib urllib, 4s timeout, `FESTER_URL` override, default `http://127.0.0.1:3010`): `status`, `metrics`, `builds`, `build `, `nodes`, `targets`, `timeline `, `sessions`, `start-build --project --targets [--no-cache] [--retries] [--fail-action]`, `cancel `, `replay `. Every subcommand degrades to actionable JSON when the service is down ("start it with `make fester-start`…"). +- `plugins/sysdeck-fester/fester.js` — a real panel: service status card, stat grid (builds total/running/succeeded/failed, cache-hit rate), cluster nodes table, live+history builds table with state chips and row actions (Cancel, Replay → inline session id, Timeline → expandable event log), a start-build form built from the target catalog, 5s auto-refresh that preserves form state. +- `shared/bridge.js` — the fester surface grew from 1 method to 11; `check-bridge-subcommands` now verifies **206 calls across 27 bridge modules** (was 195/26). + +### Also fixed in this release + +- **The shipped 0.1.3 Makefile was broken.** Its recipes were indented with 8 spaces instead of tabs — GNU make rejects the file outright (`missing separator (did you mean TAB instead of 8 spaces?)`), so `make install` / `make dist` could not run from the released tarball. v0.2.0 restores tab indentation (the fix the v0.0.28 guard itself recommended) and `make -n` passes for every target. +- **New Makefile targets**: `fester-start` (vendored fester service on :3010), `web-install` (bun + prisma setup for the web edition), `web-dev` (fester in the background + web console on :3000), `master` (rebuild the master tarball from the tree). +- Version surfaces bumped 0.1.3 → 0.2.0 across all release surfaces (Makefile, `bridge/__init__.py`, setup.py, PKGBUILD, spec, debian/changelog, compat-manifest, metainfo). + +### Verification + +- Cockpit tree guards: `python3 -m py_compile bridge/fester.py` clean; `node --check` clean on `fester.js` and `bridge.js`; `tests/check_bridge_subcommands.py` → 206/206 across 27 modules; `check-makefile-recipes` green (tabs restored); `make -n` parses for install / plugins / dist / master / web-dev / fester-start. +- Live integration smoke against the running fester service: `start-build --project gentoo-stage3 --targets mipsel` → build `gentoo-stage3-mtw730qo` (7/7 actions, 7 CAS cache hits, 1050 ms critical path); a second build listed `running` then `cancel` → `{"ok": true}` (final state cancelled, 11 timeline events); `replay` → session `sess-tl6ax5-uvo7vh`. +- Web edition end-to-end: all 28 modules clicked through with zero page errors and zero console errors; the Fester sub-app through the gateway shows live WS events, cluster nodes, and mid-flight builds; the theme engine re-tints the whole suite. +- Master tarball assembled by `scripts/make-master-tarball.sh`; verified by extraction, file count, and sha256. + +### Notes + +- Fester's version (0.2.1) is intentionally independent from SysDeck's (0.2.0) — it mirrors the separate-repo reality and lets the vendored snapshot track upstream releases without forcing a SysDeck release. +- The web edition's Prisma-backed modules (mesh, vault, containers, mining, …) are demo-badged where the host lacks the backend — the badge is honest, the data shapes are real. + +## v0.1.3 — 2026-08-19 (critical: import fixed with shutil.which + mkosi reads profile via temp symlink + download/manage UI) + +### Theme: the import was silently empty and the build still wasn't reading the config + +v0.1.3 fixes two more critical bugs that v0.1.2 missed, and adds the +download/manage UI the operator asked for. The operator's report: + +> *profile workstation still doesnt import current system pkgs. it +> trys to build only 2. which is repetitive at this point.* + +Two root causes. Both are embarrassingly simple. + +### Import bug: `from __init__ import` failed in the cockpit context + +`_detect_host_packages()` started with: + +```python +try: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from __init__ import DISTRO, PKG_MANAGER +except Exception: + DISTRO = "unknown" + PKG_MANAGER = "unknown" +``` + +The `except Exception: PKG_MANAGER = "unknown"` was the silent killer. +When the cockpit superuser channel runs the bridge helper, the Python +path context is different — `from __init__ import` fails, the except +clause silently sets `PKG_MANAGER = "unknown"`, and the function +returns `([], "unknown")`. No error, no warning, just an empty list. + +The import call in `profile_import_packages` then wrote nothing (the +empty-list early return), and the build used the profile's original +4 template packages — of which mkosi installed 2 (the base `iana-etc` +and `filesystem`). + +**Fix:** use `shutil.which()` to find the binary directly: + +```python +pacman_bin = shutil.which("pacman") +if pacman_bin: + cmd = [pacman_bin, "-Qqe"] + marker = "arch" +``` + +No import dependency. Works in any execution context. If `pacman` +isn't on PATH, it tries `apt-mark`, then `dnf`. If none are found, +returns `([], "unknown")` — but now the "unknown" is real, not a +silent import failure. + +### Build bug: `--include` doesn't replace the base config + +v0.1.2 added `--include ` to the mkosi command. I +thought `--include` told mkosi "load this config file." It doesn't. + +mkosi's `--include` flag includes a **drop-in fragment** ON TOP OF +the base `mkosi.conf`. The base config must still exist as +`mkosi.conf` in the cwd. If it doesn't, mkosi uses defaults and +the `--include` file is silently ignored. + +So for the operator's profile at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf`: +- mkosi runs in `/etc/mkosi/mkosi.conf.d/` +- looks for `mkosi.conf` there — doesn't find one +- uses empty defaults (2 base packages) +- the `--include arch-workstation.conf` file is layered on top of + nothing, effectively ignored + +**Fix:** create a temp directory, symlink the profile file as +`mkosi.conf` inside it, and run mkosi from there: + +```python +def _prepare_mkosi_work_dir(profile): + tmpdir = Path(tempfile.mkdtemp(prefix="sysdeck-mkosi-")) + link = tmpdir / "mkosi.conf" + link.symlink_to(Path(profile["path"]).resolve()) + return tmpdir +``` + +Then `build()` sets `work_dir = tmpdir`, so mkosi's cwd is the temp +dir. mkosi finds `mkosi.conf` (the symlink), follows it, reads the +actual profile file. Works for ANY profile path — v0.0.x drop-ins, +v0.1.0+ per-profile dirs, even profiles in random locations. + +The temp dir is cleaned up after the build finishes. + +### New: download artifacts directly from the panel + +Each artifact in the Artifacts panel now has a ⬇ Download button. + +The implementation uses `cockpit.spawn(["cat", path], { superuser: +"try", binary: true })` to read the file as a binary stream, collects +the chunks into a `Blob`, creates an object URL, and triggers a +browser download via a synthetic `` click. Works for +files of any size (streamed, not loaded into memory all at once by +the bridge — the JS side does collect chunks, but cockpit handles +the transport efficiently). + +### New: manage artifacts — delete per-file, clear per-profile + +Each artifact has a 🗑 button that calls `artifact-delete +`. The Python side resolves the path safely (refuses path +traversal outside `BUILDER_ARTIFACTS_DIR`) and `unlink()`s the file. + +Each profile's artifacts card has a 🗑 Clear all button that calls +`artifacts-clear `. Removes the entire +`/var/lib/sysdeck/builder/artifacts//` directory. Returns +file count + bytes freed for the log. + +The card header now shows total size: `myarch artifacts (3, 1.2 GB)`. + +### New: manage builds — delete state + log (+ optionally artifacts) + +Each build in the Builds table has a 🗑 button. Two-step confirm: + +1. "Delete build record?" — OK = delete state + log only +2. If Cancel: "Also delete ALL artifacts for profile?" — OK = delete + state + log + the profile's entire artifacts dir + +The Python `build-delete [--artifacts]` reads the state +file FIRST (to get the profile name for artifact cleanup) before +deleting it. Then deletes state + log. If `--artifacts`, also +`shutil.rmtree()` the artifacts dir. + +### Regression tests + +11 new unit tests across two new test classes: + +- `TestBuilderArtifactManagement` (7 tests): + - `test_artifact_delete_removes_file` + - `test_artifact_delete_refuses_path_traversal` + - `test_artifacts_clear_removes_all` + - `test_build_delete_removes_state_and_log` + - `test_build_delete_with_artifacts_flag_clears_artifacts_dir` + - `test_build_delete_nonexistent_returns_error` + - `test_new_subcommands_registered_in_commands` + +- `TestBuilderMkosiTempWorkDir` (3 tests): + - `test_prepare_creates_temp_dir_with_mkosi_conf_symlink` + - `test_prepare_returns_none_for_missing_profile_path` + - `test_prepare_returns_none_for_nonexistent_file` + +Existing `test_detect_host_packages_pacman` rewritten to mock +`shutil.which` instead of `__import__`. `test_build_success_path` +updated to no longer expect `--include` on the command line. + +Total: 254 tests (was 243 in v0.1.2; +11). All pass. + +### The mkosi command now + +``` +$ mkosi build --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force +# work_dir: /tmp/sysdeck-mkosi-abc123 +# backend: mkosi +# profile: myarch +# output_dir: /var/lib/sysdeck/builder/artifacts/myarch +``` + +`work_dir` is the temp dir containing `mkosi.conf` → symlink to the +real profile. mkosi reads the symlink, gets the real config. No +`--include` needed. + +--- + +## v0.1.2 — 2026-08-19 (critical: mkosi never read the profile — --include + auto-migrate) + +### Theme: the builder had zero package awareness because mkosi never saw the config + +v0.1.2 fixes the critical "zero packages" bug. The operator's report +was unambiguous: + +> *the builder absolutely does not work yet. it has zero awareness of +> packages we tell it to add.* + +Two compounding root causes were identified and both fixed. + +### Root cause 1: mkosi never read the profile config file + +`_backend_build_command()` for mkosi was: + +```python +cmd = ["mkosi", "build", "--output", name, "--output-dir", dir, "--force"] +``` + +No flag tells mkosi WHERE the profile config file is. mkosi's default +behavior: look for a file literally named `mkosi.conf` in the cwd. If +it doesn't find one, it uses EMPTY defaults — no distribution override, +no packages, no output settings, nothing. + +For the operator's profile at `/etc/mkosi/mkosi.conf.d/arch-workstation.conf`: +- `work_dir` = `/etc/mkosi/mkosi.conf.d/` (parent of the profile file) +- mkosi runs in that cwd +- mkosi looks for `mkosi.conf` in `/etc/mkosi/mkosi.conf.d/` +- The file is named `arch-workstation.conf`, NOT `mkosi.conf` +- mkosi finds no config → uses empty defaults +- Zero packages installed + +Even for v0.1.0+ profiles at `/etc/mkosi/profiles//mkosi.conf`, +the file IS named `mkosi.conf` so mkosi would find it — but only +because of the directory layout, not because sysdeck was explicit +about it. That's fragile. + +### Fix 1: `--include ` on every mkosi build + +```python +cmd = ["mkosi", "build"] +if ppath: + cmd += ["--include", ppath] +cmd += ["--output", output_name, "--output-dir", artifacts_dir, "--force"] +``` + +`--include` tells mkosi to explicitly load the profile config by path, +regardless of its filename or location. This is the fix for "zero +awareness of packages" — mkosi now ALWAYS sees the profile config, +whether it's at `/etc/mkosi/profiles/myarch/mkosi.conf` (v0.1.0 layout) +or `/etc/mkosi/mkosi.conf.d/arch-workstation.conf` (v0.0.x layout). + +### Root cause 2: legacy `Packages=` syntax silently parsed as garbage + +Even when mkosi DID read the profile file (e.g. v0.1.0+ profiles with +correct location), profiles created by v0.0.x used the old indented +`Packages=` syntax: + +```ini +[Packages] +Packages= + linux + linux-firmware + systemd + openssh +``` + +mkosi v22+ (Arch ships 25.x) only understands single-line: + +```ini +[Packages] +Packages=linux linux-firmware systemd openssh +``` + +The old indented form is silently parsed as a single package name with +embedded newlines (`"linux\nlinux-firmware\nsystemd\nopenssh"`), which +doesn't exist in any repo — so mkosi installs NOTHING. The build +"succeeds" but the image has zero of the operator's requested packages. + +### Fix 2: auto-migrate legacy `Packages=` syntax before every build + +New `_migrate_legacy_mkosi_packages(conf_path)` function: +1. Reads the profile file +2. Detects the old indented syntax via regex +3. Extracts package names from the indented block +4. Rewrites the `Packages=` line to single-line space-separated form +5. Writes the file back IN-PLACE + +`build()` calls this automatically on every mkosi build, BEFORE +constructing the command. The migration is logged in: +- **Build state JSON** (`warnings` array): `"packages_migrated: rewrote Packages= from old indented syntax to single-line (N packages: ...)"` +- **Log file header**: `# MIGRATED: rewrote Packages= from old indented syntax to single-line (N packages: ...)` + +If the file already uses modern syntax, the migration is a no-op +(returns `{"migrated": False, "reason": "already uses single-line syntax"}`). + +### Regression tests + +4 new unit tests in `TestBuilderBuildPath`: + +- `test_migrate_rewrites_old_indented_syntax` — verifies old + `Packages=\n linux\n vim\n` is rewritten to `Packages=linux vim` +- `test_migrate_noop_on_modern_syntax` — verifies already-modern files + are left unchanged +- `test_migrate_noop_on_no_packages_section` — verifies files without + `[Packages]` are left unchanged +- `test_migrate_runs_during_build` — end-to-end: `build()` with a + profile containing old syntax auto-migrates before mkosi runs, and + the migration is recorded in state + log + +The existing `test_build_success_path` was extended to verify +`--include` is on the command line and points at the profile file. + +Total: 243 tests (was 239 in v0.1.1; +4). All build-time guards pass. + +### The mkosi command line now + +``` +$ mkosi build --include /etc/mkosi/profiles/myarch/mkosi.conf --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force +# work_dir: /etc/mkosi/profiles/myarch +# backend: mkosi +# profile: myarch +# output_dir: /var/lib/sysdeck/builder/artifacts/myarch +``` + +Every flag sysdeck needs is on the CLI. Nothing depends on the +profile's `mkosi.conf` having the right settings — sysdeck forces +the config path, output name, output dir, and overwrite. + +--- + +## v0.1.1 — 2026-08-19 (output path safety fix: CLI flags force artifacts dir) + +### Theme: trusting mkosi.conf was the bug + +v0.1.1 is the "I should have done this in v0.1.0" release. The v0.1.0 +fix added `OutputDirectory=` to the scaffolded `mkosi.conf` template, +trusting mkosi to honor it. Two problems with that trust: + +1. **Old v0.0.x profiles have no `OutputDirectory=`.** The operator's + `arch-workstation` profile was created by v0.0.50 — it lives at + `/etc/mkosi/mkosi.conf.d/arch-workstation.conf` and has no output + directory setting. mkosi defaulted to writing `image.raw` into + the cwd (`/etc/mkosi/mkosi.conf.d/`), a system config directory + owned by root. +2. **mkosi then refused to overwrite the existing `image.raw`.** The + error message — "Output path /etc/mkosi/mkosi.conf.d/image.raw + exists already. (Use --force to rebuild.)" — blocked every rebuild + from the panel, which had no way to pass `--force`. + +The operator's response was unambiguous: + +> *this is NOT a safe output path. fix this now.* + +### The fix: don't trust the profile, force the CLI + +`_backend_build_command()` for mkosi was just: + +```python +cmd = ["mkosi", "build"] +``` + +It is now: + +```python +artifacts_dir = options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname) +output_name = options.get("output_name") or f"{pname}.raw" +cmd = [ + "mkosi", "build", + "--output", output_name, + "--output-dir", artifacts_dir, + "--force", +] +``` + +CLI flags override `mkosi.conf` (mkosi's documented precedence: CLI > +config file). So the output path is forced to +`/var/lib/sysdeck/builder/artifacts//.raw` regardless of +what the profile says — or doesn't say. `--force` overwrites any +existing image so rebuilds don't fail. + +### Belt-and-suspenders: refuse unsafe output paths + +Even with the CLI force, I added a safety check in `build()` that +refuses to proceed if the resolved `output_dir` is not under +`/var/lib/`, `/tmp/`, `/var/tmp/`, or the configured +`BUILDER_ARTIFACTS_DIR`. This blocks `/etc/`, `/usr/`, `/boot/`, +`/bin/`, `/sbin/`, `/lib/`, `/root/`, `/home/`, etc. — anywhere a +stray `image.raw` would corrupt the system or pollute a user's home. + +If an operator somehow passes `options.output_dir=/etc/something` via +the JS bridge, the build is refused before `subprocess.run` is called. +The error message: + +``` +refusing to build: output directory '/etc/mkosi/evil' is not under +/var/lib/, /tmp/, or /var/tmp/. Build outputs must go to +/var/lib/sysdeck/builder/artifacts// to avoid corrupting +system config directories. +``` + +### Legacy profile warning + +The operator's build was running against a v0.0.x profile in +`/etc/mkosi/mkosi.conf.d/`. v0.1.0 already fixed the scaffold location +for NEW profiles (they go in `/etc/mkosi/profiles//mkosi.conf`), +but the OLD profile is still there. v0.1.1 doesn't refuse to build it +(the output-path safety is handled), but it records a warning in both +the build state JSON and the log file: + +``` +# WARNING: profile is in /etc/mkosi/mkosi.conf.d/ (legacy v0.0.x layout). +# mkosi may silently ignore this drop-in fragment. +# Migrate to /etc/mkosi/profiles//mkosi.conf for a real profile. +``` + +The build state JSON gets a `warnings` array so the panel can surface +it in the UI too. + +### Log improvement + +The build log header now includes the resolved `output_dir` so the +operator can see exactly where the image will land before mkosi starts: + +``` +$ mkosi build --output arch-workstation.raw --output-dir /var/lib/sysdeck/builder/artifacts/arch-workstation --force +# work_dir: /etc/mkosi/mkosi.conf.d +# backend: mkosi +# profile: arch-workstation +# output_dir: /var/lib/sysdeck/builder/artifacts/arch-workstation +``` + +### Regression tests + +2 new unit tests in `TestBuilderBuildPath`: + +- `test_build_refuses_output_dir_under_etc` — verifies the safety check + rejects `output_dir=/etc/mkosi/evil`. +- `test_build_legacy_v050_profile_records_warning` — verifies building + a profile in `/etc/mkosi/mkosi.conf.d/` records the legacy warning + in state + log. + +The existing `test_build_success_path` was extended to verify the +mkosi command line includes `--output`, `--output-dir`, and `--force`, +and that `--output-dir` points at the per-profile artifacts dir. + +Total: 239 tests (was 237 in v0.1.0; +2). All build-time guards pass. + +### What the operator should do + +1. `sudo make uninstall && sudo make install && sudo systemctl restart cockpit.socket` +2. Delete the old image.raw that mkosi created in the wrong place: + `sudo rm /etc/mkosi/mkosi.conf.d/image.raw` +3. Migrate the old profile: `sudo mkdir -p /etc/mkosi/profiles/arch-workstation && sudo mv /etc/mkosi/mkosi.conf.d/arch-workstation.conf /etc/mkosi/profiles/arch-workstation/mkosi.conf` +4. Click Build on `arch-workstation` — output will land at + `/var/lib/sysdeck/builder/artifacts/arch-workstation/arch-workstation.raw` + +--- + +## v0.1.0 — 2026-08-19 (builder profile fixup + host pkg import) + +### Theme: the empty-image bug that turned out to be three bugs in a trench coat + +v0.1.0 is the "fix what v0.0.50 should have caught" release. The v0.0.50 +fix (adding the missing `import re` to `bridge/builder.py`) unblocked +the `build()` code path, and the operator immediately ran into the next +layer of problems. The build log went: + +``` +‣ Installing Arch Linux +Packages (2) iana-etc-20260530-1 filesystem-2025.10.12-1 +‣ Generating disk image +‣ /etc/mkosi/mkosi.conf.d/image.raw size is 33.0M, consumes 32.0M. +``` + +Then the operator wrote: *"nice try but i think our profile build didnt +work. i dont see a final tarball or image file to look at."* Two red +flags in that single last line: +1. The file is `image.raw`, not `myimage.raw` (the template said + `Output=myimage.raw`) +2. It's in `/etc/mkosi/mkosi.conf.d/`, not sysdeck's artifacts dir. + +The four packages from the scaffold template (`linux`, `linux-firmware`, +`systemd`, `openssh`) were silently dropped. Three compounding bugs were +to blame, and only one of them was the "obvious" one. + +### Bug 1: the scaffolded profile was never read + +`profile-create` (since v0.0.31) wrote +`/etc/mkosi/mkosi.conf.d/.conf` — a drop-in *fragment*. + +mkosi's drop-in semantics: `mkosi.conf.d/*.conf` files are layered on +top of a *parent* `mkosi.conf`. With no parent, mkosi runs as if the +fragment didn't exist. It auto-detected the host distro, defaulted to +`Format=disk` with `Output=image.raw`, and used an empty `Packages=` +list. That's why the output filename was `image.raw` not `myimage.raw`, +and why the only packages that got installed were `iana-etc` + +`filesystem` — mkosi's hardcoded Arch base. + +**Fix:** each profile now lives in its own directory +`/etc/mkosi/profiles//mkosi.conf`. `mkosi.conf` is the only +filename mkosi reads automatically from the cwd. `MKOSI_DIRS` updated +to scan `/etc/mkosi/profiles` first. + +This also makes per-profile `mkosi.extra/`, `mkosi.pkg/`, etc. work +naturally — operators can drop in supplementary files alongside the +config and mkosi picks them up. + +### Bug 2: the `Packages=` syntax was from mkosi v15 + +The template was: + +```ini +[Packages] +Packages= + linux + linux-firmware + systemd + openssh +``` + +That indented-continuation form was the **old systemd-mkosi (≤v15)** +syntax. Modern mkosi (v22+, what Arch ships as `mkosi 25.x`) wants +either `Packages=linux linux-firmware systemd openssh` on a single +line, or a separate `mkosi.pkg` file referenced via +`Packages=mkosi.pkg`. The v0.0.x form was silently parsed as a single +package named `"linux\nlinux-firmware\nsystemd\nopenssh"` and failed +to install. + +**Fix:** template + writer now emit the modern single-line form. The +reader accepts both forms so v0.0.x profiles migrate cleanly on first +append/replace. + +This bug was particularly nasty because: +- The writer's tests (`test_bridge_parsers.py:2216-2252`) asserted + `" vim\n"` was in the file — passing the test meant emitting the + *wrong* syntax. +- The reader only understood the indented form, so even if you fixed + the template by hand, the next `--mode=append` write would silently + re-break the syntax. + +### Bug 3: the artifact never reached sysdeck's artifacts directory + +`build()` (line 672-681 of the old code) only scanned +`/var/lib/sysdeck/builder/artifacts//` for artifacts. But mkosi +writes its output to the cwd (`/etc/mkosi/mkosi.conf.d/image.raw`). No +copy step. Hence "i dont see a final tarball or image file to look at" +from sysdeck's point of view — even though mkosi technically did +produce one. + +**Fix:** `_MKOSI_TEMPLATE` now sets +`OutputDirectory=/var/lib/sysdeck/builder/artifacts/` so mkosi +writes directly there. The artifacts panel's discovery code already +scanned that directory, so once mkosi writes there the panel finds it +automatically. + +### New feature: `profile-import-packages` + +Per operator request: *"import current os pkg list to profile should be +an option."* + +Queries the host's explicitly-installed packages: +- **Arch:** `pacman -Qqe` (explicitly installed; excludes deps) +- **Debian:** `apt-mark showmanual` (closest analog to `pacman -Qqe`) +- **Fedora:** `dnf repoquery --userinstalled --queryformat '%{name}'` + +Then writes the result into the profile via the existing +`_write_packages` dispatch. Defaults to **append** mode (the operator +usually wants to layer host packages on top of the profile's existing +baseline like `linux`/`systemd`/`openssh`). + +Three flags: +- `--mode=replace` — wipe the baseline first +- `--dry-run` — return what *would* be written, don't touch the file +- `--packages=` — override the host query with a JSON-encoded + multiline string (useful for importing a list captured on a + different host) + +The panel exposes a "⇩ Import host pkgs" button on every profile row. +Two-step UX: dry-run preview → `window.confirm` with package count, +source distro, and first 200 packages → append write. Operator can +cancel cleanly without any file changes. + +New polkit exec paths for `pacman`/`apt-mark`/`dnf` added to +`org.sysdeck.builder.modify`. + +### Regression tests + +7 new unit tests in `TestBuilderImportHostPackages`: +- `_detect_host_packages` dispatch (pacman path + dedup) +- `--packages` override end-to-end (writes the file) +- `--dry-run` doesn't write +- unknown profile / no args / bad mode / COMMANDS-registration error + paths + +(Actually 8 — one of the dispatch tests splits into two methods, one +for the happy path and one for dedup. The class total is 8.) + +4 existing tests in `TestBuilderPackagesField` updated for the new +single-line `Packages=` syntax. 1 new test +(`test_mkosi_modern_single_line_input_parsed`) guards against a +regression where the writer emits the new form but the reader only +understands the old one — that would silently break append mode on +profiles created by v0.1.0 itself. + +Total: 237 tests (was 228 in v0.0.50; +9). All build-time guards pass. + +### Why v0.1.0 (and not v0.0.51) + +The bug fixes are technically backwards-incompatible: profiles created +by v0.0.x live in `/etc/mkosi/mkosi.conf.d/.conf` and use the +indented `Packages=` syntax. v0.1.0's reader accepts both syntaxes +(safe migration), but the scaffold location is different. Operators +upgrading from v0.0.x to v0.1.0 should either: +1. Move their profiles from `/etc/mkosi/mkosi.conf.d/.conf` to + `/etc/mkosi/profiles//mkosi.conf`, or +2. Create a parent `/etc/mkosi/mkosi.conf` (any non-empty `[Distribution]` + section will do) so the existing drop-ins start being honored. + +The minor version bump makes the incompatibility visible. + +--- + +## v0.0.50 — 2026-08-19 (build path NameError fix: `import re` added to bridge/builder.py) + +### Theme: the one-line fix that took 18 releases to find + +v0.0.50 is a one-line bugfix release. An operator reported: + +> *NameError: name 're' is not defined. Did you forget to import +> 're'? happens right away on build for a new profile i created.* + +The traceback pointed at `bridge/builder.py` line 492, inside +`_new_build_id()`: + +```python +safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile) +``` + +`re` wasn't imported at module level. The module-level imports were: + +```python +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any +``` + +No `import re`. `_new_build_id` has used `re.sub` since v0.0.31 — +when the build operations were first added. The bug went undetected +for 18 releases (v0.0.31 through v0.0.49) until an operator actually +clicked Build on a freshly-created profile. + +#### Why it went undetected + +Three layers of defense all missed it: + +1. **`python3 -m py_compile`** (the `make check` syntax check) only + catches *syntax* errors. A `NameError` at call time is not a + syntax error — the code is syntactically valid Python, it just + references a name that isn't in scope when the function runs. + +2. **Unit tests.** The existing unit tests covered `profile_create`, + `profile_copy`, `profile_delete`, and the v0.0.49 package-writing + helpers. None of them call `build()`, and `build()` is the only + caller of `_new_build_id`. So the function that held the bug was + never exercised by any test. + +3. **Manual testing.** The operator workflow up to v0.0.48 was + "create/copy a profile, then build it from a shell." The v0.0.49 + release added the inline package-list field, which made the + create-then-build flow smooth enough that the operator clicked + Build in the panel for the first time — and hit the bug. + +The lesson: functions that are reachable only through a specific code +path (here: `build()` → `_new_build_id()`) need explicit tests that +exercise that path, even if the function itself looks trivial. The +`make check` syntax check is necessary but not sufficient. + +#### The fix + +One line added to the module-level imports: + +```python +import json +import os +import re # ← added +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any +``` + +Also removed the now-redundant local `import re` inside +`_write_packages_vmdb2` (it was a v0.0.49 workaround — that function +uses `re.compile` for the YAML-include regex, and I added a local +import there instead of checking whether `re` was already module-level. +It wasn't. The local import masked the missing module-level import for +`_write_packages_vmdb2`'s own tests, but did nothing for +`_new_build_id`). + +#### Regression tests + +9 new unit tests in `TestBuilderBuildPath`: + +- **`_new_build_id` format**: asserts the build_id matches + `^-(\d{14})$`. +- **`_new_build_id` sanitizes unsafe chars**: profile name + `myarch.v2` → `myarch_v2-` (dot replaced with `_`). +- **`_new_build_id` preserves safe chars**: profile name + `my-arch_profile` → `my-arch_profile-` (hyphens + underscores + kept). +- **`_new_build_id_re_imported_at_module_level`**: explicit + `assertIn("re", dir(builder))`. This is the regression guard — if + anyone ever removes the `import re` line in a future refactor, this + test fails before the tarball ships. The v0.0.31-v0.0.49 bug can't + recur. +- **`build()` success path**: end-to-end with mocked `subprocess.run` + (rc=0). Patches `BUILDER_STATE_DIR` / `BUILDER_LOGS_DIR` / + `BUILDER_ARTIFACTS_DIR` to tempdirs, patches `BACKENDS` to fake a + mkosi install, patches `profiles()` to return a fake profile. + Verifies response shape (`build_id` / `state` / `rc` / `success` / + `duration_s` / `artifacts` / `log_path`), state file written, log + file written, `subprocess.run` was actually called. +- **`build()` unknown profile**: returns `{error: "profile + 'nonexistent' not found"}`. +- **`build()` no args**: returns `{error: "profile name required"}` + (no crash). +- **`build()` backend not installed**: returns `{error: "backend + 'mkosi' is not installed", hint: ...}`. +- **`build()` non-zero returncode**: mocked `subprocess.run` returns + rc=1 → build state `"failed"`, `rc=1`, `success=False`. + +All tests mock `subprocess.run` and the module-level state dirs; none +touch real `/var/lib/` or invoke real backends. The tests run in 9 +milliseconds. + +#### AST audit + +To make sure there weren't *other* latent NameErrors lurking in +`builder.py`, I wrote an AST-based audit that walks every function +body, collects `Name` loads, and checks each against (module-level +names + function locals + builtins). The audit flagged ~50 items, +but every one was a false positive: + +- **Comprehension locals** (`b`, `v`, `s`, `p`, `logf`) — bound by + the comprehension itself. +- **Tuple-unpacking targets** (`cid`, `chint`, `k`, `v`, + `backend_id`, `binary`, `vargs`, `kind`) — bound by `for ... in` + loops. +- **Except-clause targets** (`exc`) — bound by `except ... as exc:`. +- **`__file__`** — provided by Python in every module. + +No real undefined names. The build path is now fully exercisable by +tests. + +#### What this release is NOT + +- **No JS changes.** This is a Python-only fix. The bridge.js + subcommand cross-check stays at 102 calls. +- **No new bridge subcommands.** `build` is an existing command; + it just works now. +- **No new plugin / polkit / bridge helper file.** Counts unchanged. +- **No changes to profile discovery, build invocation, or the + package-writing helpers.** The fix is purely the missing import. + +#### Process improvement + +The v0.0.31-v0.0.49 bug existed because no test exercised the +`build()` code path. v0.0.50 adds that test coverage — 9 tests that +mock `subprocess.run` and the state dirs, so they run hermetically in +the `make check` suite without needing a real backend installed. Any +future regression in the build path (missing imports, broken state- +file writing, wrong response shape, wrong subprocess invocation) will +now be caught before the tarball ships. + +--- + +## v0.0.49 — 2026-08-19 (builder inline package list: textarea + file upload + merge mode for all 4 backends) + +### Theme: close the loop on profile creation — paste the baseline apps inline + +v0.0.49 is a feature release for the Image Builder panel. Per user +directive: *"we should allow adding a pacman -Sy applist.txt with a +literal list of baseline apps for the profile being generated."* + +The v0.0.48 release fixed the dead-end error that operators of +archiso-only or live-build-only hosts were hitting, and added the +"Copy shipped profile" form. But both forms still left the operator +with a half-finished profile: they scaffolded/copied the config, then +had to drop to a shell to edit the package list. v0.0.49 closes that +loop — the package list is now part of the creation flow. + +#### 1. The field + +Both the Create Profile and Copy shipped profile forms now include a +shared `renderPackagesField(prefix, defaultMode)` block with three +parts: + +- A ` +
+ + +
+ + `; +} + +function renderCreateProfile(backends, primary) { + // v0.0.48: only mkosi and vmdb2 are scaffoldable via profile-create. + // The previous code fell back to `primary.id` when neither was + // installed, which on an archiso-only or live-build-only host + // funneled the operator straight into the "profile-create supports + // ('mkosi', 'vmdb2')" error. Now we render an inline hint instead. + const scaffoldable = backends.filter(b => b.id === 'mkosi' || b.id === 'vmdb2'); + if (!scaffoldable.length) { + const primaryId = primary ? primary.id : '(none)'; + return ` +
+
+

Create Profile

+
+
+

+ No scaffoldable backend is installed. profile-create + only supports mkosi and vmdb2 (single-file + specs). This host's primary backend is + ${escapeHtml(primaryId)}, which uses shipped + directory-based profiles — copy one with the form below + instead, or install a scaffoldable backend: +

+
    +
  • Arch: sudo pacman -S --needed mkosi
  • +
  • Debian: sudo apt install -y vmdb2
  • +
+
+
+ `; + } + const backendOptions = scaffoldable + .map(b => ``).join(''); + return ` +
+
+

Create Profile

+
+
+

+ Scaffolds a minimal profile in /etc/<backend>/. + Edit the scaffolded file before building. mkosi and vmdb2 only — + archiso / live-build use shipped profile dirs you should copy + via the "Copy shipped profile" form below. +

+
+ + + +
+ ${renderPackagesField('cp-create', 'replace')} +
+
+ `; +} + +function renderCopyProfile(profiles, primary) { + // v0.0.48: directory-based backends (archiso, live-build) ship + // profile trees under /usr/share that the operator should copy + // into /etc/ and edit. This form lists every shipped profile of + // those backends discovered via profiles() and offers a one-click + // copy via bridge.builder.profileCopy(). + const copyable = profiles.filter(p => p.backend === 'archiso' || p.backend === 'live-build'); + if (!copyable.length) { + return ` +
+
+

Copy shipped profile

+
+
+

+ No shipped archiso / live-build profiles were discovered + on this host. Install one of the ISO backends to make + shipped baselines available: +

+
    +
  • Arch: sudo pacman -S --needed archiso
  • +
  • Debian: sudo apt install -y live-build
  • +
+
+
+ `; + } + // Group options by backend for clarity. + const grouped = new Map(); + for (const p of copyable) { + if (!grouped.has(p.backend)) grouped.set(p.backend, []); + grouped.get(p.backend).push(p); + } + const optGroups = [...grouped.entries()].map(([backend, items]) => { + const opts = items.map(p => + `` + ).join(''); + return `${opts}`; + }).join(''); + return ` +
+
+

Copy shipped profile

+
+
+

+ Copies a shipped archiso or live-build + profile tree from /usr/share/ into + /etc/ so you can edit it before building. + This is the supported way to create new profiles for the + directory-based backends (they cannot be scaffolded from + scratch — profile-create only handles the + single-file mkosi/vmdb2 specs). +

+
+ + + +
+ ${renderPackagesField('cp-copy', 'append')} +
+
+ `; +} + +function renderBuilds(builds) { + if (!builds || !builds.length) { + return ` +
+

Builds (0)

+
+

No builds run yet. Click ▶ Build on a profile above to start one.

+
+
+ `; + } + const rows = builds.map((b) => { + const stateBadge = b.state === 'succeeded' + ? 'succeeded' + : b.state === 'failed' + ? 'failed' + : 'running'; + const duration = b.duration_s != null ? `${b.duration_s.toFixed(1)}s` : '—'; + const artifacts = (b.artifacts || []).map(a => escapeHtml(a.name)).join(', ') || '—'; + return ` + + ${escapeHtml(b.build_id)} + ${escapeHtml(b.profile)} + ${escapeHtml(b.backend)} + ${stateBadge} + ${escapeHtml(b.started || '—')} + ${escapeHtml(b.finished || '—')} + ${duration} + ${artifacts} + + + + + + `; + }).join(''); + return ` +
+
+

Builds (${builds.length})

+ +
+ + + ${rows} +
Build IDProfileBackendStateStartedFinishedDurationArtifactsActions
+
+ `; +} + +function renderArtifacts(artifacts) { + const byProfile = artifacts.by_profile || {}; + const profiles = Object.keys(byProfile); + if (!profiles.length) { + return ` +
+

Artifacts (0)

+
+

No artifacts yet. Build outputs land under /var/lib/sysdeck/builder/artifacts/<profile>/.

+
+
+ `; + } + const cards = profiles.map((p) => { + const files = byProfile[p] || []; + if (!files.length) return ''; + const totalSize = files.reduce((s, f) => s + (f.size || 0), 0); + return ` +
+
+

${escapeHtml(p)} artifacts (${files.length}, ${formatSize(totalSize)})

+ +
+ + + + ${files.map(f => ` + + + + + + + `).join('')} + +
NameSizeModifiedActions
${escapeHtml(f.name)}${formatSize(f.size)}${escapeHtml(f.modified || '—')} + + +
+
+ `; + }).join(''); + return cards; +} + +function renderHint(hint, distro) { + const fallback = ` +
+

Install an image builder

+
+

+ No image-builder backend was detected on this host. + Install one of the supported tools to enable this panel: +

+
    +
  • Arch Linux: sudo pacman -S --needed mkosi
  • +
  • Debian: sudo apt install -y vmdb2
  • +
+

+ Detected distro: ${escapeHtml(distro)} +

+
+
+ `; + if (!hint) return fallback; + return ` +
+

Install an image builder

+
+

+ No image-builder backend was detected on this host. + For ${escapeHtml(distro)}, the recommended + primary tool is ${escapeHtml(hint.primary)}: +

+
${escapeHtml(hint.primary_cmd)}
+

+ For bootable ISOs, use ${escapeHtml(hint.iso)}: +

+
${escapeHtml(hint.iso_cmd)}
+
+
+ `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const logCard = panel.querySelector('#builder-log-card'); + const logPre = panel.querySelector('#builder-log-pre'); + const logTitle = panel.querySelector('#builder-log-title'); + const showLog = (title, text) => { + if (!logCard || !logPre) return; + logCard.style.display = 'block'; + logTitle.textContent = title; + logPre.textContent = text; + }; + panel.querySelector('#btn-builder-log-close')?.addEventListener('click', () => { + if (logCard) logCard.style.display = 'none'; + }); + + // Build buttons (one per profile row). + panel.querySelectorAll('.btn-builder-build').forEach((btn) => { + btn.addEventListener('click', async () => { + const profile = btn.dataset.profile; + const backend = btn.dataset.backend; + btn.disabled = true; + btn.textContent = '⏳ Building ...'; + showLog(`Build ${profile} (${backend})`, `Running ${backend} build via cockpit superuser channel...\n(cockpit will prompt for auth)\n\nThis may take several minutes. The build runs synchronously — you can navigate away and check the Builds table for status.`); + try { + const r = await bridge.builder.build(profile, backend); + const lines = []; + lines.push(`Build ID: ${r.build_id}`); + lines.push(`Profile: ${r.profile}`); + lines.push(`Backend: ${r.backend}`); + lines.push(`State: ${r.state}`); + lines.push(`Exit: ${r.rc}`); + if (r.duration_s != null) lines.push(`Duration: ${r.duration_s.toFixed(1)}s`); + if (r.artifacts && r.artifacts.length) { + lines.push(''); + lines.push('Artifacts:'); + for (const a of r.artifacts) lines.push(` ${a.name} (${formatSize(a.size)})`); + } + lines.push(''); + lines.push('Log path: ' + (r.log_path || '(unknown)')); + // Fetch the full log. + try { + const logResult = await bridge.builder.buildLog(r.build_id); + if (logResult.log) { + lines.push(''); + lines.push('--- log ---'); + lines.push(logResult.log); + } + } catch (err) { + lines.push(`(log fetch failed: ${err.message || err})`); + } + showLog(`Build ${profile} — ${r.state}`, lines.join('\n')); + EventBus.emit('builder.build-done', r); + setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { + showLog(`Build ${profile} — error`, String(err.message || err)); + } finally { + btn.disabled = false; + btn.textContent = '▶ Build'; + } + }); + }); + + // v0.1.0: Import host pkgs buttons (one per profile row). Two-step: + // first a dry-run preview so the operator sees the package count + // and source distro before committing, then on confirm an actual + // append-mode write. Append is the safe default — the profile's + // existing baseline (kernel, systemd, openssh) is preserved and + // the host's explicitly-installed packages are layered on top. + panel.querySelectorAll('.btn-builder-import').forEach((btn) => { + btn.addEventListener('click', async () => { + const profile = btn.dataset.profile; + btn.disabled = true; + btn.textContent = '⏳ Querying...'; + showLog(`Import host packages — ${profile}`, 'Querying host package manager via cockpit superuser channel...'); + try { + // Step 1: dry-run preview. + const preview = await bridge.builder.profileImportPackages(profile, 'append', true); + if (preview.error) { + showLog(`Import host packages — ${profile} (error)`, `Host query failed:\n${preview.error}\n\nHint: ${preview.hint || 'ensure pacman/apt/dnf is installed on the host.'}`); + return; + } + const cnt = preview.package_count || 0; + const src = preview.source || 'unknown'; + const distro = preview.host_distro || 'unknown'; + const truncated = preview.truncated + ? `\n(showing first 200 of ${cnt}; full list will be written on confirm)` + : ''; + const sample = (preview.packages || []).join('\n'); + // Step 2: confirm and write. + const go = window.confirm( + `Import ${cnt} explicitly-installed packages from this host (${distro})\n` + + `into profile '${profile}' in APPEND mode?\n\n` + + `Source: ${src}\n\n` + + `Sample (first ${Math.min(cnt, 200)}):\n${sample}${truncated}` + ); + if (!go) { + showLog(`Import host packages — ${profile} (cancelled)`, 'Operator cancelled. Profile file untouched.'); + return; + } + btn.textContent = '⏳ Writing...'; + const r = await bridge.builder.profileImportPackages(profile, 'append', false); + const lines = []; + if (r.imported) { + lines.push(`Imported ${r.count || 0} packages from ${r.source} into '${r.profile}'.`); + lines.push(`Backend: ${r.backend}`); + lines.push(`Mode: ${r.mode}`); + lines.push(`Path: ${r.path}`); + } else if (r.error) { + lines.push(`Import failed: ${r.error}`); + } + showLog(`Import host packages — ${profile} — ${r.imported ? 'done' : 'error'}`, lines.join('\n')); + EventBus.emit('builder.import-done', r); + setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { + showLog(`Import host packages — ${profile} — error`, String(err.message || err)); + } finally { + btn.disabled = false; + btn.textContent = '⇩ Import host pkgs'; + } + }); + }); + + // Create Profile form. + panel.querySelector('#btn-builder-create')?.addEventListener('click', async () => { + const nameInput = panel.querySelector('#cp-name'); + const backendSelect = panel.querySelector('#cp-backend'); + const name = nameInput?.value?.trim(); + const backend = backendSelect?.value; + if (!name) { showLog('Create profile — error', 'Profile name required.'); return; } + if (!backend) { showLog('Create profile — error', 'Backend selection required.'); return; } + // v0.0.49: read optional package list + merge mode. + const packagesText = panel.querySelector('#cp-create-packages')?.value || ''; + const mode = panel.querySelector('#cp-create-mode')?.value || 'replace'; + const pkgSummary = packagesText.trim() + ? ` (${mode} mode, ${packagesText.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('#')).length} packages)` + : ''; + showLog('Create profile', `Scaffolding ${backend} profile '${name}'${pkgSummary} via cockpit superuser channel...`); + try { + const r = await bridge.builder.profileCreate(name, backend, null, + packagesText.trim() || null, mode); + if (r.created) { + const lines = [ + `Created ${r.backend} profile '${r.name}'.`, + '', + `Path: ${r.path}`, + `Template: ${r.template}`, + ]; + if (r.packages) { + lines.push(`Packages: ${r.packages.count} (${r.packages.mode} mode)`); + lines.push(`Package file: ${r.packages.path}`); + } else if (r.packages_error) { + lines.push(`Packages: ERROR — ${r.packages_error}`); + } + lines.push('', 'Edit the file(s) before building.'); + showLog('Create profile — success', lines.join('\n')); + setTimeout(() => mount(panel, { bridge, EventBus }), 1200); + } else { + showLog('Create profile — failed', `Error: ${r.error || 'unknown'}\n\n${r.hint || ''}`); + } + } catch (err) { + showLog('Create profile — error', String(err.message || err)); + } + }); + + // v0.0.48: Copy shipped profile form (archiso / live-build). + panel.querySelector('#btn-builder-copy')?.addEventListener('click', async () => { + const srcSelect = panel.querySelector('#cp-copy-src'); + const nameInput = panel.querySelector('#cp-copy-name'); + const newName = nameInput?.value?.trim(); + if (!newName) { showLog('Copy profile — error', 'New profile name required.'); return; } + // The option value is "|". + const raw = srcSelect?.value || ''; + const sepIdx = raw.lastIndexOf('|'); + if (sepIdx < 0) { showLog('Copy profile — error', 'Select a source profile.'); return; } + const srcName = raw.slice(0, sepIdx); + const backend = raw.slice(sepIdx + 1); + // v0.0.49: read optional package list + merge mode. + const packagesText = panel.querySelector('#cp-copy-packages')?.value || ''; + const mode = panel.querySelector('#cp-copy-mode')?.value || 'append'; + const pkgSummary = packagesText.trim() + ? ` (${mode} mode, ${packagesText.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('#')).length} packages)` + : ''; + showLog('Copy profile', + `Copying ${backend} profile '${srcName}' → '${newName}'${pkgSummary} via cockpit superuser channel...`); + try { + const r = await bridge.builder.profileCopy(srcName, newName, backend, + packagesText.trim() || null, mode); + if (r.copied) { + const lines = [ + `Copied ${r.backend} profile '${r.source}' → '${r.name}'.`, + '', + `Source: ${r.source_path}`, + `Destination: ${r.path}`, + ]; + if (r.packages) { + lines.push(`Packages: ${r.packages.count} (${r.packages.mode} mode)`); + lines.push(`Package file: ${r.packages.path}`); + } else if (r.packages_error) { + lines.push(`Packages: ERROR — ${r.packages_error}`); + } + lines.push('', 'Edit the files in the destination directory before building.'); + showLog('Copy profile — success', lines.join('\n')); + setTimeout(() => mount(panel, { bridge, EventBus }), 1200); + } else { + showLog('Copy profile — failed', `Error: ${r.error || 'unknown'}\n\n${r.hint || ''}`); + } + } catch (err) { + showLog('Copy profile — error', String(err.message || err)); + } + }); + + // v0.0.49: file-upload handlers for both forms. When the operator + // picks a file, read it as text and populate the corresponding + // textarea. The textarea is the source of truth — the operator + // can review/edit the uploaded content before submitting. + const wireFileInput = (fileInputId, textareaId, logLabel) => { + const fileInput = panel.querySelector(fileInputId); + const textarea = panel.querySelector(textareaId); + if (!fileInput || !textarea) return; + fileInput.addEventListener('change', () => { + const file = fileInput.files[0]; + if (!file) return; + // 1 MB cap — anything larger is probably not a package list. + if (file.size > 1_000_000) { + showLog(logLabel, `File ${file.name} is ${formatSize(file.size)} — too large (1 MB cap).`); + fileInput.value = ''; + return; + } + const reader = new FileReader(); + reader.onload = (ev) => { + textarea.value = ev.target.result; + showLog(logLabel, `Loaded ${file.name} (${formatSize(file.size)}) into the textarea — review and edit before submitting.`); + }; + reader.onerror = () => { + showLog(logLabel, `Failed to read ${file.name}: ${reader.error || 'unknown error'}`); + }; + reader.readAsText(file); + }); + }; + wireFileInput('#cp-create-packages-file', '#cp-create-packages', 'Create profile — file upload'); + wireFileInput('#cp-copy-packages-file', '#cp-copy-packages', 'Copy profile — file upload'); + + // Per-build log buttons. + panel.querySelectorAll('.btn-builder-log').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.dataset.buildId; + showLog(`Build log — ${id}`, 'Loading log ...'); + try { + const r = await bridge.builder.buildLog(id); + if (r.log) showLog(`Build log — ${id}`, r.log); + else showLog(`Build log — ${id}`, `Error: ${r.error || 'no log'}`); + } catch (err) { + showLog(`Build log — ${id}`, String(err.message || err)); + } + }); + }); + + // Refresh button. + panel.querySelector('#btn-builder-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); + + // v0.1.3: artifact download buttons. Uses cockpit.spawn(["cat", path]) + // to read the file as binary, then creates a Blob + download link. + // The file is read via the superuser channel so it works even when + // the artifacts dir is root-owned. + panel.querySelectorAll('.btn-artifact-download').forEach((btn) => { + btn.addEventListener('click', async () => { + const profile = btn.dataset.profile; + const name = btn.dataset.name; + const path = btn.dataset.path; + btn.disabled = true; + btn.textContent = '⏳ ...'; + try { + // Read the file via cockpit.spawn cat. We use binary mode + // by reading as a binary stream. cockpit.spawn returns a + // channel that we collect into a byte array. + const channel = cockpit.spawn(["cat", path], { + superuser: "try", + binary: true, + }); + const chunks = []; + channel.ondata = (data) => { chunks.push(data); }; + await new Promise((resolve, reject) => { + channel.onclose = (resp) => { + if (resp.exit_status === 0 || resp.exit_status === null) resolve(); + else reject(new Error(`cat exited ${resp.exit_status}`)); + }; + }); + const blob = new Blob(chunks, { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showLog(`Download — ${name}`, `Downloaded ${name} (${formatSize(blob.size)}).\nPath: ${path}`); + } catch (err) { + showLog(`Download — ${name} — error`, String(err.message || err)); + } finally { + btn.disabled = false; + btn.textContent = '⬇ Download'; + } + }); + }); + + // v0.1.3: artifact delete buttons (per-file). + panel.querySelectorAll('.btn-artifact-delete').forEach((btn) => { + btn.addEventListener('click', async () => { + const profile = btn.dataset.profile; + const name = btn.dataset.name; + if (!window.confirm(`Delete artifact '${name}' from profile '${profile}'?`)) return; + btn.disabled = true; + try { + const r = await bridge.builder.artifactDelete(profile, name); + if (r.deleted) { + showLog(`Delete artifact — ${name}`, `Deleted ${name} (freed ${formatSize(r.size || 0)}).`); + setTimeout(() => mount(panel, { bridge, EventBus }), 500); + } else { + showLog(`Delete artifact — ${name} — error`, r.error || 'unknown error'); + } + } catch (err) { + showLog(`Delete artifact — ${name} — error`, String(err.message || err)); + } finally { + btn.disabled = false; + } + }); + }); + + // v0.1.3: clear all artifacts for a profile. + panel.querySelectorAll('.btn-artifacts-clear').forEach((btn) => { + btn.addEventListener('click', async () => { + const profile = btn.dataset.profile; + if (!window.confirm(`Delete ALL artifacts for profile '${profile}'?\nThis cannot be undone.`)) return; + btn.disabled = true; + try { + const r = await bridge.builder.artifactsClear(profile); + if (r.cleared) { + showLog(`Clear artifacts — ${profile}`, + `Cleared ${r.files_deleted} files (${formatSize(r.bytes_freed || 0)} freed).`); + setTimeout(() => mount(panel, { bridge, EventBus }), 500); + } else { + showLog(`Clear artifacts — ${profile} — error`, r.error || 'unknown error'); + } + } catch (err) { + showLog(`Clear artifacts — ${profile} — error`, String(err.message || err)); + } finally { + btn.disabled = false; + } + }); + }); + + // v0.1.3: build delete buttons (removes state + log, optionally artifacts). + panel.querySelectorAll('.btn-builder-delete').forEach((btn) => { + btn.addEventListener('click', async () => { + const buildId = btn.dataset.buildId; + const profile = btn.dataset.profile; + const deleteArtifacts = window.confirm( + `Delete build record '${buildId}'?\n\n` + + `Click OK to delete state + log files only.\n` + + `Click Cancel to also delete the artifacts for profile '${profile}'.` + ); + // If user clicked Cancel on the first confirm, ask again with + // the "also delete artifacts" option. + let withArtifacts = false; + if (!deleteArtifacts) { + withArtifacts = window.confirm( + `Also delete ALL artifacts for profile '${profile}'?\n\n` + + `Click OK to delete state + log + artifacts.\n` + + `Click Cancel to abort.` + ); + if (!withArtifacts) return; + } + btn.disabled = true; + try { + const r = await bridge.builder.buildDelete(buildId, withArtifacts); + if (r.deleted) { + const lines = [`Deleted build ${buildId}.`, 'Files removed:']; + (r.files || []).forEach(f => lines.push(` ${f}`)); + if (r.errors && r.errors.length) { + lines.push('', 'Errors:'); + r.errors.forEach(e => lines.push(` ${e}`)); + } + showLog(`Delete build — ${buildId}`, lines.join('\n')); + setTimeout(() => mount(panel, { bridge, EventBus }), 500); + } else { + showLog(`Delete build — ${buildId} — error`, r.error || 'unknown error'); + } + } catch (err) { + showLog(`Delete build — ${buildId} — error`, String(err.message || err)); + } finally { + btn.disabled = false; + } + }); + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +function formatSize(bytes) { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let sz = bytes; + while (sz >= 1024 && i < units.length - 1) { sz /= 1024; i++; } + return `${sz.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
`; +} diff --git a/plugins/sysdeck-builder/index.html b/plugins/sysdeck-builder/index.html new file mode 100755 index 0000000..1b7efce --- /dev/null +++ b/plugins/sysdeck-builder/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Image Builder + + + + + +
+
Loading…
+
+ + + diff --git a/plugins/sysdeck-builder/manifest.json b/plugins/sysdeck-builder/manifest.json new file mode 100755 index 0000000..3b26847 --- /dev/null +++ b/plugins/sysdeck-builder/manifest.json @@ -0,0 +1,28 @@ +{ + "version": 0, + "name": "sysdeck-builder", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Image Builder", + "order": 30, + "keywords": [ + { + "matches": [ + "builder", + "mkosi", + "vmdb2", + "archiso", + "live-build", + "image", + "compose", + "blueprint" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-containers/containers.js b/plugins/sysdeck-containers/containers.js new file mode 100755 index 0000000..3641288 --- /dev/null +++ b/plugins/sysdeck-containers/containers.js @@ -0,0 +1,146 @@ +/* + * SysDeck - Containers Panel (v0.0.35) + * Author: Jeremy Anderson (https://dcos.net) + * + * Podman container management panel. The Kata portion of the + * former merged "Containers & VMs" module is now its own sidebar + * entry — `SysDeck Kata` (plugins/sysdeck-kata/) — per the v0.0.35 + * user directive: "kata containers should be called SysDeck Kata and + * moved out of the tools area." + * + * v0.0.34 was a merged two-tab panel (Podman + Kata iframe). The + * v0.0.35 split restores the one-module-one-concern shape: this + * panel manages Podman containers only; the SysDeck Kata plugin + * hosts the pre-built cockpit-kata React app for Kata sandboxes & VMs. + * + * Bridge surface (see shared/bridge.js → bridge.containers): + * list() → podman ps --format json (normalized) + * inspect(id) → podman inspect + * action(id, action) → podman stop|restart|rm + * count() → derived from list() + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + panel.innerHTML = renderShell(); + await renderPodmanTable(panel, { bridge, EventBus }); + EventBus.emit('containers.loaded', {}); +} + +// ── Shell ────────────────────────────────────────────────────────── + +function renderShell() { + return ` +
+

Containers

+

Podman runtime — list, inspect, stop / restart / remove

+
+
+
+

Loading containers…

+ +
+
+
+
+
+

+ Kata Containers (hardware-virtualized OCI sandboxes) is now a + standalone sidebar entry — SysDeck Kata. Open it + to manage Kata sandboxes & VMs from the pre-built React app. +

+
+
+ `; +} + +async function renderPodmanTable(panel, { bridge, EventBus }) { + const host = panel.querySelector('#containers-table-host'); + const summary = panel.querySelector('#containers-summary'); + if (!host || !summary) return; + host.innerHTML = `
`; + + let containers = []; + try { + containers = await bridge.containers.list(); + } catch (err) { + summary.textContent = 'Podman unavailable'; + host.innerHTML = renderPodmanError(err); + return; + } + summary.textContent = `Podman runtime — ${containers.length} container${containers.length === 1 ? '' : 's'}`; + host.innerHTML = ` + + + + + + ${containers.map(renderPodmanRow).join('') || ''} + +
IDNameImageStatusPortsActions
No containers. Run `podman run -d --name hello alpine sleep 9999` to create one.
+ `; + + host.querySelectorAll('[data-action]').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const action = btn.dataset.action; + btn.disabled = true; + try { + await bridge.containers.action(id, action); + EventBus.emit('container.action', { id, action }); + } catch (err) { + EventBus.emit('container.error', { id, error: err.message }); + } + renderPodmanTable(panel, { bridge, EventBus }); + }); + }); +} + +function renderPodmanRow(c) { + const statusClass = (c.status || '').startsWith('Up') ? 'success' : 'warn'; + const id = (c.id || '').substring(0, 12); + const name = c.name || '—'; + const image = c.image || '—'; + const status = c.status || '—'; + const ports = c.ports || '—'; + return ` + + ${escapeHtml(id)} + ${escapeHtml(name)} + ${escapeHtml(image)} + ${escapeHtml(status)} + ${escapeHtml(ports)} + + + + + + + `; +} + +function renderPodmanError(err) { + return `
+

Podman unavailable

+

${escapeHtml(err.message || String(err))}. Install podman to manage containers from this panel.

+

Arch: pacman -S podman · Debian: apt install podman · Fedora: dnf install podman

+
`; +} + +// ── Utilities ─────────────────────────────────────────────────────── + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
+
+
+
+
`; +} diff --git a/plugins/sysdeck-containers/index.html b/plugins/sysdeck-containers/index.html new file mode 100755 index 0000000..2da7a12 --- /dev/null +++ b/plugins/sysdeck-containers/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Containers + + + + + +
+
Loading…
+
+ + + diff --git a/plugins/sysdeck-containers/manifest.json b/plugins/sysdeck-containers/manifest.json new file mode 100755 index 0000000..f7d8820 --- /dev/null +++ b/plugins/sysdeck-containers/manifest.json @@ -0,0 +1,30 @@ +{ + "version": 0, + "name": "sysdeck-containers", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Containers & VMs", + "order": 20, + "keywords": [ + { + "matches": [ + "containers", + "podman", + "docker", + "oci", + "images", + "pods", + "kata", + "sandbox", + "vm", + "isolation" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval' frame-src 'self'" +} diff --git a/plugins/sysdeck-db/db.js b/plugins/sysdeck-db/db.js new file mode 100755 index 0000000..19dc218 --- /dev/null +++ b/plugins/sysdeck-db/db.js @@ -0,0 +1,324 @@ +/* + * SysDeck - Databases Panel (v0.0.33) + * Author: Jeremy Anderson (https://dcos.net) + * + * DB Control module — unified control for SQL / NoSQL / vector / + * time-series / graph / embedded / cloud / AI database engines. + * The bridge helper bridge/db.py surfaces 32+ engines with + * summary / status / start / stop / restart / connections / query + * subcommands. + * + * The cockpit-way pattern (v0.0.31+): the bridge runs systemctl + * directly via subprocess; the JS panel passes { superuser: 'try' } + * to cockpit.spawn so the cockpit bridge prompts the operator via + * polkit for the org.sysdeck.db.modify action. + * + * The panel surfaces: + * - Summary card: total engines, running count, total data size, + * total memory, total connections. + * - Engines table: per-engine id, name, family, status, version, + * port, size, memory, connections, with Start/Stop/Restart + * buttons per row. + * - Query runner: SQL-family engines only — input a SQL string + * and execute against the selected engine. + * - Connections viewer: list active TCP connections to an engine. + * + * Bridge surface (see shared/bridge.js → bridge.db): + * summary() → {engines, totalEngines, runningCount, ...} + * status(id) → single-engine detail dict + * start(id) → {action, engine, rc, success, output, stderr} + * stop(id) → same shape + * restart(id) → same shape + * connections(id) → {connections, count} + * query(id, sql) → {output, engine, query} + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + let summary = {}; + try { + summary = await bridge.db.summary(); + } catch (err) { + panel.innerHTML = renderError(err); + return; + } + + const engines = summary.engines || []; + const running = engines.filter((e) => e.status === 'running'); + const totalSize = summary.totalSizeMB || 0; + const totalMem = summary.totalMemoryMB || 0; + const totalConn = summary.totalConnections || 0; + + // Group engines by family for cleaner rendering. + const byFamily = new Map(); + for (const e of engines) { + if (!byFamily.has(e.family)) byFamily.set(e.family, []); + byFamily.get(e.family).push(e); + } + + panel.innerHTML = ` +
+

Database Control

+

+ ${summary.totalEngines || engines.length} engines across ${byFamily.size} families + · ${running.length} running + · ${(totalSize / 1024).toFixed(1)} GB data + · ${(totalMem / 1024).toFixed(1)} GB memory + · ${totalConn} connections +

+
+ +
+
+

Engines

+
${summary.totalEngines || engines.length}
+
total (${byFamily.size} families)
+
+
+

Running

+
${running.length}
+
${running.length ? 'active services' : 'all stopped'}
+
+
+

Data size

+
${(totalSize / 1024).toFixed(1)}
+
GB across all engines
+
+
+ + ${renderEnginesTable(engines, byFamily)} + +
+
+

Run SQL Query

+
+
+

+ SQL-family engines only (postgresql, mysql/mariadb, sqlite, cockroachdb, clickhouse, duckdb). + The query is run via the engine's CLI client (psql -tAc, mysql -e, etc.). +

+
+ + + +
+ +
+
+ + + +
+
+

Engine Detail

+ +
+
+

+ Click ▶/■/↻ on a row above to start / stop / restart an engine. + Click 🔍 to view the engine's full status (version, port, data dir, + config path, log path, connections, memory). +

+
+
+ `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('db.loaded', { engineCount: engines.length, running: running.length }); +} + +// ── Render helpers ─────────────────────────────────────────────────── + +function renderEnginesTable(engines, byFamily) { + if (!engines.length) { + return ` +
+

Engines (0)

+
+

No database engines detected on this host.

+
+
+ `; + } + // Render a table per family for clearer grouping. + const cards = []; + for (const [family, items] of byFamily) { + const running = items.filter((e) => e.status === 'running').length; + cards.push(` +
+
+

${escapeHtml(family)} (${items.length})

+ ${running} running +
+ + + + ${items.map((e) => ` + + + + + + + + + + + `).join('')} + +
EngineStatusVersionPortSizeMemConnsActions
${escapeHtml(e.name)}
${escapeHtml(e.id)}
${statusBadge(e.status)}${escapeHtml((e.version || '').slice(0, 30))}${e.port || '—'}${formatSize(e.sizeMB)}${formatSize(e.memoryMB)}${e.connections || 0} +
+ + + + +
+
+
+ `); + } + return cards.join(''); +} + +function statusBadge(status) { + const map = { + 'running': 'running', + 'starting': 'starting', + 'stopped': 'stopped', + 'error': 'error', + 'uninstalled': 'uninstalled', + }; + return map[status] || `${escapeHtml(status)}`; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const outputCard = panel.querySelector('#db-output-card'); + const outputPre = panel.querySelector('#db-output-pre'); + const outputTitle = panel.querySelector('#db-output-title'); + const showOutput = (title, text, isError = false) => { + if (!outputCard || !outputPre) return; + outputCard.style.display = 'block'; + outputTitle.textContent = title; + outputPre.textContent = text; + outputPre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + panel.querySelector('#btn-db-output-close')?.addEventListener('click', () => { + if (outputCard) outputCard.style.display = 'none'; + }); + + const startStopRestart = async (btn, method, label) => { + const id = btn.dataset.id; + if (!id) return; + btn.disabled = true; + showOutput(`${label} ${id}`, `${label} engine ${id} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.db[method](id); + const ok = r.success ?? (r.rc === 0); + showOutput(`${label} ${id} — ${ok ? 'success' : 'failed'}`, + `rc=${r.rc}\nsuccess=${ok}\noutput: ${r.output || '(empty)'}\nstderr: ${r.stderr || '(empty)'}`, + !ok); + if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 1200); + } catch (err) { + showOutput(`${label} ${id} — error`, String(err.message || err), true); + } finally { + setTimeout(() => { btn.disabled = false; }, 1200); + } + }; + + panel.querySelectorAll('.btn-db-start').forEach((btn) => { + btn.addEventListener('click', () => startStopRestart(btn, 'start', 'Start')); + }); + panel.querySelectorAll('.btn-db-stop').forEach((btn) => { + btn.addEventListener('click', () => startStopRestart(btn, 'stop', 'Stop')); + }); + panel.querySelectorAll('.btn-db-restart').forEach((btn) => { + btn.addEventListener('click', () => startStopRestart(btn, 'restart', 'Restart')); + }); + + panel.querySelectorAll('.btn-db-status').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.dataset.id; + showOutput(`Status: ${id}`, 'Loading ...'); + try { + const r = await bridge.db.status(id); + if (r.error) { showOutput(`Status: ${id} — error`, r.error, true); return; } + showOutput(`Status: ${id}`, JSON.stringify(r, null, 2)); + } catch (err) { showOutput(`Status: ${id} — error`, String(err.message || err), true); } + }); + }); + + panel.querySelector('#btn-db-query')?.addEventListener('click', async () => { + const engineId = panel.querySelector('#db-query-engine')?.value; + const sql = panel.querySelector('#db-query-sql')?.value?.trim(); + if (!engineId) { showOutput('Query', 'Select an engine first.', true); return; } + if (!sql) { showOutput('Query', 'Enter a SQL query first.', true); return; } + showOutput(`Query: ${engineId}`, `Running query on ${engineId} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.db.query(engineId, sql); + if (r.error) { + showOutput(`Query: ${engineId} — error`, r.error, true); + } else { + showOutput(`Query: ${engineId}`, `query: ${r.query || sql}\n\noutput:\n${r.output || '(empty)'}`); + } + } catch (err) { showOutput(`Query: ${engineId} — error`, String(err.message || err), true); } + }); + + panel.querySelector('#btn-db-connections')?.addEventListener('click', async () => { + const engineId = panel.querySelector('#db-query-engine')?.value; + if (!engineId) { showOutput('Connections', 'Select an engine first.', true); return; } + showOutput(`Connections: ${engineId}`, 'Loading ...'); + try { + const r = await bridge.db.connections(engineId); + if (r.error) { showOutput(`Connections: ${engineId} — error`, r.error, true); return; } + const lines = [`count: ${r.count}`]; + for (const c of (r.connections || [])) lines.push(c); + showOutput(`Connections: ${engineId}`, lines.join('\n')); + } catch (err) { showOutput(`Connections: ${engineId} — error`, String(err.message || err), true); } + }); + + panel.querySelector('#btn-db-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +function formatSize(mb) { + if (!mb) return '0 MB'; + if (mb < 1024) return `${mb} MB`; + return `${(mb / 1024).toFixed(1)} GB`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
+
+
+
+
`; +} + +function renderError(err) { + return `
+

Database bridge unavailable

+

${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/db.py.

+
`; +} diff --git a/plugins/sysdeck-db/index.html b/plugins/sysdeck-db/index.html new file mode 100755 index 0000000..8763087 --- /dev/null +++ b/plugins/sysdeck-db/index.html @@ -0,0 +1,64 @@ + + + + + SysDeck Databases + + + + + +
+
Loading…
+
+ + + diff --git a/plugins/sysdeck-db/manifest.json b/plugins/sysdeck-db/manifest.json new file mode 100755 index 0000000..24a9ff3 --- /dev/null +++ b/plugins/sysdeck-db/manifest.json @@ -0,0 +1,37 @@ +{ + "version": 0, + "name": "sysdeck-db", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Databases", + "order": 39, + "keywords": [ + { + "matches": [ + "database", + "db", + "sql", + "postgresql", + "mysql", + "mariadb", + "sqlite", + "mongodb", + "redis", + "valkey", + "influxdb", + "neo4j", + "clickhouse", + "milvus", + "qdrant", + "weaviate", + "duckdb" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-fester/fester.js b/plugins/sysdeck-fester/fester.js new file mode 100755 index 0000000..f634e8b --- /dev/null +++ b/plugins/sysdeck-fester/fester.js @@ -0,0 +1,692 @@ +/* + * SysDeck - Fester Panel (v0.2.0) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.2.0 REAL INTEGRATION — replaces the v0.1.3 build-jobs stub (which + * listed systemd units whose name contained "fester"/"build" and never + * talked to an orchestrator). This panel is now a live client of the + * vendored fester service — the distributed DAG build orchestrator + * from web/mini-services/fester, REST + WebSocket on 127.0.0.1:3010 — + * through bridge/fester.py: + * + * status / metrics / builds / nodes / targets / sessions read polls + * startBuild → POST /api/build (build_id) + * cancel → POST /api/builds//cancel + * replay → POST /api/sessions (session) + * timeline → GET /api/timeline/ + * + * Layout: service status card + stat grid, cluster nodes table, + * builds table (live first, then history) with per-row Cancel / + * Replay / Timeline actions, and a Start-a-Build form fed by the + * project/target catalog. Auto-refreshes every 5s; the refresh loop + * re-renders only the status/nodes/builds containers — the form is + * rendered once, so its state survives every tick. If the service is + * down the panel shows the bridge's error message verbatim (it carries + * the remediation hint) and keeps retrying, flipping back online on + * its own. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + const ctx = { + panel, + bridge, + EventBus, + laidOut: false, // full layout rendered (vs offline card) + expanded: new Set(), // build ids whose timeline row is open + firstRenderDone: false, + targetsResp: null, // project catalog for the start form + }; + + // The status probe decides online vs offline — its error message + // carries the remediation hint, shown verbatim when offline. + let status = null; + let statusErr = null; + try { + status = await bridge.fester.status(); + } catch (err) { + statusErr = err; + } + + if (statusErr || !status || status.ok === false) { + renderOffline(ctx, errMessage(statusErr, status)); + EventBus.emit('fester.offline', {}); + } else { + await mountLayout(ctx, status); + } + + // ── auto-refresh (5s) ───────────────────────────────────────── + // Re-fetches status + metrics + builds + nodes and re-renders only + // the status/nodes/builds containers. Also watches for the service + // going away (→ offline card) or coming back (→ full layout). + if (panel._festerInterval) clearInterval(panel._festerInterval); + panel._festerInterval = setInterval(() => { poll(ctx); }, 5000); + + // Clean up the interval when the panel leaves the DOM + // (house pattern from netsec.js). + const observer = new MutationObserver(() => { + if (!document.body.contains(panel)) { + clearInterval(panel._festerInterval); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); +} + +// ── refresh loop ──────────────────────────────────────────────────── + +async function poll(ctx) { + let status = null; + let statusErr = null; + try { + status = await ctx.bridge.fester.status(); + } catch (err) { + statusErr = err; + } + const online = !statusErr && status && status.ok !== false; + + if (online && !ctx.laidOut) { + // Service came back after an offline render — build the layout. + await mountLayout(ctx, status); + return; + } + if (!online && ctx.laidOut) { + // Service dropped — swap to the offline card. + renderOffline(ctx, errMessage(statusErr, status)); + ctx.EventBus.emit('fester.offline', {}); + return; + } + if (!online) return; // still offline — the card is already shown + + await refreshData(ctx, status); +} + +async function refreshNow(ctx) { + // Immediate re-fetch after a user action (start/cancel/manual). + let status = null; + let statusErr = null; + try { + status = await ctx.bridge.fester.status(); + } catch (err) { + statusErr = err; + } + if (!statusErr && status && status.ok !== false && ctx.laidOut) { + await refreshData(ctx, status); + } +} + +async function refreshData(ctx, status) { + if (!ctx.laidOut) return; + const { bridge, panel } = ctx; + + // Per-call try/catch: one failing endpoint must not sink the panel. + const metricsResp = await safe(bridge.fester.metrics()); + const buildsResp = await safe(bridge.fester.builds()); + const nodesResp = await safe(bridge.fester.nodes()); + + const statusEl = panel.querySelector('#fester-status'); + const nodesEl = panel.querySelector('#fester-nodes'); + const buildsEl = panel.querySelector('#fester-builds'); + if (statusEl) statusEl.innerHTML = renderStatus(status, metricsResp, nodesResp); + if (nodesEl) nodesEl.innerHTML = renderNodes(nodesResp); + if (buildsEl) buildsEl.innerHTML = renderBuilds(buildsResp); + + wireStatusAndRows(ctx); + + // Re-open timeline rows that were expanded before the re-render + // (the events re-fetch keeps them live). + await reopenExpanded(ctx); + + if (!ctx.firstRenderDone) { + ctx.firstRenderDone = true; + ctx.EventBus.emit('fester.loaded', { builds: countBuilds(buildsResp) }); + } +} + +// ── layout ────────────────────────────────────────────────────────── + +async function mountLayout(ctx, status) { + const { panel, bridge } = ctx; + ctx.laidOut = true; + + // Target catalog feeds the Start-a-Build form. Fetched once per + // layout — the form is static for the panel's lifetime, which is + // what preserves its state across refresh ticks. + let targetsResp = null; + let targetsErr = null; + try { + targetsResp = await bridge.fester.targets(); + } catch (err) { + targetsErr = err; + } + ctx.targetsResp = targetsResp; + + const baseUrl = (status && status.base_url) || 'http://127.0.0.1:3010'; + panel.innerHTML = ` +
+

SysDeck Fester

+

Distributed DAG build orchestration — vendored fester service (${escapeHtml(baseUrl)} · REST+WS)

+
+
+
+
+
+ ${renderStartForm(targetsResp, targetsErr)} +
+ `; + + wireStartForm(ctx); + await refreshData(ctx, status); +} + +function renderOffline(ctx, message) { + ctx.laidOut = false; + ctx.panel.innerHTML = ` +
+

SysDeck Fester

+

Distributed DAG build orchestration — vendored fester service (web/mini-services/fester · REST+WS)

+
+
+
+

Fester Service Offline

+ offline +
+

${escapeHtml(message)}

+

Retrying every 5 seconds — the panel reconnects automatically when the service is back.

+
+ `; +} + +// ── status card + stat grid ───────────────────────────────────────── + +function renderStatus(status, metricsResp, nodesResp) { + const m = (metricsResp && metricsResp.ok !== false) ? metricsResp : null; + const metrics = (m && m.metrics) || {}; + const builds = metrics.builds || {}; + const actions = metrics.actions || {}; + const nodesOk = nodesResp && nodesResp.ok !== false; + const nodeCount = nodesOk && Array.isArray(nodesResp.nodes) + ? nodesResp.nodes.length + : (status && typeof status.nodes === 'number' ? status.nodes : 0); + const stat = (v) => (m ? n(v) : '—'); + + return ` +
+
+

Service

+
+ online + +
+
+
+
+ fester v${escapeHtml(status && status.version)} + ${escapeHtml(status && status.base_url)} · ${escapeHtml(status && status.transport)} · clock ${escapeHtml(status && status.clock)} +
+
+ ${nodeCount} cluster nodes + up ${escapeHtml(fmtDuration(status && status.uptime_s))} +
+ ${m ? '' : `

metrics unavailable${metricsResp && metricsResp.error ? ': ' + escapeHtml(metricsResp.error) : ''}

`} +
+
+
+
+
${stat(builds.total)}
+
builds total
+
+
+
${stat(builds.running)}
+
running
+
+
+
${stat(builds.succeeded)}
+
succeeded
+
+
+
${stat(builds.failed)}
+
failed
+
+
+
${m ? fmtNum(actions.cache_hit_rate, 1) + '%' : '—'}
+
cache-hit rate (${stat(actions.cache_hits)}/${stat(actions.total)} actions)
+
+
+
${nodesOk ? nodeCount : '—'}
+
cluster nodes
+
+
+ `; +} + +// ── cluster nodes table ───────────────────────────────────────────── + +function renderNodes(nodesResp) { + const nodes = (nodesResp && Array.isArray(nodesResp.nodes)) ? nodesResp.nodes : null; + if (!nodes) { + const msg = (nodesResp && nodesResp.error) + ? nodesResp.error + : 'No cluster nodes registered.'; + return ` +
+

Cluster Nodes

+

${escapeHtml(msg)}

+
`; + } + const rows = nodes.map((nd) => ` + ${escapeHtml(nd.name)} + ${nodeStateBadge(nd.state)} + ${fmtNum(nd.cpu_load, 1)}% + ${fmtNum(nd.temp, 1)}°C + ${n(nd.active_jobs)}/${n(nd.max_jobs)} + `).join(''); + return ` +
+

Cluster Nodes

+ + + ${rows} +
NodeStateCPU loadTempJobs (active/max)
+
`; +} + +function nodeStateBadge(state) { + const s = String(state || ''); + const cls = s === 'online' ? 'suite-badge success' + : s === 'degraded' ? 'suite-badge warn' + : 'suite-badge'; + return `${escapeHtml(s || '—')}`; +} + +// ── builds table ──────────────────────────────────────────────────── + +function renderBuilds(buildsResp) { + if (!buildsResp || buildsResp.ok === false) { + const msg = (buildsResp && buildsResp.error) + ? buildsResp.error + : 'build list unavailable'; + return ` +
+

Builds

+

${escapeHtml(msg)}

+
`; + } + const live = Array.isArray(buildsResp.builds) ? buildsResp.builds : []; + const history = Array.isArray(buildsResp.history) ? buildsResp.history : []; + const seen = new Set(live.map((b) => String(b.build_id || ''))); + + const rows = [ + ...live.map((b) => buildRow(b, true)), + ...history + .filter((b) => !seen.has(String(b.build_id || ''))) + .map((b) => buildRow(b, false)), + ]; + + return ` +
+

Builds

+ + + + + + + ${rows.join('') || ''} + +
BuildProjectStateActionsCache hitsCritical pathStarted
No builds yet — start one below.
+

Live builds first, then history (${live.length} live · ${history.length} stored).

+
`; +} + +function buildRow(b, live) { + const id = String(b.build_id || ''); + const state = String(b.state || ''); + const running = state === 'running' || state === 'queued'; + const done = live ? null : b.actions_done; + const total = live ? b.actions : b.actions_total; + const cacheHits = live ? null : b.cache_hits; + const criticalMs = live ? null : b.critical_path_ms; + + const buttons = [ + running ? `` : '', + !running ? `` : '', + ``, + ].filter(Boolean).join(' '); + + return ` + ${escapeHtml(id)} + ${escapeHtml(b.project || '')} + ${stateBadge(state)} + ${done == null ? '—' : n(done)}/${n(total)} + ${cacheHits == null ? '—' : n(cacheHits)} + ${criticalMs == null ? '—' : n(criticalMs) + ' ms'} + ${escapeHtml(fmtTime(b.started_at))} + +
${buttons}
+
+ + `; +} + +function stateBadge(state) { + const s = String(state || ''); + const cls = (s === 'running' || s === 'queued') ? 'suite-badge warn' + : s === 'succeeded' ? 'suite-badge success' + : s === 'failed' ? 'suite-badge danger' + : 'suite-badge'; // cancelled / unknown → neutral gray + return `${escapeHtml(s || '—')}`; +} + +// ── timeline expansion ────────────────────────────────────────────── + +async function toggleTimeline(ctx, buildId) { + if (ctx.expanded.has(buildId)) { + ctx.expanded.delete(buildId); + const row = findTimelineRow(ctx, buildId); + if (row) row.remove(); + return; + } + ctx.expanded.add(buildId); + await insertTimelineRow(ctx, buildId); +} + +async function insertTimelineRow(ctx, buildId) { + const buildRowEl = findBuildRow(ctx, buildId); + if (!buildRowEl) return; + const tr = document.createElement('tr'); + tr.setAttribute('data-timeline', buildId); + tr.innerHTML = `loading timeline…`; + buildRowEl.after(tr); + try { + const resp = await ctx.bridge.fester.timeline(buildId); + tr.innerHTML = timelineTd(resp); + } catch (err) { + tr.innerHTML = `${escapeHtml(errMessage(err, null))}`; + } +} + +async function reopenExpanded(ctx) { + for (const id of ctx.expanded) { + if (!findBuildRow(ctx, id)) continue; + await insertTimelineRow(ctx, id); + } +} + +function timelineTd(resp) { + const events = (resp && Array.isArray(resp.events)) ? resp.events : []; + if (!events.length) { + return `No timeline events.`; + } + const lines = events.slice(-15).map((ev) => + `
${escapeHtml(fmtEvent(ev))}
` + ).join(''); + return `${lines}`; +} + +function fmtEvent(ev) { + let line = `#${n(ev.id)} ${fmtTimeShort(ev.ts)} ${String(ev.type || '?')}`; + if (ev.state != null) line += `/${String(ev.state)}`; + if (ev.action) line += ` — ${ev.action}`; + return line; +} + +// ── start-a-build form ────────────────────────────────────────────── + +function renderStartForm(targetsResp, targetsErr) { + const projects = (targetsResp && Array.isArray(targetsResp.projects)) ? targetsResp.projects : null; + if (targetsErr || !projects) { + const msg = targetsErr ? errMessage(targetsErr, null) + : (targetsResp && targetsResp.error) || 'target catalog unavailable'; + return ` +
+

Start a Build

+

${escapeHtml(msg)}

+
`; + } + return ` +
+
+

Start a Build

+ POST /api/build via bridge.fester.startBuild +
+
+
+ + +
+
Targets
+
+
+ + +
+ +
+
+
`; +} + +function wireStartForm(ctx) { + const { panel } = ctx; + const select = panel.querySelector('#fester-project'); + const targetsBox = panel.querySelector('#fester-targets'); + if (!select || !targetsBox) return; + + const renderTargetOptions = () => { + const name = select.value; + const project = ((ctx.targetsResp && ctx.targetsResp.projects) || []) + .find((p) => p.name === name); + const list = (project && project.targets) || []; + targetsBox.innerHTML = list.map((t) => ` + `).join('') || 'No targets for this project.'; + }; + renderTargetOptions(); + select.addEventListener('change', renderTargetOptions); + + panel.querySelector('#btn-fester-start')?.addEventListener('click', async () => { + const result = panel.querySelector('#fester-start-result'); + const btn = panel.querySelector('#btn-fester-start'); + const project = select.value; + const targets = Array.from(panel.querySelectorAll('.fester-target-cb:checked')) + .map((el) => el.value); + const noCache = !!(panel.querySelector('#fester-nocache')?.checked); + const retries = Number(panel.querySelector('#fester-retries')?.value || '0'); + + if (!project || targets.length === 0) { + if (result) result.textContent = 'Pick a project and at least one target.'; + return; + } + if (btn) btn.disabled = true; + if (result) result.textContent = `starting ${project} (${targets.join(', ')})…`; + try { + const res = await ctx.bridge.fester.startBuild(project, targets, { noCache, retries }); + if (result) { + result.textContent = (res && res.ok) + ? `started build ${res.build_id} (retries ${res.retries != null ? res.retries : 0})` + : `start failed: ${(res && res.error) || 'unknown error'}`; + } + } catch (err) { + if (result) result.textContent = `start failed: ${errMessage(err, null)}`; + } + if (btn) btn.disabled = false; + // Immediate refresh so the new build shows up right away. + refreshNow(ctx); + }); +} + +// ── row actions (cancel / replay / timeline) ──────────────────────── + +function wireStatusAndRows(ctx) { + const { panel } = ctx; + + panel.querySelector('#btn-fester-refresh')?.addEventListener('click', () => { + refreshNow(ctx); + }); + + panel.querySelectorAll('#fester-builds button[data-fx]').forEach((btn) => { + btn.addEventListener('click', () => { + const fx = btn.getAttribute('data-fx'); + const id = btn.getAttribute('data-build'); + if (!id) return; + if (fx === 'cancel') doCancel(ctx, id, btn); + else if (fx === 'replay') doReplay(ctx, id, btn); + else if (fx === 'timeline') toggleTimeline(ctx, id); + }); + }); +} + +async function doCancel(ctx, buildId, btn) { + btn.disabled = true; + let res = null; + let err = null; + try { + res = await ctx.bridge.fester.cancel(buildId); + } catch (e) { + err = e; + } + // A 409-style {ok:false, error:"build not running"} is surfaced + // as-is — the bridge prints the service's JSON verbatim. + const msg = err ? errMessage(err, null) + : (res && res.ok) ? 'cancel requested' + : `cancel failed: ${(res && res.error) || 'unknown error'}`; + setRowNote(ctx, buildId, msg); + refreshNow(ctx); +} + +async function doReplay(ctx, buildId, btn) { + btn.disabled = true; + let res = null; + let err = null; + try { + res = await ctx.bridge.fester.replay(buildId); + } catch (e) { + err = e; + } + let msg; + if (err) { + msg = `replay failed: ${errMessage(err, null)}`; + } else if (res && res.ok) { + const sid = res.session && res.session.session_id; + msg = sid ? `session ${sid}` : 'session created'; + } else { + msg = `replay failed: ${(res && res.error) || 'unknown error'}`; + } + setRowNote(ctx, buildId, msg); + btn.disabled = false; +} + +function setRowNote(ctx, buildId, msg) { + ctx.panel.querySelectorAll('[data-note]').forEach((el) => { + if (el.getAttribute('data-note') === buildId) el.textContent = msg; + }); +} + +function findBuildRow(ctx, buildId) { + let found = null; + ctx.panel.querySelectorAll('#fester-builds tr[data-build]').forEach((el) => { + if (el.getAttribute('data-build') === buildId) found = el; + }); + return found; +} + +function findTimelineRow(ctx, buildId) { + let found = null; + ctx.panel.querySelectorAll('#fester-builds tr[data-timeline]').forEach((el) => { + if (el.getAttribute('data-timeline') === buildId) found = el; + }); + return found; +} + +// ── utilities ─────────────────────────────────────────────────────── + +async function safe(p) { + try { + const v = await p; + return v ?? null; + } catch { + return null; + } +} + +function errMessage(err, resp) { + if (resp && resp.error) return String(resp.error); + if (err && err.message) return String(err.message); + if (err) return String(err); + return 'fester service unreachable'; +} + +function countBuilds(buildsResp) { + if (!buildsResp || buildsResp.ok === false) return 0; + const live = Array.isArray(buildsResp.builds) ? buildsResp.builds.length : 0; + const hist = Array.isArray(buildsResp.history) ? buildsResp.history.length : 0; + return live + hist; +} + +function n(v) { + const num = Number(v); + return Number.isFinite(num) ? num : 0; +} + +function fmtNum(v, digits) { + const num = Number(v); + return Number.isFinite(num) ? num.toFixed(digits) : '—'; +} + +function fmtTime(ts) { + const num = Number(ts); + if (!Number.isFinite(num) || num <= 0) return '—'; + return new Date(num * 1000).toLocaleString(); +} + +function fmtTimeShort(ts) { + const num = Number(ts); + if (!Number.isFinite(num) || num <= 0) return '--:--:--'; + return new Date(num * 1000).toLocaleTimeString(); +} + +function fmtDuration(s) { + const num = Number(s); + if (!Number.isFinite(num) || num < 0) return '—'; + const h = Math.floor(num / 3600); + const m = Math.floor((num % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${Math.floor(num % 60)}s`; + return `${Math.floor(num)}s`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderSkeleton() { + return `
+
+
+
+
`; +} diff --git a/plugins/sysdeck-fester/index.html b/plugins/sysdeck-fester/index.html new file mode 100755 index 0000000..47b92bc --- /dev/null +++ b/plugins/sysdeck-fester/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Fester + + + + + +
+
Loading…
+
+ + + diff --git a/plugins/sysdeck-fester/manifest.json b/plugins/sysdeck-fester/manifest.json new file mode 100755 index 0000000..bbb465d --- /dev/null +++ b/plugins/sysdeck-fester/manifest.json @@ -0,0 +1,25 @@ +{ + "version": 0, + "name": "sysdeck-fester", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Fester", + "order": 28, + "keywords": [ + { + "matches": [ + "fester", + "build", + "orchestration", + "dag", + "compose" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-firewall/firewall.js b/plugins/sysdeck-firewall/firewall.js new file mode 100755 index 0000000..e21206c --- /dev/null +++ b/plugins/sysdeck-firewall/firewall.js @@ -0,0 +1,811 @@ +/* + * SysDeck - Firewall Panel (v0.0.45) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.45 TRADEMARK SCRUB. Wording-only release — no functional changes. + * Per user directive: "you cannot say smoothwall and ipfire where merged + * into our fw script either. you can say logic derived from or influenced + * by these projects." Every reference to Smoothwall Express or IPFire now + * uses "takes influence from" / "logic derived from" instead of "merged" + * or "shipped". The sysdeck-fw backend, the 7 firewall templates, and the + * v0.0.44 service/port editor are unchanged. All 141 unit tests pass. + * + * v0.0.47 — SERVICE/PORT EDITOR MOVED TO ITS OWN SIDEBAR ENTRY. + * Per user directive: "we should move the service/ports editor to its + * own module entry for ease of access." The editor card that lived at + * the bottom of this panel since v0.0.44 has been lifted out into a + * first-class plugin — sysdeck-services at order 45. The bridge surface + * (bridge.firewall.services / service-info / set-service-port / + * restart-service) is unchanged; a new bridge.services proxy was added + * to bridge.js so the new panel has a clean API. The services() Promise + * in the parallel load below was removed because this panel no longer + * needs the inventory — it lives in the new Services panel. The + * renderServicePortEditor() function and the .btn-svc-save / .btn-svc- + * restart wireEvents handlers were removed from this file. + * + * v0.0.44 PUBLIC-SERVER VARIANTS + SERVICE/PORT EDITOR. + * + * - Three new public-server templates ship in this release: + * remote-admin.sh (SSH + Cockpit), public-webserver.sh + * (Caddy + Varnish + MariaDB), ai-llm.sh (Ollama + OpenWebUI + * + Hermes + Odysseus). They appear in the existing Templates + * card when the 'custom' backend is active — no new UI surface + * needed for selection. + * - Service/Port Editor card — runs bridge.firewall.services() to + * enumerate listening TCP ports on the host and cross-reference + * against the SERVICES_REGISTRY (ssh, cockpit, caddy, varnish, + * mariadb, ollama, openwebui, hermes, odysseus). Each registered + * service shows: current port from its config file, the listening + * ports actually active, the systemd unit, and an editable port + * input. Clicking Save edits the config file atomically (tmpfile + * + fsync + rename) and runs `systemctl restart` on the service. + * Unmapped listeners (ports with no matching registry entry) are + * shown in a separate block so the operator can spot services + * the editor doesn't yet know about. + * + * v0.0.31 REWRITE — PREVIOUS VERSION WAS READ-ONLY. + * + * The v0.0.30 panel could only list active nftables rules. v0.0.31 + * turns it into a full firewall manager: + * + * - Template selector — operator picks from installed templates + * under /usr/share/sysdeck/firewall/templates/ (vps-webserver.sh, + * no-services.sh, plus any operator-dropped *.sh). Each template + * is shown with its description and detected services. + * - Apply / Stop / Restart buttons — invoke the template's start / + * stop / restart action via the bridge under the cockpit + * superuser channel (polkit). No `sudo` shell-out from JS. + * - Service detection preview — runs the template's `detect` + * action and renders the inventory (OS, interface, services + * detected) before applying. + * - Live ban-list table — ssh_abuse / port_scanners / connlimit_abuse + * sets with per-IP Unban buttons and a Clear All button. + * - Active ruleset table — refreshed after each mutating operation + * so the operator sees the new state immediately. + * + * v0.0.36 BACKEND DROPDOWN + SECURITY CARD. + * + * - Backend selector — operator picks between custom / cilium / + * sysdeck-fw. The bridge probes availability (cilium + * installed? nftables installed? kernel BPF features?) and shows + * an install hint if missing. The "Install" button triggers + * bridge.firewall.installBackend (delegates to packages module). + * - Cilium-specific sections — when cilium is the active backend, + * the panel renders Cilium Status / Endpoints / Policy cards in + * place of the nftables ruleset table. + * - Security Card — renders the CVE-derived hardening checklist + * (bridge.firewall.securityHardening). Documents the lessons + * applied from Webmin, Cockpit, Ajenti, ISPConfig, Virtualmin, + * cPanel, Plesk, CyberPanel, aaPanel, CloudPanel, HestiaCP, + * VestaCP, Froxlor, InterWorx, BrainyCP, DirectAdmin, CWP CVE + * disclosures. Full table in docs/SECURITY-HARDENING.md. + * - Excluded backends info — explains why UFW, fwbuilder, iptables- + * legacy, iptables-nft, Shorewall, Smoothwall Express (trademark), + * and IPFire (trademark) are not in the dropdown. We took influence + * from Smoothwall Express and IPFire for the sysdeck-fw backend; + * we do not ship templates called "smoothwall" or "ipfire". + * + * Mutating ops go through bridge.firewall.apply / stop / restart / ban / + * unban / clearBans / switchBackend / installBackend / ciliumPolicyApply, + * which pass { superuser: 'try' } to cockpit.spawn. The cockpit bridge + * prompts the operator for auth via polkit; the org.sysdeck.firewall.modify + * action (shipped since v0.0.17, extended in v0.0.36 to authorize cilium + * + cilium-agent + helm) authorizes the binaries. This is the "cockpit + * way" per user directive v0.0.31. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + // v0.0.47: services inventory no longer loaded here — the editor + // moved to its own sidebar entry (sysdeck-services at order 45). + const [backendsResp, templates, status, rules, chains, hardening] = await Promise.all([ + safe(bridge.firewall.backends(), { active: 'custom', backends: [], excluded: [] }), + safe(bridge.firewall.templates(), []), + safe(bridge.firewall.status(), { state: 'unavailable', bans: {} }), + safe(bridge.firewall.listRules(), []), + safe(bridge.firewall.listChains(), []), + safe(bridge.firewall.securityHardening(), { applied: [], cves_reviewed: [] }), + ]); + const activeBackend = backendsResp?.active || 'custom'; + const backends = backendsResp?.backends || []; + const excluded = backendsResp?.excluded || []; + const activeTemplate = status?.active_template || null; + const state = status?.state || 'unavailable'; + const bans = status?.bans || {}; + const bannedIpCount = status?.banned_ip_count || 0; + const isCilium = activeBackend === 'cilium'; + + panel.innerHTML = ` +
+

Firewall Control

+

+ ${escapeHtml(activeBackend)} + · ${isCilium ? 'eBPF datapath' : 'nftables'} + · ${state} + ${!isCilium ? ` · ${chains.length} chains · ${rules.length} rules` : ''} + · ${bannedIpCount} banned IP${bannedIpCount === 1 ? '' : 's'} + ${activeTemplate ? ` · active: ${escapeHtml(activeTemplate)}` : ''} +

+
+ + ${renderBackendSelector(backends, excluded, activeBackend, templates, activeTemplate)} + + ${isCilium ? await renderCiliumSections(bridge) : ''} + + ${!isCilium ? renderTemplateSelector(templates, activeTemplate, state, activeBackend, backends) : ''} + + ${renderControls(state, isCilium, activeBackend, backends)} + + ${renderDetectionSection()} + + ${!isCilium ? renderBans(bans, bannedIpCount) : ''} + + ${!isCilium ? renderRuleset(rules, chains) : ''} + + ${renderSecurityCard(hardening)} + + ${renderServicesLinkCard()} + + + `; + + // v0.0.43: Store backends data for getSelectedTemplate() to access + // (the Apply button handler needs to know which backend is active + // to determine which template to apply). + window.__sysdeckFirewallBackends = backends; + window.__sysdeckFirewallActiveBackend = activeBackend; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('firewall.loaded', { + ruleCount: rules.length, state, bannedIpCount, activeBackend, + }); +} + +// ── Render helpers ────────────────────────────────────────────────── + +function renderBackendSelector(backends, excluded, activeBackend, templates, activeTemplate) { + if (!backends || !backends.length) { + return ` +
+

Firewall Backend

+
+

+ No firewall backends available. This indicates a + broken install — re-install the sysdeck + package. +

+
+
+ `; + } + // v0.0.43: Find the active backend object to determine whether it + // has its own template (cilium → cilium, sysdeck-fw → sysdeck-fw). + // If it does, the template selector is hidden — the backend IS the + // template. Only 'custom' shows the template selector. + const activeBackendObj = backends.find((b) => b.id === activeBackend) || backends[0]; + const backendHasTemplate = activeBackendObj && activeBackendObj.template; + const options = backends.map((b) => { + const isActive = b.id === activeBackend; + const installed = b.available?.installed; + const techBadge = b.ebpf + ? 'eBPF' + : 'nftables'; + const statusBadge = installed + ? 'installed' + : 'not installed'; + return ` + + `; + }).join(''); + const excludedItems = excluded.map((e) => ` +
  • ${escapeHtml(e.id)} — ${escapeHtml(e.reason)}
  • + `).join(''); + return ` +
    +
    +

    Firewall Backend (${backends.length})

    + +
    +
    + ${options} +

    + Switching backend stops the previous backend cleanly + before applying the new one. + ${backendHasTemplate + ? `The ${escapeHtml(activeBackendObj.name)} backend uses its own template (${escapeHtml(activeBackendObj.template)}) — no template selection needed.` + : 'The custom backend lets you pick from the basic nftables templates below.'} +

    + ${excluded.length ? ` +
    + + Excluded backends (${excluded.length}) — click to expand + +
      + ${excludedItems} +
    +
    + ` : ''} +
    +
    + `; +} + +async function renderCiliumSections(bridge) { + // Fetch Cilium status + endpoints + policy in parallel. + const [statusResp, endpointsResp, policyResp] = await Promise.all([ + safe(bridge.firewall.ciliumStatus(), { installed: false, output: '', stderr: '' }), + safe(bridge.firewall.ciliumEndpoints(), { installed: false, endpoints: [] }), + safe(bridge.firewall.ciliumPolicy(), { installed: false, policies: [] }), + ]); + const installed = statusResp?.installed || false; + if (!installed) { + return ` +
    +
    +

    Cilium eBPF Backend

    +
    +
    +

    + cilium-cli is not installed. Use the "Install via + packages module" button on the backend card above, + or install manually: +

    +
    sudo pacman -S cilium-cli      # Arch
    +sudo apt install cilium-cli    # Debian
    +helm repo add cilium https://helm.cilium.io/
    +helm install cilium cilium/cilium -n kube-system
    +
    +
    + `; + } + const endpointCount = Array.isArray(endpointsResp?.endpoints) ? endpointsResp.endpoints.length : 0; + const policyCount = Array.isArray(policyResp?.policies) ? policyResp.policies.length : 0; + return ` +
    +
    +

    Cilium Status

    +
    +
    +
    ${escapeHtml(statusResp?.output || statusResp?.stderr || '(no output)')}
    +
    +
    +
    +
    +

    Cilium Endpoints (${endpointCount})

    +
    +
    +
    ${escapeHtml(JSON.stringify(endpointsResp?.endpoints || [], null, 2))}
    +
    +
    +
    +
    +

    Cilium Policies (${policyCount})

    + +
    +
    +
    ${escapeHtml(JSON.stringify(policyResp?.policies || [], null, 2))}
    +
    +
    + `; +} + +function renderServicesLinkCard() { + // v0.0.47: the Service / Port Editor moved to its own sidebar entry + // (sysdeck-services at order 45). This card is a signpost — it tells + // the operator where to find the editor and what it does. Keeping + // a stub here preserves the workflow for operators who used to + // scroll to the bottom of the Firewall panel for port edits. + return ` +
    +
    +

    Service / Port Editor

    + moved +
    +
    +

    + The service/port editor has been promoted to its own + sidebar entry — Service / Ports at + order 45 — for ease of access. It enumerates every + listening TCP socket on the host via + ss -tlnp (with a + /proc/net/tcp fallback), cross-references + against the SERVICES_REGISTRY in + bridge/firewall.py, and lets you edit + the port in the service's config file with an atomic + write + systemctl restart. +

    +

    + Click Service / Ports in the sidebar + to open the editor. The bridge surface + (bridge.firewall.services / + service-info / + set-service-port / + restart-service) is unchanged from + v0.0.44; a new bridge.services proxy + was added in v0.0.47 so the new panel has a clean + API surface. +

    +
    +
    + `; +} + +function renderSecurityCard(hardening) { + if (!hardening || !hardening.applied || !hardening.applied.length) { + return ''; + } + const rows = hardening.applied.map((h) => ` + + ${escapeHtml(h.id || '')} + ${escapeHtml(h.title || '')}
    ${escapeHtml(h.detail || '')}
    + ${escapeHtml(h.cve || '')} + + `).join(''); + const cveBadges = (hardening.cves_reviewed || []).slice(0, 12).map((cve) => + `${escapeHtml(cve)}` + ).join(''); + const moreCount = (hardening.cves_reviewed || []).length - 12; + return ` +
    +
    +

    Security Hardening (v0.0.36)

    + ${hardening.applied.length} lessons applied +
    +
    +

    + Each hardening item maps to a real CVE disclosure in + Webmin, Cockpit, Ajenti, ISPConfig, or Virtualmin. + Full checklist in docs/SECURITY-HARDENING.md. +

    + + + ${rows} +
    IDHardeningCVE
    +
    + CVEs reviewed: + ${cveBadges} + ${moreCount > 0 ? `+ ${moreCount} more` : ''} +
    +
    +
    + `; +} + +function renderTemplateSelector(templates, activeTemplate, state, activeBackend, backends) { + // v0.0.43: If the active backend has its own template (cilium → cilium, + // sysdeck-fw → sysdeck-fw), DON'T render the template selector at all — + // the backend IS the template. This fixes the v0.0.36 logic flaw where + // two independent lists (backend + template) didn't coordinate. + const activeBackendObj = backends?.find((b) => b.id === activeBackend); + if (activeBackendObj?.template) { + return ''; // backend has its own template — selector not needed + } + // For the 'custom' backend, filter templates to show ONLY the basic + // nftables templates (vps-webserver, no-services). Exclude cilium + + // sysdeck-fw — those are backend-specific and would conflict if applied + // while the custom backend is active. + const backendTemplates = new Set( + (backends || []) + .filter((b) => b.template) // backends with their own template + .map((b) => b.template) + ); + const filteredTemplates = (templates || []).filter((t) => + !backendTemplates.has(t.name) + ); + if (!filteredTemplates.length) { + return ` +
    +

    Templates

    +
    +

    + No firewall templates found under + /usr/share/sysdeck/firewall/templates/. + Install the sysdeck package to ship the + default templates (vps-webserver.sh, no-services.sh), + or drop your own *.sh file there. +

    +
    +
    + `; + } + const items = filteredTemplates.map((t) => { + const isActive = t.name === activeTemplate; + return ` + + `; + }).join(''); + return ` +
    +
    +

    Templates (${filteredTemplates.length})

    +
    +
    + ${items} +
    +
    + `; +} + +function renderControls(state, isCilium, activeBackend, backends) { + const isRunning = state === 'running'; + // v0.0.43: backend-aware Apply button label. + let applyLabel; + if (isCilium) { + applyLabel = '▶ Apply Cilium Policy'; + } else { + const backend = backends?.find((b) => b.id === activeBackend); + if (backend?.template) { + applyLabel = `▶ Apply ${backend.template}`; + } else { + applyLabel = '▶ Apply Template'; + } + } + return ` +
    +
    +

    Controls

    +
    +
    +
    + + + + + + +
    +

    + Apply / Restart / Stop prompt for the cockpit superuser + password via polkit. The + org.sysdeck.firewall.modify action authorizes + /usr/bin/nft${isCilium ? ', /usr/bin/cilium, /usr/bin/cilium-agent, /usr/bin/helm' : ''}. +

    +
    +
    + `; +} + +function renderDetectionSection() { + return ` + + `; +} + +function renderBans(bans, total) { + const sets = Object.keys(bans); + if (!sets.length) { + return ` +
    +

    Banned IPs (0)

    +
    +

    No ban sets available — firewall is not running.

    +
    +
    + `; + } + const allBanned = new Set(); + for (const set of sets) { + for (const ip of (bans[set] || [])) allBanned.add(ip); + } + const banRows = [...allBanned].map((ip) => { + const setsWithIp = sets.filter((s) => (bans[s] || []).includes(ip)); + return ` + + ${escapeHtml(ip)} + ${setsWithIp.map((s) => `${escapeHtml(s)}`).join('')} + + + `; + }).join(''); + return ` +
    +
    +

    Banned IPs (${allBanned.size})

    + +
    + + + + ${banRows || ''} + +
    IPIn setsAction
    No banned IPs.
    +

    + Ban sets: ${sets.join(', ')}. + ssh_abuse = SSH brute-force ban (1h timeout), + port_scanners = port scan detection (1h timeout), + connlimit_abuse = connection rate limit exceeded (10m timeout). +

    +
    + `; +} + +function renderRuleset(rules, chains) { + return ` +
    +
    +

    Active Ruleset

    + ${rules.length} rules in ${chains.length} chains +
    + + + + + + ${rules.slice(0, 100).map((r) => ` + + + + + + `).join('') || ''} + +
    ChainRuleHandle
    ${escapeHtml(r.chain)}${escapeHtml(r.spec)}${r.handle}
    No rules — firewall is not running.
    + ${rules.length > 100 ? `

    Showing first 100 of ${rules.length} rules.

    ` : ''} +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const output = (msg, isError = false) => { + const card = panel.querySelector('#fw-output'); + const pre = panel.querySelector('#fw-output-pre'); + if (!card || !pre) return; + card.style.display = 'block'; + pre.textContent = msg; + pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + panel.querySelector('#btn-fw-output-close')?.addEventListener('click', () => { + const card = panel.querySelector('#fw-output'); + if (card) card.style.display = 'none'; + }); + + const getSelectedTemplate = () => { + // v0.0.43: The template to apply depends on the active backend. + // If the backend has its own template (cilium → cilium, sysdeck-fw + // → sysdeck-fw), use that — the template selector is hidden. + // If the backend is 'custom', use the operator's selection from + // the template radio buttons. + const backendsResp = window.__sysdeckFirewallBackends; + const activeBackend = window.__sysdeckFirewallActiveBackend; + if (backendsResp && activeBackend) { + const backend = backendsResp.find((b) => b.id === activeBackend); + if (backend?.template) return backend.template; + } + // 'custom' backend — use the radio button selection. + const checked = panel.querySelector('input[name="fw-template"]:checked'); + if (checked) return checked.value; + // If only one template, return it. + const only = panel.querySelector('input[name="fw-template"]'); + return only ? only.value : null; + }; + + const getSelectedBackend = () => { + const checked = panel.querySelector('input[name="fw-backend"]:checked'); + return checked ? checked.value : null; + }; + + // v0.0.36: backend dropdown — switch backend (stops old, applies new). + panel.querySelector('#btn-fw-switch-backend')?.addEventListener('click', async () => { + const backend = getSelectedBackend(); + if (!backend) { output('Select a backend first.', true); return; } + output(`Switching firewall backend to ${backend} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.firewall.switchBackend(backend); + const steps = (r.steps || []).map((s) => + ` [${s.step}] rc=${s.rc} ${s.output ? '· ' + s.output.split('\n')[0] : ''}${s.stderr ? ' · stderr: ' + s.stderr.split('\n')[0] : ''}` + ).join('\n'); + output(r.switched + ? `Backend switched: ${r.previous} → ${r.current}\n\nSteps:\n${steps}\n\nAvailable: ${JSON.stringify(r.available)}` + : `Backend switch FAILED: ${JSON.stringify(r, null, 2)}`, + !r.switched); + if (r.switched) setTimeout(() => mount(panel, { bridge, EventBus }), 1200); + } catch (err) { output(`Switch-backend error: ${err.message || err}`, true); } + }); + + // v0.0.36: install backend deps (delegates to packages module). + panel.querySelectorAll('.btn-fw-install-backend').forEach((btn) => { + btn.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const backend = btn.dataset.backend; + if (!backend) return; + output(`Installing packages for backend ${backend} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.firewall.installBackend(backend); + output(r.installed + ? `Backend ${backend} installed.\nPackages: ${(r.packages || []).join(', ')}\n\n${r.output || ''}` + : `Install FAILED.\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`, + !r.installed); + if (r.installed) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { output(`Install-backend error: ${err.message || err}`, true); } + }); + }); + + // v0.0.36: apply Cilium default policy. + panel.querySelector('#btn-fw-cilium-apply-policy')?.addEventListener('click', async () => { + output('Applying Cilium default policy ... (cockpit will prompt for auth)'); + try { + const r = await bridge.firewall.ciliumPolicyApply('cilium-default.yaml'); + output(r.applied + ? `Cilium policy applied: ${r.policy}\n\n${r.output || ''}` + : `Cilium policy apply FAILED.\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`, + !r.applied); + if (r.applied) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { output(`Cilium-policy-apply error: ${err.message || err}`, true); } + }); + + panel.querySelector('#btn-fw-apply')?.addEventListener('click', async () => { + const template = getSelectedTemplate(); + if (!template) { output('Select a template first.', true); return; } + output(`Applying template ${template} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.firewall.apply(template); + const msg = r.success + ? `Template ${r.template} applied successfully.\n\n${r.output || ''}` + : `Apply FAILED (rc=${r.rc}).\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`; + output(msg, !r.success); + if (r.success) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { output(`Apply error: ${err.message || err}`, true); } + }); + + panel.querySelector('#btn-fw-restart')?.addEventListener('click', async () => { + output('Restarting firewall ... (cockpit will prompt for auth)'); + try { + const r = await bridge.firewall.restart(); + const ok = r.restarted; + output(ok + ? `Firewall restarted (template: ${r.template || 'none'}).\n\n${JSON.stringify(r.apply_result || {}, null, 2)}` + : `Restart did not complete: ${JSON.stringify(r, null, 2)}`, + !ok); + if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { output(`Restart error: ${err.message || err}`, true); } + }); + + panel.querySelector('#btn-fw-stop')?.addEventListener('click', async () => { + output('Stopping firewall ... (cockpit will prompt for auth)'); + try { + const r = await bridge.firewall.stop(); + output(r.stopped + ? `Firewall stopped (method: ${r.method}).\n\n${r.output || ''}` + : `Stop FAILED.\n\nstderr: ${r.stderr || '(empty)'}`, + !r.stopped); + if (r.stopped) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { output(`Stop error: ${err.message || err}`, true); } + }); + + panel.querySelector('#btn-fw-detect')?.addEventListener('click', async () => { + const card = panel.querySelector('#fw-detect-card'); + const pre = panel.querySelector('#fw-detect-pre'); + if (!card || !pre) return; + card.style.display = 'block'; + pre.textContent = 'Running service detection ...'; + try { + const r = await bridge.firewall.detect(); + pre.textContent = r.output || `(no output)\n\nstderr: ${r.stderr || ''}`; + EventBus.emit('firewall.detected', { template: r.active_template }); + } catch (err) { + pre.textContent = `Detect error: ${err.message || err}`; + } + }); + + panel.querySelector('#btn-fw-check')?.addEventListener('click', async () => { + output('Validating ruleset ...'); + try { + const r = await bridge.firewall.check(); + output(r.valid + ? 'Ruleset is valid.' + : `Validation FAILED.\n\nstderr: ${r.stderr || '(empty)'}`, + !r.valid); + } catch (err) { output(`Check error: ${err.message || err}`, true); } + }); + + panel.querySelector('#btn-fw-clear-bans')?.addEventListener('click', async () => { + output('Clearing all ban lists ... (cockpit will prompt for auth)'); + try { + const r = await bridge.firewall.clearBans(); + output(r.cleared + ? 'All ban lists cleared.' + : `Clear-bans partial failure: ${JSON.stringify(r.sets)}`, + !r.cleared); + if (r.cleared) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { output(`Clear-bans error: ${err.message || err}`, true); } + }); + + panel.querySelectorAll('.btn-fw-unban').forEach((btn) => { + btn.addEventListener('click', async () => { + const ip = btn.dataset.ip; + output(`Unbanning ${ip} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.firewall.unban(ip); + output(r.unbanned + ? `${ip} unbanned from sets: ${Object.entries(r.sets).filter(([k, v]) => v).map(([k]) => k).join(', ') || '(was not in any set)'}` + : `${ip} was not found in any ban set.`, + !r.unbanned); + setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { output(`Unban error: ${err.message || err}`, true); } + }); + }); + + panel.querySelector('#btn-fw-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); + + // v0.0.47: the .btn-svc-save / .btn-svc-restart handlers and the + // .svc-port-input wiring were removed from this file — the entire + // Service / Port Editor card moved to the new sysdeck-services + // plugin at sidebar order 45. See plugins/sysdeck-services/services.js. + + // systemd unit subscription (kept from v0.0.30 — best-effort). + if (panel._unsubscribeUnit) panel._unsubscribeUnit(); + try { + if (bridge.dbusProxies?.systemd) { + panel._unsubscribeUnit = bridge.dbusProxies.systemd.subscribeToUnit( + 'nftables.service', + () => mount(panel, { bridge, EventBus }), + ); + } + } catch { + // systemd proxy unavailable — manual refresh still works. + } +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-firewall/index.html b/plugins/sysdeck-firewall/index.html new file mode 100755 index 0000000..5a035aa --- /dev/null +++ b/plugins/sysdeck-firewall/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Firewall + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-firewall/manifest.json b/plugins/sysdeck-firewall/manifest.json new file mode 100755 index 0000000..9d79eca --- /dev/null +++ b/plugins/sysdeck-firewall/manifest.json @@ -0,0 +1,50 @@ +{ + "version": 0, + "name": "sysdeck-firewall", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Firewall", + "order": 21, + "keywords": [ + { + "matches": [ + "firewall", + "nftables", + "iptables", + "rules", + "filter", + "ban", + "unban", + "template", + "ssh", + "rate limit", + "cilium", + "ebpf", + "xdp", + "sysdeck-fw", + "zone", + "color zone", + "airwall", + "backend", + "security", + "hardening", + "remote-admin", + "public-webserver", + "ai-llm", + "ollama", + "openwebui", + "hermes", + "odysseus", + "caddy", + "varnish", + "mariadb" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-firmware/firmware.js b/plugins/sysdeck-firmware/firmware.js new file mode 100755 index 0000000..84cf0e3 --- /dev/null +++ b/plugins/sysdeck-firmware/firmware.js @@ -0,0 +1,52 @@ +/* + * SysDeck - Firmware Panel + * Author: Jeremy Anderson (https://dcos.net) + * + * Uses fwupdmgr to enumerate firmware devices and tpm2_pcrread to dump + * the first PCR register (boot chain proof). Both calls fail closed + * with informative cards when the underlying tools are absent. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + const [devices, tpmPcr0] = await Promise.allSettled([ + bridge.firmware.devices(), + bridge.firmware.tpmInfo(), + ]); + const deviceList = devices.value?.Devices ?? []; + panel.innerHTML = ` +
    +

    Firmware Control

    +

    fwupd + TPM 2.0

    +
    +
    +

    fwupd Devices (${deviceList.length})

    + + + + ${deviceList.map((d) => ` + + + + + + + `).join('') || ''} + +
    NameVendorVersionFlags
    ${d.Name}${d.Vendor ?? '—'}${d.Version ?? '—'}${(d.Flags ?? []).join(', ') || '—'}
    No fwupd devices.
    +
    +
    +

    TPM 2.0 — PCR 0 (SHA256)

    +
    ${escapeHtml(tpmPcr0.value ?? 'tpm2-tools not installed')}
    +
    + `; + EventBus.emit('firmware.loaded', { deviceCount: deviceList.length }); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); +} + +function renderSkeleton() { + return `
    `; +} diff --git a/plugins/sysdeck-firmware/index.html b/plugins/sysdeck-firmware/index.html new file mode 100755 index 0000000..a4fbfc9 --- /dev/null +++ b/plugins/sysdeck-firmware/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Firmware + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-firmware/manifest.json b/plugins/sysdeck-firmware/manifest.json new file mode 100755 index 0000000..121e342 --- /dev/null +++ b/plugins/sysdeck-firmware/manifest.json @@ -0,0 +1,25 @@ +{ + "version": 0, + "name": "sysdeck-firmware", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Firmware", + "order": 29, + "keywords": [ + { + "matches": [ + "firmware", + "fwupd", + "tpm", + "bios", + "update" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-fleet/fleet.js b/plugins/sysdeck-fleet/fleet.js new file mode 100755 index 0000000..b5fc645 --- /dev/null +++ b/plugins/sysdeck-fleet/fleet.js @@ -0,0 +1,103 @@ +/* + * SysDeck - Fleet Compute Panel (v0.0.10) + * Author: Jeremy Anderson (https://dcos.net) + * + * Subscribes to the bridge metrics tap for live CPU + memory samples. + * The tap is a cockpit.metrics channel that emits derive samples at + * the bridge's update interval; no polling required. + * + * Also surfaces the local host uptime via `uptime` and peer-host + * guidance via the cockpit multi-host dashboard contract. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + const [uptime] = await Promise.allSettled([bridge.fleet.uptime()]); + panel.innerHTML = ` +
    +

    Fleet Compute

    +

    Local host (fleet-of-one)

    +
    +
    +
    +

    Uptime

    +
    ${escapeHtml(uptime.value ?? 'unavailable')}
    +
    +
    +

    Live CPU / Memory

    +
    +
    + CPU user + — +
    +
    +
    +
    +
    + Memory used + — +
    +
    +
    +
    +
    +
    +
    +
    +

    Peer Hosts

    +
    + Multi-host dashboard not enabled. Configure /etc/cockpit/machines.d/ to surface peer hosts. +
    +
    + `; + + // Subscribe to the live metrics tap. The unsubscribe function is + // stashed on the panel so a re-mount can clean up. + if (panel._unsubscribeMetrics) panel._unsubscribeMetrics(); + try { + panel._unsubscribeMetrics = bridge.fleet.subscribeLoadAvg((samples) => { + if (!Array.isArray(samples)) return; + const [cpuUser, _cpuSys, memUsed, memTotal] = samples; + if (typeof cpuUser === 'number') { + const cpuEl = panel.querySelector('#fleet-cpu-user'); + const cpuBar = panel.querySelector('#fleet-cpu-bar'); + if (cpuEl) cpuEl.textContent = `${cpuUser.toFixed(1)}%`; + if (cpuBar) cpuBar.style.width = `${Math.min(cpuUser, 100)}%`; + } + if (typeof memUsed === 'number' && typeof memTotal === 'number' && memTotal > 0) { + const pct = (memUsed / memTotal) * 100; + const memEl = panel.querySelector('#fleet-mem-used'); + const memBar = panel.querySelector('#fleet-mem-bar'); + if (memEl) memEl.textContent = `${formatBytes(memUsed)} / ${formatBytes(memTotal)}`; + if (memBar) { + memBar.style.width = `${pct.toFixed(1)}%`; + memBar.classList.toggle('warn', pct > 75); + memBar.classList.toggle('danger', pct > 90); + } + } + }); + } catch { + // Metrics channel unavailable — bars stay at 0%. + } + + EventBus.emit('fleet.loaded'); +} + +function formatBytes(bytes) { + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); +} + +function renderSkeleton() { + return `
    `; +} diff --git a/plugins/sysdeck-fleet/index.html b/plugins/sysdeck-fleet/index.html new file mode 100755 index 0000000..ec4f4f2 --- /dev/null +++ b/plugins/sysdeck-fleet/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Fleet + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-fleet/manifest.json b/plugins/sysdeck-fleet/manifest.json new file mode 100755 index 0000000..f55bfc2 --- /dev/null +++ b/plugins/sysdeck-fleet/manifest.json @@ -0,0 +1,25 @@ +{ + "version": 0, + "name": "sysdeck-fleet", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Fleet", + "order": 26, + "keywords": [ + { + "matches": [ + "fleet", + "uptime", + "load", + "hosts", + "machines" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-glances/glances.js b/plugins/sysdeck-glances/glances.js new file mode 100755 index 0000000..43e0c47 --- /dev/null +++ b/plugins/sysdeck-glances/glances.js @@ -0,0 +1,388 @@ +/* + * SysDeck - Glances Panel (v0.0.47) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.47 — DEFAULT-ON EMBEDDED WEBUI. Per user directive: "the glances + * we should default to enabling the built in webui and embedding that + * into our module instead it visually looks stunning in comparison to + * ours." v0.0.34 added the webui integration but kept it opt-in (the + * operator had to click "Start Web UI" each time they opened the panel) + * and rendered the legacy SysDeck snapshot cards above the iframe. + * v0.0.47 flips the default: + * + * 1. AUTO-START: when the panel mounts and glances is installed but + * the webserver isn't running, the panel calls + * bridge.glances.startWeb() automatically. The operator sees a + * "Starting Glances web UI …" stub for <1s, then the full Glances + * web UI loads in the iframe. No click required. + * 2. EMBEDDED-FIRST LAYOUT: the iframe is now the primary view, + * sized to fill the viewport (min-height: calc(100vh - 200px)). + * The legacy snapshot cards (CPU / Memory / Swap / Network / Disk + * / Processes) are moved into a collapsed
    at the bottom + * of the page so they don't push the iframe below the fold. The + * operator can still expand them for a quick numeric read, but + * the default view is the Glances web UI. + * 3. STOP ON UNMOUNT (best-effort): when the panel is unmounted + * (operator navigates away), we don't stop the webserver — it's + * cheap to keep running and the operator may re-open the panel + * soon. The existing Stop button is still there for explicit + * shutdown. (If we wanted to be aggressive we could stop on + * pagehide, but that would slow re-entry.) + * + * v0.0.34 INTEGRATES THE GLANCES BUILT-IN WEB UI as a module. + * The user directive: "glances is not integrated yet i just assumed + * you would integrate the built in webui as a module." Glances ships + * a webserver via `glances -w` (default 127.0.0.1:61208) that serves + * the full Glances web UI — every chart, every sensor, every top + * process, every history graph. SysDeck starts that webserver as a + * background process via bridge.glances.startWeb() and iframes the + * running web UI into this panel. No SysDeck-side reimplementation + * of the Glances UI. + * + * Glances is GPL-3.0 licensed by Nicolargo. The bridge helper invokes + * it as a separate process via subprocess — the suite (MIT) and + * Glances (GPL-3.0) remain independent programs. No Glances code is + * bundled. + */ + +// v0.0.47: how long to wait between calling startWeb() and re-checking +// web-status. Glances typically takes <1s on a warm start, but we give +// it a small grace period before re-rendering so the iframe doesn't +// load a half-up webserver. +const WEB_START_POLL_MS = 800; + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + let webStatus = await safe(bridge.glances.webStatus(), {}); + let snapshot = await safe(bridge.glances.snapshot(), {}); + + // v0.0.47: AUTO-START. If glances is installed and the webserver + // isn't running, start it automatically. The operator gets the + // full Glances web UI on first paint instead of an empty iframe + // and a "click here" prompt. + if (webStatus?.available !== false && !webStatus?.running) { + panel.innerHTML = renderStartingCard(webStatus); + try { + const startResp = await bridge.glances.startWeb(); + if (startResp?.started || startResp?.already_running) { + // Poll web-status after a short grace period so the + // webserver has time to bind the socket. + await new Promise((r) => setTimeout(r, WEB_START_POLL_MS)); + webStatus = await safe(bridge.glances.webStatus(), webStatus); + // Re-fetch the snapshot too — it's now backed by the + // running webserver's data. + snapshot = await safe(bridge.glances.snapshot(), snapshot); + } else if (startResp?.error) { + console.warn('glances.startWeb returned error:', startResp.error); + } + } catch (err) { + console.warn('glances.startWeb threw:', err); + } + } + + const webRunning = webStatus?.running === true; + const webUrl = webStatus?.url || 'http://127.0.0.1:61208'; + const glancesAvailable = webStatus?.available !== false; + + panel.innerHTML = ` +
    +

    System Monitor

    +

    + Glances — real-time system overview + · GPL-3.0 · Nicolargo + ${webRunning + ? ` · web UI running` + : (glancesAvailable + ? ` · web UI stopped` + : ` · glances not installed`)} + ${webRunning && webStatus?.pid ? ` · PID ${webStatus.pid}` : ''} +

    +
    + + ${renderWebControls(webStatus, webRunning, webUrl)} + + ${webRunning ? renderWebIframe(webUrl) : ''} + + ${!webRunning && glancesAvailable ? renderStartPrompt(webUrl) : ''} + + ${glancesAvailable ? renderLegacySnapshotDetails(snapshot) : ''} + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('glances.loaded', { webRunning, autoStarted: webRunning }); +} + +// ── Skeleton + starting states ────────────────────────────────────── + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} + +function renderStartingCard(webStatus) { + // v0.0.47: shown while we're auto-starting the webserver. + const url = webStatus?.url || 'http://127.0.0.1:61208'; + return ` +
    +

    System Monitor

    +

    + Glances — real-time system overview + · GPL-3.0 · Nicolargo + · starting web UI… +

    +
    +
    +
    +

    Starting Glances web UI…

    +
    +
    +

    + v0.0.47 auto-start: the panel is launching the + built-in Glances webserver at + ${escapeHtml(url)} via + glances -w --bind 127.0.0.1 --port 61208. + The full web UI will appear here in a moment — every + chart, every sensor, every top process, every history + graph, without SysDeck re-implementing any of it. +

    +

    + If this takes more than a few seconds, click + ↻ Refresh or check the cockpit bridge + log. The first start may be slow if glances needs to + warm its import cache. +

    +
    +
    + `; +} + +// ── Web UI controls ───────────────────────────────────────────────── + +function renderWebControls(webStatus, webRunning, webUrl) { + if (webStatus?.available === false) { + return ` +
    + `; + } + return ` +
    +
    +

    Glances Web UI

    +
    + + + +
    +
    +
    +

    + ${webRunning + ? `Running at ${escapeHtml(webUrl)}${webStatus.pid ? ` (PID ${webStatus.pid})` : ''}. The iframe below loads the full Glances web UI — every chart, every sensor, every top process, every history graph. No SysDeck-side reimplementation. v0.0.47 auto-starts this on panel mount; click Stop to disable.` + : `Stopped. v0.0.47 default is auto-start on panel mount — click ▶ Start Web UI to launch it manually, or ↻ Refresh to re-trigger the auto-start. The bridge runs glances -w --bind 127.0.0.1 --port 61208 as a background process via the cockpit superuser channel.`} +

    +
    +
    + `; +} + +function renderWebIframe(webUrl) { + // v0.0.47: iframe is now the primary view — sized to fill the + // viewport. min-height uses calc(100vh - 200px) so the iframe + // extends to just above the page footer, leaving room for the + // controls card above and the legacy snapshot
    below. + return ` +
    +
    +

    Web UI — ${escapeHtml(webUrl)}

    + ↗ Open in new tab +
    + +
    + `; +} + +function renderStartPrompt(webUrl) { + // Shown when the webserver isn't running and didn't auto-start + // (e.g. glances is installed but startWeb returned an error). + return ` +
    +
    +

    Web UI not running

    +
    +
    +

    + The auto-start didn't bring up the Glances webserver. + Click ▶ Start Web UI above to try + again, or ↻ Refresh to re-run the + auto-start sequence. Expected URL: + ${escapeHtml(webUrl)}. +

    +
    +
    + `; +} + +// ── Legacy snapshot (collapsed
    ) ─────────────────────────── + +function renderLegacySnapshotDetails(snapshot) { + // v0.0.47: the snapshot cards are kept (they're a useful numeric + // read) but moved into a collapsed
    so the iframe is the + // primary view. The operator can expand for the SysDeck-rendered + // CPU/Memory/Swap/Network/Disk/Processes summary. + return ` +
    + +

    Legacy SysDeck Snapshot (collapsed — iframe above is the primary view)

    +
    +
    + ${renderSnapshotCards(snapshot)} +
    +
    + `; +} + +function renderSnapshotCards(snapshot) { + const cpu = snapshot.cpu || {}; + const mem = snapshot.mem || {}; + const swap = snapshot.memswap || {}; + const network = snapshot.network || {}; + const fs = snapshot.fs || {}; + const procs = snapshot.processcount || {}; + return ` +
    +
    +

    CPU

    +
    ${(cpu.total || 0).toFixed(1)}%
    +
    user: ${(cpu.user || 0).toFixed(1)}% · system: ${(cpu.system || 0).toFixed(1)}% · idle: ${(cpu.idle || 0).toFixed(1)}%
    +
    +
    +
    +

    Memory

    +
    ${(mem.percent || 0).toFixed(1)}%
    +
    ${fmtMB(mem.used || 0)} / ${fmtMB(mem.total || 0)}
    +
    +
    +
    +

    Swap

    +
    ${(swap.percent || 0).toFixed(1)}%
    +
    ${fmtMB(swap.used || 0)} / ${fmtMB(swap.total || 0)}
    +
    +
    +
    +
    +
    +

    Network Interfaces

    +
    + + + + ${Object.entries(network).map(([iface, n]) => ` + + + + + + `).join('') || ''} + +
    InterfaceRx/sTx/sRx ErrTx Err
    ${escapeHtml(iface)}${fmtKB(n.rx || 0)}/s${fmtKB(n.tx || 0)}/s${n.rx_errors || 0}${n.tx_errors || 0}
    No network data.
    +
    +
    +
    +

    Disk I/O

    +
    + + + + ${Object.entries(fs).map(([dev, d]) => ` + + + + `).join('') || ''} + +
    DeviceRead/sWrite/s
    ${escapeHtml(dev)}${fmtKB(d.r_bytes_ps || 0)}/s${fmtKB(d.w_bytes_ps || 0)}/s
    No disk data.
    +
    +
    +

    Processes: ${procs.total || 0} total · ${procs.running || 0} running · ${procs.sleeping || 0} sleeping · ${procs.thread || 0} threads

    +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + panel.querySelector('#btn-glances-start-web')?.addEventListener('click', async () => { + const btn = panel.querySelector('#btn-glances-start-web'); + if (btn) { btn.disabled = true; btn.textContent = 'Starting ...'; } + try { + const r = await bridge.glances.startWeb(); + if (r.started || r.already_running) { + // Give the webserver a moment to bind before re-mount. + await new Promise((res) => setTimeout(res, WEB_START_POLL_MS)); + mount(panel, { bridge, EventBus }); + } else { + if (btn) { btn.disabled = false; btn.textContent = '▶ Start Web UI'; } + alert(`Failed to start Glances web UI:\n${r.error || r.reason || 'unknown'}`); + } + } catch (err) { + if (btn) { btn.disabled = false; btn.textContent = '▶ Start Web UI'; } + alert(`Start error: ${err.message || err}`); + } + }); + + panel.querySelector('#btn-glances-stop-web')?.addEventListener('click', async () => { + const btn = panel.querySelector('#btn-glances-stop-web'); + if (btn) { btn.disabled = true; } + try { + await bridge.glances.stopWeb(); + setTimeout(() => mount(panel, { bridge, EventBus }), 500); + } catch (err) { + if (btn) { btn.disabled = false; } + alert(`Stop error: ${err.message || err}`); + } + }); + + panel.querySelector('#btn-glances-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function fmtMB(kb) { return (kb / 1024).toFixed(1) + ' MB'; } +function fmtKB(bytes) { return (bytes / 1024).toFixed(1) + ' KB'; } + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/plugins/sysdeck-glances/index.html b/plugins/sysdeck-glances/index.html new file mode 100755 index 0000000..97f95f8 --- /dev/null +++ b/plugins/sysdeck-glances/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Glances + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-glances/manifest.json b/plugins/sysdeck-glances/manifest.json new file mode 100755 index 0000000..410596f --- /dev/null +++ b/plugins/sysdeck-glances/manifest.json @@ -0,0 +1,31 @@ +{ + "version": 0, + "name": "sysdeck-glances", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Glances", + "order": 34, + "keywords": [ + { + "matches": [ + "glances", + "monitoring", + "cpu", + "memory", + "disk", + "network", + "webui", + "web ui", + "embed", + "iframe", + "real-time" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; frame-src 'self' http://127.0.0.1:61208 http://localhost:61208" +} diff --git a/plugins/sysdeck-integrity/index.html b/plugins/sysdeck-integrity/index.html new file mode 100755 index 0000000..7a01088 --- /dev/null +++ b/plugins/sysdeck-integrity/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Integrity + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-integrity/integrity.js b/plugins/sysdeck-integrity/integrity.js new file mode 100755 index 0000000..f4371b9 --- /dev/null +++ b/plugins/sysdeck-integrity/integrity.js @@ -0,0 +1,100 @@ +/* + * SysDeck - Integrity Panel (v0.0.10) + * Author: Jeremy Anderson (https://dcos.net) + * + * Calls lynis audit system via cockpit.spawn. Falls back gracefully + * when lynis is absent — the trust score becomes null and the panel + * shows an install hint. + * + * Uses the systemd dbus proxy to surface the integrity-scanner service + * state without polling. The proxy fires 'changed' when the unit state + * transitions; the panel re-fetches the trust score on that signal. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + const trust = await bridge.integrity.trustScore(); + + panel.innerHTML = ` +
    +

    Integrity Auditor

    +

    Lynis system audit

    +
    +
    +
    +

    Trust Score

    +
    + ${trust !== null + ? `
    ${trust}/100
    ` + : '

    Lynis not installed.

    '} +
    +
    +
    +

    Quick Actions

    +
    + +
    +
    +
    +

    Scanner Status

    +
    +
    lynis${trust !== null ? 'ready' : 'missing'}
    +
    rkhunterdeferred
    +
    chkrootkitdeferred
    +
    +
    +
    + `; + + const runBtn = panel.querySelector('#btn-run-lynis'); + if (runBtn) { + runBtn.addEventListener('click', async () => { + runBtn.disabled = true; + runBtn.textContent = 'Running audit…'; + try { + await bridge.integrity.runLynis(); + EventBus.emit('integrity.scan.complete'); + mount(panel, { bridge, EventBus }); + } catch (err) { + runBtn.textContent = 'Run Full Audit'; + runBtn.disabled = false; + EventBus.emit('integrity.scan.error', { error: err.message }); + } + }); + } + + // Subscribe to lynis.service state changes via the systemd dbus proxy. + // On any transition, re-fetch the trust score so the panel reflects + // the most recent audit result without manual refresh. + if (panel._unsubscribeUnit) panel._unsubscribeUnit(); + try { + if (bridge.dbusProxies?.systemd) { + panel._unsubscribeUnit = bridge.dbusProxies.systemd.subscribeToUnit( + 'lynis.service', + () => mount(panel, { bridge, EventBus }), + ); + } + } catch { + // systemd proxy unavailable — manual refresh still works. + } +} + +const SCORE_COLORS = [ + { min: 90, color: 'var(--suite-accent-success)' }, + { min: 70, color: 'var(--suite-accent-warn)' }, + { min: 0, color: 'var(--suite-accent-danger)' }, +]; + +function scoreColor(score) { + // Step-down: lookup table over if-ladder. First match wins. + const entry = SCORE_COLORS.find((band) => score >= band.min); + return entry?.color ?? 'var(--suite-accent-danger)'; +} + +function renderSkeleton() { + return `
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-integrity/manifest.json b/plugins/sysdeck-integrity/manifest.json new file mode 100755 index 0000000..79f4278 --- /dev/null +++ b/plugins/sysdeck-integrity/manifest.json @@ -0,0 +1,25 @@ +{ + "version": 0, + "name": "sysdeck-integrity", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Integrity", + "order": 22, + "keywords": [ + { + "matches": [ + "integrity", + "lynis", + "audit", + "hardening", + "trust" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-jellyfin/index.html b/plugins/sysdeck-jellyfin/index.html new file mode 100755 index 0000000..ae0d967 --- /dev/null +++ b/plugins/sysdeck-jellyfin/index.html @@ -0,0 +1,64 @@ + + + + + SysDeck Jellyfin + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-jellyfin/jellyfin.js b/plugins/sysdeck-jellyfin/jellyfin.js new file mode 100755 index 0000000..66eb8f3 --- /dev/null +++ b/plugins/sysdeck-jellyfin/jellyfin.js @@ -0,0 +1,254 @@ +/* + * SysDeck - Jellyfin Panel (v0.0.35) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.35 directive: "next we will integrate a jellyfin management + * module where it starts, stops, and loads the admin panel in the + * module." Jellyfin ships a single systemd unit (jellyfin.service) + * and serves a full admin web UI on http://127.0.0.1:8096. The + * bridge starts/stops/restarts the service via systemctl; the panel + * iframes the running admin UI — same pattern as the v0.0.34 Glances + * integration. + * + * The panel surfaces: + * - Service summary card: status badge, version, port, uptime + * - Service controls: Start / Stop / Restart (polkit prompts) + * - Admin UI iframe: loads http://127.0.0.1:8096 when running + * - Library list: best-effort GET /Library/VirtualFolders + * - Install hint when jellyfin is not installed + * + * Jellyfin is GPL-2.0 licensed by the Jellyfin contributors. The + * bridge helper invokes it as a separate process via subprocess — + * the suite (MIT) and Jellyfin (GPL-2.0) remain independent + * programs. No Jellyfin code is bundled. + * + * Bridge surface (see shared/bridge.js → bridge.jellyfin): + * summary() → {available, status, version, port, url, ...} + * status() → service state dict + * start() → {action, rc, success, output, stderr} + * stop() → same shape + * restart() → same shape + * webStatus() → {running, url, port, ...} + * libraries() → {libraries: [...], count: N} + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + const [summary, webStatus, libraries] = await Promise.all([ + safe(bridge.jellyfin.summary(), {}), + safe(bridge.jellyfin.webStatus(), {}), + safe(bridge.jellyfin.libraries(), { libraries: [], count: 0 }), + ]); + + const running = webStatus?.running === true || summary?.status === 'running'; + const available = webStatus?.available !== false && summary?.available !== false; + const webUrl = webStatus?.url || summary?.url || 'http://127.0.0.1:8096'; + + panel.innerHTML = ` +
    +

    Jellyfin Media Server

    +

    + Self-hosted media streaming · movies · TV · music + · GPL-2.0 · Jellyfin contributors + ${available + ? (running + ? ` · running` + : ` · stopped`) + : ` · not installed`} +

    +
    + + ${renderServiceCard(summary, available, running, webUrl)} + + ${available && running ? renderWebIframe(webUrl) : ''} + + ${available ? renderLibrariesCard(libraries) : ''} + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('jellyfin.loaded', { running, available }); +} + +// ── Service card ──────────────────────────────────────────────────── + +function renderServiceCard(summary, available, running, webUrl) { + if (!available) { + return ` +
    +

    Jellyfin Service

    +
    +

    + ${escapeHtml(summary?.reason || webStatus?.reason || 'Jellyfin is not installed.')} +

    + ${(summary?.install || webStatus?.install) + ? `

    ${escapeHtml(summary?.install || webStatus?.install)}

    ` + : ''} +

    + Jellyfin is GPL-2.0 licensed by the Jellyfin contributors — + https://jellyfin.org/ +

    +
    +
    + `; + } + + const uptime = formatUptime(summary?.uptime_seconds || 0); + return ` +
    +
    +

    Service — ${escapeHtml(summary?.service || 'jellyfin.service')}

    +
    + + + + +
    +
    +
    + + + + + + + + + + +
    Status${statusBadge(summary?.status)}
    ActiveState${escapeHtml(summary?.active || '—')}
    SubState${escapeHtml(summary?.sub || '—')}
    Uptime${running ? escapeHtml(uptime) : '—'}
    Version${escapeHtml(summary?.version || '—')}
    Port${summary?.port || 8096}
    URL${escapeHtml(webUrl)}
    +

    + The bridge runs systemctl start/stop/restart jellyfin.service + via the cockpit superuser channel (polkit org.sysdeck.jellyfin.modify). +

    +
    +
    + `; +} + +function renderWebIframe(webUrl) { + return ` +
    +
    +

    Admin Panel — ${escapeHtml(webUrl)}

    + ↗ Open in new tab +
    + +
    + `; +} + +function renderLibrariesCard(libraries) { + const libs = libraries?.libraries || []; + if (libraries?.error) { + return ` +
    +

    Libraries

    +
    +

    ${escapeHtml(libraries.error)}

    +
    +
    + `; + } + return ` +
    +
    +

    Libraries (${libraries?.count || 0})

    +
    + + + + ${libs.map((lib) => ` + + + + + + `).join('') || ''} + +
    NameTypePaths
    ${escapeHtml(lib.name)}${escapeHtml(lib.type || 'mixed')}${escapeHtml((lib.paths || []).join(' · ') || '—')}
    No libraries configured. Open the admin panel above to add media folders.
    +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const action = async (btn, method, label) => { + if (!btn) return; + btn.disabled = true; + const original = btn.textContent; + btn.textContent = `${label} ...`; + try { + const r = await bridge.jellyfin[method](); + if (!r?.success && method !== 'restart') { + alert(`${label} failed:\n${r?.stderr || r?.output || 'unknown'}`); + } + setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { + btn.disabled = false; + btn.textContent = original; + alert(`${label} error: ${err.message || err}`); + } + }; + + panel.querySelector('#btn-jellyfin-start')?.addEventListener('click', (ev) => action(ev.currentTarget, 'start', 'Start')); + panel.querySelector('#btn-jellyfin-stop')?.addEventListener('click', (ev) => action(ev.currentTarget, 'stop', 'Stop')); + panel.querySelector('#btn-jellyfin-restart')?.addEventListener('click', (ev) => action(ev.currentTarget, 'restart', 'Restart')); + panel.querySelector('#btn-jellyfin-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function statusBadge(status) { + const map = { + 'running': 'running', + 'starting': 'starting', + 'stopped': 'stopped', + 'error': 'error', + 'unknown': 'unknown', + }; + return map[status] || `${escapeHtml(status || 'unknown')}`; +} + +function formatUptime(seconds) { + if (!seconds || seconds <= 0) return '—'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h ${mins}m`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-jellyfin/manifest.json b/plugins/sysdeck-jellyfin/manifest.json new file mode 100755 index 0000000..f5b7535 --- /dev/null +++ b/plugins/sysdeck-jellyfin/manifest.json @@ -0,0 +1,30 @@ +{ + "version": 0, + "name": "sysdeck-jellyfin", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Jellyfin", + "order": 40, + "keywords": [ + { + "matches": [ + "jellyfin", + "media", + "server", + "movies", + "tv", + "music", + "streaming", + "transcoding", + "subsonic", + "emby" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval' frame-src 'self' http://127.0.0.1:8096 http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:8096 http://127.0.0.1:*; img-src 'self' data: http://127.0.0.1:8096 http://127.0.0.1:*" +} diff --git a/plugins/sysdeck-kata/index.html b/plugins/sysdeck-kata/index.html new file mode 100755 index 0000000..967c15b --- /dev/null +++ b/plugins/sysdeck-kata/index.html @@ -0,0 +1,63 @@ + + + + + SysDeck Kata + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-kata/kata.js b/plugins/sysdeck-kata/kata.js new file mode 100755 index 0000000..c158d0e --- /dev/null +++ b/plugins/sysdeck-kata/kata.js @@ -0,0 +1,423 @@ +/* + * SysDeck - Kata Panel (v0.0.43) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.43 PRODUCTION REWRITE — PREVIOUS VERSION WAS MOCK DATA. + * + * The v0.0.35-v0.0.43 Kata panel shipped a pre-built React bundle + * from the upstream cockpit-kata sub-project. That bundle displayed + * HARDCODED MOCK DATA: + * - 5 fake sandboxes (web-frontend-prod, api-gateway-staging, etc.) + * with synthetic UUIDs and createdAt:"2026-07-15..." timestamps + * - fake per-sandbox metrics (cpuUsagePercent, memoryUsageMB, + * historyCpu/historyMemory arrays) + * - a fake QCrows bundle catalog + * - a fake PXE status (always dnsmasqRunning:true) + * The only real features were the QCrows kernel-bundle extraction + * (qcrows-export / qcrows-initrd-regen via cockpit.spawn) and the + * kata-runtime check call. + * + * v0.0.43 deletes the React bundle and ships this vanilla-JS panel + * backed by bridge/kata.py. Every value displayed is REAL: + * - Sandbox list comes from kata-monitor /sandboxes + filesystem + * enumeration of /run/vc/sbs/ (Go shim) + /run/kata/ (Rust shim). + * - Per-sandbox metrics come from kata-monitor /metrics?sandbox= + * (Prometheus text, parsed). + * - Runtime version comes from `kata-runtime version` + `kata-runtime + * env --json`. + * - Host capability comes from `kata-runtime check` (exit code). + * - PXE status comes from `systemctl is-active dnsmasq` + real + * filesystem probes of /srv/tftp/. + * - QCrows bundle list comes from real filesystem enumeration of + * /usr/share/sysdeck/kata/qcrows/. + * + * When no sandboxes are running, the panel shows an EMPTY STATE + * (not mock data). When kata-runtime is not installed, the panel + * shows an install hint. When kata-monitor is not running, the + * metrics card shows a hint to start it. + * + * Security hardening (v0.0.36 + v0.0.43): + * - Sandbox IDs validated with ^[0-9a-f]{64}$ in the bridge before + * any subprocess or HTTP call. CVE-2024-2947 lesson. + * - All bridge output rendered with escapeHtml() / textContent. + * CVE-2022-36446 lesson. + * - No innerHTML on bridge data. + * - HTTP to kata-monitor is 127.0.0.1-only, no redirects (SSRF + * defense). CVE-2020-35850 lesson. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + // Load summary + pxe-status + qcrows-list in parallel. + const [summary, pxeStatus, qcrowsList] = await Promise.all([ + safe(bridge.kata.summary(), { + total_sandboxes: 0, running_sandboxes: 0, sandboxes: [], + kata_monitor: { running: false }, kata_runtime: { installed: false }, + host_capable: false, check_message: 'unknown', + }), + safe(bridge.kata.pxeStatus(), { + dnsmasq_running: false, tftp_dir_exists: false, + tftp_dir_writable: false, pxelinux_entries: [], + }), + safe(bridge.kata.qcrowsList(), []), + ]); + + panel.innerHTML = ` +
    +

    SysDeck Kata

    +

    + Kata Containers — hardware-virtualized OCI sandboxes + · ${summary.host_capable ? 'host capable' : 'host not capable'} + · ${summary.total_sandboxes} sandbox${summary.total_sandboxes === 1 ? '' : 'es'} + ${summary.running_sandboxes !== summary.total_sandboxes ? ` (${summary.running_sandboxes} running)` : ''} + ${summary.kata_runtime?.installed ? ` · kata-runtime ${escapeHtml(summary.kata_runtime.version || '?')}` : ' · kata-runtime not installed'} + ${summary.kata_monitor?.running ? ' · kata-monitor running' : ' · kata-monitor not running'} +

    +
    + + ${renderRuntimeCard(summary)} + + ${renderSandboxList(summary.sandboxes, summary.kata_monitor)} + + ${renderPxeCard(pxeStatus)} + + ${renderQcrowsCard(qcrowsList)} + + + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('kata.loaded', { + totalSandboxes: summary.total_sandboxes, + runningSandboxes: summary.running_sandboxes, + hostCapable: summary.host_capable, + }); +} + +// ── Render helpers ────────────────────────────────────────────────── + +function renderRuntimeCard(summary) { + const rt = summary.kata_runtime || {}; + if (!rt.installed) { + return ` +
    +
    +

    Runtime

    +
    +
    +

    + kata-runtime is not installed. Install Kata Containers 3.x: +

    +
    # Arch (AUR):
    +yay -S kata-runtime kata-containers-image
    +
    +# Debian/Ubuntu (official repo):
    +sudo apt install kata-runtime kata-containers-image
    +
    +# Or build from source (you mentioned compiling yesterday):
    +# https://github.com/kata-containers/kata-containers/blob/main/docs/install/
    +

    + After install, run kata-runtime check to verify + host capability (nested virt, KVM, etc.). +

    +
    +
    + `; + } + const env = rt.env || {}; + const host = env.Host || {}; + const hypervisor = env.Hypervisor || {}; + return ` +
    +
    +

    Runtime

    + +
    +
    + + + + + + + + ${host.Kernel ? `` : ''} + ${host.Architecture ? `` : ''} + ${hypervisor.Path ? `` : ''} + ${hypervisor.MachineType ? `` : ''} + +
    kata-runtime version${escapeHtml(rt.version || '?')}
    commit${escapeHtml(rt.commit || '?')}
    OCI specs${escapeHtml(rt.oci || '?')}
    host capable${summary.host_capable ? 'yes' : 'no'}
    check message${escapeHtml(summary.check_message || '')}
    host kernel${escapeHtml(host.Kernel)}
    architecture${escapeHtml(host.Architecture)}
    hypervisor${escapeHtml(hypervisor.Path)}
    machine type${escapeHtml(hypervisor.MachineType)}
    +
    +
    + `; +} + +function renderSandboxList(sandboxes, kataMonitor) { + if (!sandboxes || !sandboxes.length) { + return ` +
    +
    +

    Sandboxes (0)

    +
    +
    +

    + No kata sandboxes running. This is the real empty state — + not mock data. Sandboxes are enumerated from: +

    +
      +
    • kata-monitor /sandboxes (HTTP, port 8090)${kataMonitor?.running ? ' ✓ running' : ' — not running'}
    • +
    • /run/vc/sbs/<id>/ (Go shim filesystem)
    • +
    • /run/kata/<id>/ (Rust shim filesystem)
    • +
    +

    + To create a sandbox, use ctr, crictl, + or kubectl with RuntimeClass kata. +

    +
    +
    + `; + } + const rows = sandboxes.map((sb) => { + const idShort = sb.id.substring(0, 12); + const isRunning = sb.agent_url || sb.shim_socket; + return ` + + ${escapeHtml(idShort)}… + ${isRunning ? 'running' : 'unknown'} + ${escapeHtml(sb.source || '?')} + ${sb.agent_url ? escapeHtml(sb.agent_url) : '—'} + + + + + + `; + }).join(''); + return ` +
    +
    +

    Sandboxes (${sandboxes.length})

    + +
    + + + + + ${rows} +
    IDStatusSourceAgent URLActions
    +
    + + + `; +} + +function renderPxeCard(pxe) { + const dnsmasqBadge = pxe.dnsmasq_running + ? 'running' + : 'not running'; + const tftpExistsBadge = pxe.tftp_dir_exists + ? 'exists' + : 'missing'; + const tftpWritableBadge = pxe.tftp_dir_writable + ? 'writable' + : 'not writable'; + const entries = (pxe.pxelinux_entries || []).length + ? pxe.pxelinux_entries.map((e) => `${escapeHtml(e)}`).join('') + : '(no entries)'; + return ` +
    +
    +

    PXE / TFTP Boot

    + +
    +
    + + + + + + +
    dnsmasq${dnsmasqBadge}
    /srv/tftp${tftpExistsBadge} ${tftpWritableBadge}
    pxelinux.cfg/ entries${entries}
    +

    + PXE boot configuration for network-booting kata sandboxes. + dnsmasq serves DHCP + TFTP; pxelinux.cfg/ holds per-host + boot configs (named by MAC address or "default"). +

    +
    +
    + `; +} + +function renderQcrowsCard(qcrows) { + if (!qcrows || !qcrows.length) { + return ` +
    +
    +

    QCrows Kernel Bundles (0)

    +
    +
    +

    + No QCrows kernel bundles found at + /usr/share/sysdeck/kata/qcrows/. + This is the real empty state — not mock data. +

    +

    + QCrows bundles are pre-built kata kernel + initrd + + rootfs images. Build one with: +

    +
    qcrows-export --kernel /path/to/vmlinuz --initrd /path/to/initrd \\
    +  --rootfs /path/to/rootfs --name alpine-3.20-kata
    +
    +
    + `; + } + const totalSize = qcrows.reduce((sum, q) => sum + (q.size_bytes || 0), 0); + const totalMb = (totalSize / (1024 * 1024)).toFixed(1); + const rows = qcrows.map((q) => ` + + ${escapeHtml(q.filename)} + ${q.size_mb} MB + ${new Date(q.mtime * 1000).toISOString().split('T')[0]} + + `).join(''); + return ` +
    +
    +

    QCrows Kernel Bundles (${qcrows.length}, ${totalMb} MB total)

    +
    + + + ${rows} +
    FilenameSizeModified
    +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const output = (msg, isError = false) => { + const card = panel.querySelector('#kata-output'); + const pre = panel.querySelector('#kata-output-pre'); + if (!card || !pre) return; + card.style.display = 'block'; + pre.textContent = msg; + pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + panel.querySelector('#btn-kata-output-close')?.addEventListener('click', () => { + const card = panel.querySelector('#kata-output'); + if (card) card.style.display = 'none'; + }); + + // Refresh buttons (multiple — runtime card + sandbox card). + panel.querySelectorAll('#btn-kata-refresh, #btn-kata-pxe-refresh').forEach((btn) => { + btn.addEventListener('click', () => mount(panel, { bridge, EventBus })); + }); + + // Inspect buttons. + panel.querySelectorAll('.btn-kata-inspect').forEach((btn) => { + btn.addEventListener('click', async () => { + const sid = btn.dataset.id; + const card = panel.querySelector('#kata-inspect-card'); + const pre = panel.querySelector('#kata-inspect-pre'); + if (!card || !pre) return; + card.style.display = 'block'; + pre.textContent = `Inspecting ${sid.substring(0, 12)}…`; + try { + const r = await bridge.kata.inspect(sid); + pre.textContent = JSON.stringify(r, null, 2); + } catch (err) { + pre.textContent = `Inspect error: ${err.message || err}`; + } + }); + }); + panel.querySelector('#btn-kata-inspect-close')?.addEventListener('click', () => { + const card = panel.querySelector('#kata-inspect-card'); + if (card) card.style.display = 'none'; + }); + + // Metrics buttons. + panel.querySelectorAll('.btn-kata-metrics').forEach((btn) => { + btn.addEventListener('click', async () => { + const sid = btn.dataset.id; + const card = panel.querySelector('#kata-metrics-card'); + const pre = panel.querySelector('#kata-metrics-pre'); + if (!card || !pre) return; + card.style.display = 'block'; + pre.textContent = `Fetching metrics for ${sid.substring(0, 12)}…`; + try { + const r = await bridge.kata.metrics(sid); + if (r.error) { + pre.textContent = `Metrics error: ${r.error}`; + } else if (r.parsed && r.summary) { + const s = r.summary; + pre.textContent = [ + `Sandbox: ${r.id}`, + `CPU: ${s.cpu_usage_percent ?? 'n/a'}%`, + `Memory: ${s.memory_usage_bytes != null ? (s.memory_usage_bytes / 1048576).toFixed(1) + ' MB' : 'n/a'}`, + `Network RX: ${s.network_rx_bytes ?? 'n/a'} bytes`, + `Network TX: ${s.network_tx_bytes ?? 'n/a'} bytes`, + `Uptime: ${s.uptime_seconds ?? 'n/a'} s`, + '', + '--- Raw metric families ---', + JSON.stringify(r.families, null, 2), + ].join('\n'); + } else { + pre.textContent = r.raw || '(no metrics — kata-monitor not running or sandbox not found)'; + } + } catch (err) { + pre.textContent = `Metrics error: ${err.message || err}`; + } + }); + }); + panel.querySelector('#btn-kata-metrics-close')?.addEventListener('click', () => { + const card = panel.querySelector('#kata-metrics-card'); + if (card) card.style.display = 'none'; + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-kata/manifest.json b/plugins/sysdeck-kata/manifest.json new file mode 100755 index 0000000..e097d74 --- /dev/null +++ b/plugins/sysdeck-kata/manifest.json @@ -0,0 +1,36 @@ +{ + "version": 0, + "name": "sysdeck-kata", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Kata", + "order": 27, + "keywords": [ + { + "matches": [ + "kata", + "containers", + "sandbox", + "vm", + "isolation", + "kata-runtime", + "kata-containers", + "kata-monitor", + "microvm", + "hardware-virtualization", + "qcrows", + "pxe", + "tftp", + "cloud-hypervisor", + "firecracker", + "qemu" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-mesh/index.html b/plugins/sysdeck-mesh/index.html new file mode 100755 index 0000000..43c52e4 --- /dev/null +++ b/plugins/sysdeck-mesh/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Service Mesh + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-mesh/manifest.json b/plugins/sysdeck-mesh/manifest.json new file mode 100755 index 0000000..b0d4576 --- /dev/null +++ b/plugins/sysdeck-mesh/manifest.json @@ -0,0 +1,25 @@ +{ + "version": 0, + "name": "sysdeck-mesh", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Service Mesh", + "order": 24, + "keywords": [ + { + "matches": [ + "mesh", + "kubernetes", + "kubectl", + "services", + "k8s" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-mesh/mesh.js b/plugins/sysdeck-mesh/mesh.js new file mode 100755 index 0000000..5db9e44 --- /dev/null +++ b/plugins/sysdeck-mesh/mesh.js @@ -0,0 +1,41 @@ +/* + * SysDeck - Service Mesh Panel + * Author: Jeremy Anderson (https://dcos.net) + * + * Queries Kubernetes services via `kubectl get svc -A -o json`. Renders + * the service list with namespaces. Falls back to a hint card when + * kubectl is absent or the cluster is unreachable. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + const data = await bridge.mesh.services(); + const items = data?.items ?? []; + panel.innerHTML = ` +
    +

    Service Mesh

    +

    ${items.length} Kubernetes services

    +
    +
    + + + + ${items.map((svc) => ` + + + + + + + + `).join('') || ''} + +
    NamespaceServiceTypeCluster IPPorts
    ${svc.metadata.namespace}${svc.metadata.name}${svc.spec.type ?? '—'}${svc.spec.clusterIP ?? '—'}${(svc.spec.ports ?? []).map((p) => `${p.port}/${p.protocol}`).join(', ') || '—'}
    No services. Confirm kubectl is installed and kubeconfig is reachable.
    +
    + `; + EventBus.emit('mesh.loaded', { serviceCount: items.length }); +} + +function renderSkeleton() { + return `
    `; +} diff --git a/plugins/sysdeck-mining/index.html b/plugins/sysdeck-mining/index.html new file mode 100755 index 0000000..d63f53d --- /dev/null +++ b/plugins/sysdeck-mining/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Mining + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-mining/manifest.json b/plugins/sysdeck-mining/manifest.json new file mode 100755 index 0000000..0b5e547 --- /dev/null +++ b/plugins/sysdeck-mining/manifest.json @@ -0,0 +1,25 @@ +{ + "version": 0, + "name": "sysdeck-mining", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Mining", + "order": 31, + "keywords": [ + { + "matches": [ + "mining", + "xmrig", + "monero", + "hashrate", + "worker" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-mining/mining.js b/plugins/sysdeck-mining/mining.js new file mode 100755 index 0000000..797539d --- /dev/null +++ b/plugins/sysdeck-mining/mining.js @@ -0,0 +1,477 @@ +/* + * SysDeck - Mining Dashboard Panel (v0.0.34) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive: + * "themes and mining they need to be expanded for maximum ui + * control. think 1999 power tool style here." The Mining Dashboard + * panel surfaces every XMRig REST API knob: + * + * - Summary stats total hashrate, pool URL, uptime. + * - Per-thread hashrate table of thread index → hashrate, with + * pause/resume buttons per worker. + * - Pool configuration form to set pool URL / username / + * password via PUT /1/config. + * - Thread configuration number input for thread count, write via + * PUT /1/config. + * - Algorithm picker select dropdown with 7 algorithm presets + * (RandomX, RandomWOW, RandomARQ, etc.) + * - Service controls start / stop / restart xmrig.service via + * systemctl under the cockpit superuser + * channel (polkit org.sysdeck.system.modify). + * - Pause / resume all instant pause/resume of all workers + * via XMRig JSON-RPC. + * + * Mutating ops run via the cockpit superuser channel. No `sudo` + * shell-out from JS. XMRig is GPL-3.0 licensed by the XMRig project. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + // Fire all the read-only calls in parallel. + const [summary, threads, poolCfg, threadsCfg, algoCfg, svcStatus] = await Promise.all([ + safe(bridge.mining.workers(), null), + safe(bridge.mining.threads(), {}), + safe(bridge.mining.poolConfigGet(), {}), + safe(bridge.mining.threadsConfigGet(), {}), + safe(bridge.mining.algorithmGet(), {}), + safe(bridge.mining.serviceStatus(), {}), + ]); + + if (!summary) { + panel.innerHTML = renderXmrigUnreachable(svcStatus); + wireServiceControls(panel, { bridge, EventBus }); + return; + } + + const hashrate = summary.hashrate?.total?.[0] ?? 0; + const poolUrl = summary.pool?.url ?? '—'; + const uptime = summary.uptime ?? 0; + panel.innerHTML = ` +
    +

    Mining Dashboard

    +

    + XMRig v${escapeHtml(summary.version ?? '—')} + · ${escapeHtml(svcStatus?.state || '?')} + · ${threads.thread_count ?? 0} threads + · ${formatHashrate(hashrate)} +

    +
    + + ${renderSummaryStats(hashrate, poolUrl, uptime, summary)} + + ${renderServiceControls(svcStatus)} + + ${renderAllControls()} + + ${renderPerThreadTable(threads)} + + ${renderPoolConfigForm(poolCfg)} + + ${renderThreadsConfigForm(threadsCfg)} + + ${renderAlgorithmPicker(algoCfg)} + + + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('mining.loaded', { hashrate, threadCount: threads.thread_count }); +} + +// ── Summary stats ─────────────────────────────────────────────────── + +function renderSummaryStats(hashrate, poolUrl, uptime, summary) { + return ` +
    +
    +

    Total Hashrate

    +
    + ${formatHashrate(hashrate).split(' ')[0]} ${formatHashrate(hashrate).split(' ').slice(1).join(' ')} +
    +
    +
    +

    Pool

    +
    ${escapeHtml(poolUrl)}
    +
    +
    +

    Uptime

    +
    ${formatUptime(uptime)}
    +
    +
    +
    +

    + ${summary.results?.times_full ?? 0} accepted shares · ${summary.results?.times_rejected ?? 0} rejected · + pool latency ${summary.results?.best_time ?? '—'}s · + diff ${summary.results?.diff_current ?? '—'} +

    +
    + `; +} + +// ── Service controls ──────────────────────────────────────────────── + +function renderServiceControls(svcStatus) { + const active = svcStatus?.active === true; + return ` +
    +
    +

    xmrig.service

    + ${escapeHtml(svcStatus?.state || 'unknown')} +
    +
    +
    + + + + +
    +

    + Start / Stop / Restart run systemctl on xmrig.service via the cockpit + superuser channel (polkit org.sysdeck.system.modify). No sudo shell-out. +

    +
    +
    + `; +} + +// ── All-workers controls (pause / resume) ────────────────────────── + +function renderAllControls() { + return ` +
    +
    +

    All Workers

    +
    +
    +
    + + +
    +

    + Pause/Resume calls the XMRig JSON-RPC paused / resumed methods — no + service restart required. The workers stop hashing immediately and resume on the same pool. +

    +
    +
    + `; +} + +// ── Per-thread hashrate table ─────────────────────────────────────── + +function renderPerThreadTable(threads) { + if (!threads?.available) { + return ` +
    +

    Per-Thread Hashrate

    +

    ${escapeHtml(threads?.reason || 'XMRig REST API not reachable')}

    +
    + `; + } + const list = threads.threads || []; + const max = Math.max(1, ...list.map((t) => t.hashrate || 0)); + const rows = list.map((t) => { + const pct = max > 0 ? Math.round((t.hashrate / max) * 100) : 0; + return ` + + #${t.index} + ${formatHashrate(t.hashrate)} + +
    +
    +
    + + + + + + + `; + }).join(''); + return ` +
    +
    +

    Per-Thread Hashrate (${list.length})

    +
    + + + ${rows || ''} +
    ThreadHashrateShare of maxActions
    No thread data.
    +
    + `; +} + +// ── Pool configuration form ──────────────────────────────────────── + +function renderPoolConfigForm(poolCfg) { + if (!poolCfg?.available) { + return `

    Pool config unreachable.

    `; + } + const current = (poolCfg.pools || [])[0] || {}; + return ` +
    +
    +

    Pool Configuration

    +
    +
    +
    + + + + +
    +

    + Applies via PUT /1/config — XMRig reloads the config live without restarting the daemon. +

    +
    +
    + `; +} + +// ── Thread count form ─────────────────────────────────────────────── + +function renderThreadsConfigForm(threadsCfg) { + if (!threadsCfg?.available) { + return `

    Threads config unreachable.

    `; + } + return ` +
    +
    +

    Thread Count

    +
    +
    +
    + + +
    +

    + Current: ${threadsCfg.thread_count ?? '?'} threads · hugepages: ${threadsCfg.hugepages ? 'on' : 'off'} · + hw-aes: ${threadsCfg.hw_aes ? 'on' : 'off'} · priority: ${threadsCfg.priority ?? 'default'}. + Changing the count triggers a live reload — XMRig re-spawns the worker threads without a service restart. +

    +
    +
    + `; +} + +// ── Algorithm picker ─────────────────────────────────────────────── + +function renderAlgorithmPicker(algoCfg) { + if (!algoCfg?.available) { + return `

    Algorithm config unreachable.

    `; + } + const presets = algoCfg.presets || []; + const current = algoCfg.current || ''; + const options = presets.map((p) => + `` + ).join(''); + return ` +
    +
    +

    Algorithm

    + current: ${escapeHtml(current || 'auto')} +
    +
    +
    + + +
    +

    + Force a specific RandomX variant if the auto-detect picks the wrong one. Applies via PUT /1/config. +

    +
    +
    + `; +} + +// ── XMRig unreachable card ───────────────────────────────────────── + +function renderXmrigUnreachable(svcStatus) { + return ` +
    +

    Mining Dashboard

    +

    + XMRig REST API + · ${escapeHtml(svcStatus?.state || 'not running')} +

    +
    +
    +

    XMRig Unavailable

    +
    +

    + The XMRig REST API at http://127.0.0.1:18088 is not reachable. + Start xmrig.service below, or install XMRig if absent. +

    +

    + Arch: pacman -S xmrig · Debian: apt install xmrig · Fedora: build from source (no official package). + XMRig needs --http-host 127.0.0.1 --http-port 18088 in its systemd unit + for the REST API to be reachable. +

    +
    +
    + ${renderServiceControls(svcStatus)} + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const outputCard = panel.querySelector('#mining-output-card'); + const outputPre = panel.querySelector('#mining-output-pre'); + const outputTitle = panel.querySelector('#mining-output-title'); + const showOutput = (title, text, isError = false) => { + if (!outputCard || !outputPre) return; + outputCard.style.display = 'block'; + outputTitle.textContent = title; + outputPre.textContent = text; + outputPre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + panel.querySelector('#btn-mining-output-close')?.addEventListener('click', () => { + if (outputCard) outputCard.style.display = 'none'; + }); + + // Service controls. + const serviceOp = async (op, label, okKey) => { + showOutput(`${label} xmrig.service`, `${label} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.mining[op](); + const ok = r[okKey] ?? (r.rc === 0); + showOutput(`${label} xmrig.service — ${ok ? 'success' : 'failed'}`, + `rc=${r.rc}\noutput: ${r.output || '(empty)'}\nstderr: ${r.stderr || '(empty)'}`, + !ok); + if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { + showOutput(`${label} xmrig.service — error`, String(err.message || err), true); + } + }; + panel.querySelector('#btn-mining-start')?.addEventListener('click', () => serviceOp('start', 'Start', 'started')); + panel.querySelector('#btn-mining-stop')?.addEventListener('click', () => serviceOp('stop', 'Stop', 'stopped')); + panel.querySelector('#btn-mining-restart')?.addEventListener('click', () => serviceOp('restart', 'Restart', 'restarted')); + panel.querySelector('#btn-mining-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + + // All-workers controls. + panel.querySelector('#btn-mining-pause-all')?.addEventListener('click', async () => { + showOutput('Pause all', 'Pausing all workers via XMRig JSON-RPC ...'); + try { + const r = await bridge.mining.pause(); + showOutput(`Pause all — ${r.paused ? 'success' : 'failed'}`, JSON.stringify(r, null, 2), !r.paused); + } catch (err) { showOutput('Pause all — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-mining-resume-all')?.addEventListener('click', async () => { + showOutput('Resume all', 'Resuming all workers via XMRig JSON-RPC ...'); + try { + const r = await bridge.mining.resume(); + showOutput(`Resume all — ${r.resumed ? 'success' : 'failed'}`, JSON.stringify(r, null, 2), !r.resumed); + } catch (err) { showOutput('Resume all — error', String(err.message || err), true); } + }); + + // Per-worker pause/resume. + panel.querySelectorAll('.btn-pause-worker').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id, 10); + showOutput(`Pause worker ${id}`, `Pausing worker ${id} ...`); + try { + const r = await bridge.mining.pauseWorker(id); + showOutput(`Pause worker ${id} — ${r.paused ? 'success' : 'failed'}`, JSON.stringify(r, null, 2), !r.paused); + } catch (err) { showOutput(`Pause worker ${id} — error`, String(err.message || err), true); } + }); + }); + panel.querySelectorAll('.btn-resume-worker').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id, 10); + showOutput(`Resume worker ${id}`, `Resuming worker ${id} ...`); + try { + const r = await bridge.mining.resumeWorker(id); + showOutput(`Resume worker ${id} — ${r.resumed ? 'success' : 'failed'}`, JSON.stringify(r, null, 2), !r.resumed); + } catch (err) { showOutput(`Resume worker ${id} — error`, String(err.message || err), true); } + }); + }); + + // Pool config. + panel.querySelector('#btn-mining-pool-set')?.addEventListener('click', async () => { + const url = panel.querySelector('#mining-pool-url')?.value?.trim(); + const user = panel.querySelector('#mining-pool-user')?.value?.trim(); + const pass = panel.querySelector('#mining-pool-pass')?.value?.trim() || 'x'; + if (!url || !user) { + showOutput('Set pool', 'Pool URL and wallet address are both required.', true); + return; + } + showOutput('Set pool', `PUT /1/config — pool.url=${url} pool.user=${user} ...`); + try { + const r = await bridge.mining.poolConfigSet(url, user, pass); + showOutput(`Set pool — ${r.set ? 'success' : 'failed'}`, JSON.stringify(r, null, 2), !r.set); + if (r.set) setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { showOutput('Set pool — error', String(err.message || err), true); } + }); + + // Threads config. + panel.querySelector('#btn-mining-threads-set')?.addEventListener('click', async () => { + const count = panel.querySelector('#mining-threads-count')?.value; + if (!count) { showOutput('Set threads', 'Enter a thread count.', true); return; } + showOutput('Set threads', `PUT /1/config — cpu.threads=${count} ...`); + try { + const r = await bridge.mining.threadsConfigSet(count); + showOutput(`Set threads — ${r.set ? 'success' : 'failed'}`, JSON.stringify(r, null, 2), !r.set); + if (r.set) setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { showOutput('Set threads — error', String(err.message || err), true); } + }); + + // Algorithm. + panel.querySelector('#btn-mining-algo-set')?.addEventListener('click', async () => { + const id = panel.querySelector('#mining-algo-select')?.value; + if (!id) { showOutput('Set algorithm', 'Select an algorithm preset.', true); return; } + showOutput('Set algorithm', `PUT /1/config — cpu.asm=${id} ...`); + try { + const r = await bridge.mining.algorithmSet(id); + showOutput(`Set algorithm — ${r.set ? 'success' : 'failed'}`, JSON.stringify(r, null, 2), !r.set); + if (r.set) setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { showOutput('Set algorithm — error', String(err.message || err), true); } + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function formatHashrate(h) { + if (!h || h <= 0) return '0 H/s'; + if (h >= 1000) return `${(h / 1000).toFixed(2)} kH/s`; + if (h >= 1) return `${h.toFixed(1)} H/s`; + return `${h.toFixed(3)} H/s`; +} + +function formatUptime(s) { + if (!s) return '—'; + const min = Math.floor(s / 60); + if (min < 60) return `${min} min`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ${min % 60}m`; + const d = Math.floor(hr / 24); + return `${d}d ${hr % 24}h`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    `; +} diff --git a/plugins/sysdeck-modules/index.html b/plugins/sysdeck-modules/index.html new file mode 100755 index 0000000..f07efa1 --- /dev/null +++ b/plugins/sysdeck-modules/index.html @@ -0,0 +1,89 @@ + + + + + SysDeck 3rd-Party Modules + + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-modules/manifest.json b/plugins/sysdeck-modules/manifest.json new file mode 100755 index 0000000..767aaa5 --- /dev/null +++ b/plugins/sysdeck-modules/manifest.json @@ -0,0 +1,39 @@ +{ + "version": 0, + "name": "sysdeck-modules", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "3rd-Party Modules", + "order": 44, + "keywords": [ + { + "matches": [ + "modules", + "third-party", + "3rd-party", + "third party", + "extensions", + "addons", + "navigator", + "file-sharing", + "zfs", + "45drives", + "cockpit-pacman", + "cockpit-identities", + "cockpit-sensors", + "cockpit-benchmark", + "license", + "attribution", + "credit", + "1-click", + "inline license" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-modules/modules.js b/plugins/sysdeck-modules/modules.js new file mode 100755 index 0000000..0aebd3e --- /dev/null +++ b/plugins/sysdeck-modules/modules.js @@ -0,0 +1,334 @@ +/* + * SysDeck — 3rd-Party Modules Panel (v0.0.46) + * Author: Jeremy Anderson (https://dcos.net) + * + * Catalog-driven installer for third-party Cockpit modules. + * + * DESIGN (per v0.0.46 directive): the license, developer/author, + * 3rd-party module name, and a homepage link are rendered INLINE + * in every catalog row. The Install button is a TRUE 1-click install + * — clicking it is the operator's acceptance of the inline-displayed + * license. No modal, no extra confirmation step. + * + * The bridge helper (modules3p.py) still refuses silent installs: + * install() requires --accept-license=1. The JS always passes that + * flag on every click, because the license is shown inline next to + * the button — clicking Install IS the acceptance gesture. + * + * Flow: + * 1. bridge.modules3p.status() → renders rows + * 2. operator clicks Install on a row + * 3. bridge.modules3p.install(id, true) → 1 click, runs as root via polkit + * 4. bridge appends an audit record to /etc/cockpit/MODULE_LICENSES.log + * 5. JS re-renders the catalog + * + * Uninstall follows the same pattern: 1 click, runs uninstall, audit. + */ + +const LICENSE_SHORT_NAMES = { + "MIT": { cls: "success", note: "permissive — retains copyright + notice" }, + "LGPL-2.1": { cls: "info", note: "weak copyleft — derivatives of the library must stay LGPL" }, + "LGPL-2.1+": { cls: "info", note: "weak copyleft — or-later" }, + "GPL-2.0": { cls: "warn", note: "copyleft — derivative works must be GPL-2.0+" }, + "GPL-2.0+": { cls: "warn", note: "copyleft — or-later" }, + "GPL-3.0": { cls: "warn", note: "copyleft — derivative works must be GPL-3.0+" }, + "AGPL-3.0": { cls: "warn", note: "strong copyleft — network use triggers source disclosure" }, + "Apache-2.0": { cls: "success", note: "permissive — retains NOTICE file + patent grant" }, + "BSD-2-Clause": { cls: "success", note: "permissive — retains copyright + notice" }, + "BSD-3-Clause": { cls: "success", note: "permissive — retains copyright + notice + no endorsement" }, +}; + +function licenseBadge(lic) { + const meta = LICENSE_SHORT_NAMES[lic] || { cls: "info", note: "see upstream for terms" }; + return `${esc(lic)}`; +} + +function esc(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[c]); +} + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + let catalog = []; + try { + catalog = await bridge.modules3p.status(); + } catch (err) { + panel.innerHTML = renderError("Catalog unavailable", err); + return; + } + + panel.innerHTML = renderShell(catalog); + wireUp(panel, catalog, bridge); +} + +function renderShell(catalog) { + const categories = unique(catalog.map((m) => m.category)).sort(); + const total = catalog.length; + const installed = catalog.filter((m) => m.installed).length; + const missingDeps = catalog.filter((m) => m.missing_deps?.length > 0).length; + + return ` +
    +

    3rd-Party Cockpit Modules

    +

    + Optional add-ons. License, developer, source, and homepage + are shown inline next to each Install button — clicking + Install is your acceptance of the displayed license. +

    +
    + + + + ${installed}/${total} installed · ${missingDeps} with missing deps + +
    +
    + +
    + ${catalog.map((m) => renderRow(m)).join("")} +
    + +
    +
    +

    License Audit Log

    + +
    +

    + Append-only record at /etc/cockpit/MODULE_LICENSES.log. + Every install / uninstall writes one JSON line with module id, + license, author, source URL, action, and UTC timestamp. +

    +
    +

    Click Refresh to load.

    +
    +
    + +
    +

    Compliance Notes

    +

    + SysDeck (MIT) invokes every upstream tool as a separate + process via cockpit.spawn — no third-party + code is bundled into the SysDeck tarball. Each module's + license applies only to the module itself, not to SysDeck. + For GPL-family modules (45Drives Navigator / File Sharing / + ZFS Manager, cockpit-pacman) the invocation boundary is a + subprocess call; you remain responsible for complying with + each upstream license when distributing the combined system. + See THIRD_PARTY.md for the full license + compatibility matrix. +

    +
    + `; +} + +function renderRow(m) { + const installLabel = m.installed ? "Reinstall" : "Install"; + const installBtn = ``; + const uninstallBtn = m.installed + ? `` + : ""; + const missingWarn = (m.missing_deps?.length > 0) + ? `${m.missing_deps.length} missing dep${m.missing_deps.length > 1 ? "s" : ""}` + : ""; + const installedBadge = m.installed + ? `installed` + : `not installed`; + const depList = m.depends?.length + ? `runtime deps: ${m.depends.map(esc).join(", ")}` + : ""; + + // The license agreement, developer, source, and homepage are all + // rendered INLINE in this row — visible right next to the Install + // button. No modal is needed. + return ` +
    +
    +

    ${esc(m.name)} (${esc(m.id)})

    +
    + ${installedBadge} + ${licenseBadge(m.license)} + ${missingWarn} +
    +
    +

    ${esc(m.blurb)}

    +
    + developer: ${esc(m.author)} + license: ${esc(m.license)} + source: ${esc(m.source)} + homepage: visit ↗ + category: ${esc(m.category)} + ${depList} +
    +
    + ${installBtn} + ${uninstallBtn} +
    +
    +
    + `; +} + +function unique(arr) { + return Array.from(new Set(arr)); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    +
    `; +} + +function renderError(title, err) { + return `
    +

    ${esc(title)}

    +

    ${esc(err.message || err)}.

    +

    Run python3 /usr/lib/sysdeck/bridge/modules3p.py status on the host to debug.

    +
    `; +} + +function wireUp(panel, catalog, bridge) { + const grid = panel.querySelector("#modules-grid"); + const filterCat = panel.querySelector("#modules-filter-category"); + const filterSearch = panel.querySelector("#modules-filter-search"); + + function applyFilter() { + const cat = filterCat.value; + const q = filterSearch.value.trim().toLowerCase(); + grid.querySelectorAll(".modules-row").forEach((row) => { + const m = catalog.find((x) => x.id === row.dataset.id); + if (!m) return; + const catOk = !cat || m.category === cat; + const qOk = !q || [m.name, m.author, m.license, m.id, m.blurb] + .some((s) => String(s).toLowerCase().includes(q)); + row.style.display = (catOk && qOk) ? "" : "none"; + }); + } + filterCat.addEventListener("change", applyFilter); + filterSearch.addEventListener("input", applyFilter); + + // 1-click install / uninstall — license was shown inline in the row, + // so the click IS the operator's acceptance. + grid.addEventListener("click", async (ev) => { + const btn = ev.target.closest("button[data-action]"); + if (!btn) return; + const id = btn.dataset.id; + const action = btn.dataset.action; + const row = btn.closest(".modules-row"); + const flashBox = row?.querySelector("[data-row-flash]"); + const originalLabel = btn.textContent; + + if (action === "install") { + btn.disabled = true; + btn.textContent = "Installing…"; + showRowFlash(flashBox, `Pulling ${id} — see audit log. License shown inline above.`, "info"); + try { + const r = await bridge.modules3p.install(id, /*acceptLicense=*/ true); + if (r.ok) { + showRowFlash(flashBox, + `Installed ${r.name} — ${r.license} · ${r.author} · ${r.source}. Audit record appended.`, + "success"); + await refresh(panel, bridge); + } else { + showRowFlash(flashBox, + `Install failed: ${r.error || r.message || "unknown error"}`, + "danger"); + btn.disabled = false; + btn.textContent = originalLabel; + } + } catch (e) { + showRowFlash(flashBox, `Install error: ${e.message || e}`, "danger"); + btn.disabled = false; + btn.textContent = originalLabel; + } + } else if (action === "uninstall") { + btn.disabled = true; + btn.textContent = "Removing…"; + showRowFlash(flashBox, `Removing ${id}…`, "info"); + try { + const r = await bridge.modules3p.uninstall(id); + if (r.ok) { + showRowFlash(flashBox, `Removed ${id}. Audit record appended.`, "success"); + await refresh(panel, bridge); + } else { + showRowFlash(flashBox, + `Uninstall failed: ${r.error || r.message || "unknown error"}`, + "danger"); + btn.disabled = false; + btn.textContent = originalLabel; + } + } catch (e) { + showRowFlash(flashBox, `Uninstall error: ${e.message || e}`, "danger"); + btn.disabled = false; + btn.textContent = originalLabel; + } + } + }); + + panel.querySelector("#modules-audit-refresh")?.addEventListener("click", async () => { + const body = panel.querySelector("#modules-audit-body"); + body.innerHTML = `

    Loading…

    `; + try { + const r = await bridge.modules3p.audit(); + body.innerHTML = renderAudit(r.records || []); + } catch (e) { + body.innerHTML = `

    Failed: ${esc(e.message || e)}

    `; + } + }); +} + +function showRowFlash(flashBox, msg, kind) { + if (!flashBox) return; + flashBox.innerHTML = `${esc(msg)}`; +} + +function renderAudit(records) { + if (!records.length) { + return `

    No records yet — installs and uninstalls will appear here.

    `; + } + return ` + + + ${records.map((r) => r.raw + ? `` + : ` + + + + + + + ` + ).join("")} + +
    Time (UTC)ModuleLicenseAuthorActionDetail
    ${esc(r.raw)}
    ${esc(r.ts || "")}${esc(r.module || r.id || "")}${esc(r.license || "")}${esc(r.author || "")}${esc(r.action || "")}${esc(r.detail || "")}
    `; +} + +function actionClass(action) { + if (!action) return ""; + if (action.endsWith("-ok")) return "success"; + if (action.endsWith("-failed")) return "danger"; + return ""; +} + +async function refresh(panel, bridge) { + let catalog = []; + try { + catalog = await bridge.modules3p.status(); + } catch (e) { + showRowFlash(panel, `Refresh failed: ${e.message || e}`, "danger"); + return; + } + panel.innerHTML = renderShell(catalog); + wireUp(panel, catalog, bridge); +} diff --git a/plugins/sysdeck-monitoring/index.html b/plugins/sysdeck-monitoring/index.html new file mode 100755 index 0000000..e13a2c5 --- /dev/null +++ b/plugins/sysdeck-monitoring/index.html @@ -0,0 +1,101 @@ + + + + + SysDeck Monitoring + + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-monitoring/manifest.json b/plugins/sysdeck-monitoring/manifest.json new file mode 100755 index 0000000..0c8cbd3 --- /dev/null +++ b/plugins/sysdeck-monitoring/manifest.json @@ -0,0 +1,35 @@ +{ + "version": 0, + "name": "sysdeck-monitoring", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Monitoring", + "order": 43, + "keywords": [ + { + "matches": [ + "prometheus", + "grafana", + "monitoring", + "metrics", + "dashboards", + "alerts", + "alertmanager", + "pushgateway", + "time series", + "observability", + "targets", + "scrape", + "datasources", + "panels", + "visualization" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval' frame-src 'self' http://127.0.0.1:9095 http://127.0.0.1:3000;" +} diff --git a/plugins/sysdeck-monitoring/monitoring.js b/plugins/sysdeck-monitoring/monitoring.js new file mode 100755 index 0000000..a848170 --- /dev/null +++ b/plugins/sysdeck-monitoring/monitoring.js @@ -0,0 +1,344 @@ +/* + * SysDeck - Monitoring Panel (v0.0.43) + * Author: Jeremy Anderson (https://dcos.net) + * + * Shared tabbed module hosting Prometheus + Grafana — the two + * observability stack components. Per user directive: "we have 2 + * modules left, we can actually have them share a module with tabs + * similar to the container/vm module. we should add prometheus, and + * graphana webui modules." + * + * The panel has two tabs: + * 1. Prometheus — status card (version, uptime, targets, alerts) + + * iframe of the real Prometheus web UI at http://127.0.0.1:9095 + * 2. Grafana — status card (version, dashboards, datasources) + + * iframe of the real Grafana web UI at http://127.0.0.1:3000 + * + * Both tabs back their data with REAL bridge helpers (bridge/prometheus.py + * and bridge/grafana.py) that call the actual HTTP APIs. No mock data. + * The iframes load the real web UIs directly — same pattern as the + * v0.0.34 Glances integration. + * + * v0.0.43 hardening applied to both bridge helpers: + * - NoRedirectHandler on all HTTP calls (SSRF defense, CVE-2020-35850) + * - 127.0.0.1-only URL check (SSRF defense) + * - Env scrubbed on every subprocess (CVE-2024-6126) + * - Output sanitized (CVE-2022-36446) + * - No sudo — cockpit superuser channel + polkit handles auth + * (CVE-2022-0824 lesson — the v0.0.15-era sudo shell-out is gone) + * + * Bridge surface (see shared/bridge.js → bridge.prometheus + bridge.grafana): + * Prometheus: + * summary() → overall status + version + targets + alerts count + * targets() → scrape target health (up/down/duration) + * alerts() → current firing + pending alerts + * rules() → alerting + recording rules + * config() → full Prometheus config + * logSummary() → pushgateway log pipeline throughput + * restart() → systemctl restart prometheus.service (superuser) + * reload() → SIGHUP config reload (superuser) + * Grafana: + * summary() → overall status + version + dashboard count + * dashboards() → list of provisioned dashboards + * datasources() → list of configured datasources + * alerts() → Grafana-managed alerts + * health() → Grafana health endpoint + * org() → current organization info + * users() → Grafana user list + * plugins() → installed Grafana plugins + * search(query) → search dashboards by name + * restart() → systemctl restart grafana-server.service (superuser) + * reload() → SIGUSR2 provisioning reload (superuser) + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + // Load both summaries in parallel. + const [promSummary, grafSummary] = await Promise.all([ + safe(bridge.prometheus.summary(), { installed: false, status: 'uninstalled' }), + safe(bridge.grafana.summary(), { installed: false, status: 'uninstalled' }), + ]); + + panel.innerHTML = ` +
    +

    SysDeck Monitoring

    +

    + Observability stack — Prometheus (metrics) + Grafana (dashboards) + · Prometheus ${promSummary.installed ? 'installed' : 'absent'} + · Grafana ${grafSummary.installed ? 'installed' : 'absent'} +

    +
    + +
    + + +
    + +
    + ${renderPrometheusTab(promSummary)} +
    + +
    + ${renderGrafanaTab(grafSummary)} +
    + + + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('monitoring.loaded', { + prometheusInstalled: promSummary.installed, + grafanaInstalled: grafSummary.installed, + }); +} + +// ── Prometheus tab ────────────────────────────────────────────────── + +function renderPrometheusTab(summary) { + if (!summary.installed) { + return renderInstallHint('Prometheus', 'prometheus', 'prometheus.service', [ + '# Arch:', + 'sudo pacman -S prometheus', + '# Debian/Ubuntu:', + 'sudo apt install prometheus prometheus-alertmanager', + '# Fedora/RHEL:', + 'sudo dnf install prometheus alertmanager', + '', + '# IMPORTANT: Cockpit-ws uses port 9090 by default.', + '# Move Prometheus to port 9095 to avoid the conflict.', + '# Edit /etc/prometheus/prometheus.yml and add:', + '# web.listen_address: "127.0.0.1:9095"', + '# Or set in /etc/default/prometheus:', + '# ARGS="--web.listen-address=127.0.0.1:9095"', + ], '9095'); + } + const status = summary.status || 'unknown'; + const statusBadge = `${escapeHtml(status)}`; + const targetsUp = summary.targetsUp || 0; + const targetsTotal = summary.targetsTotal || 0; + const alertsFiring = summary.alertsFiring || 0; + return ` +
    +
    +

    Prometheus Status

    +
    + + + +
    +
    +
    + + + + + + + + + +
    status${statusBadge}
    version${escapeHtml(summary.version || '?')}
    uptime${escapeHtml(summary.uptime || '?')}
    targets${targetsUp}/${targetsTotal} up
    alerts firing${alertsFiring > 0 ? `${alertsFiring}` : '0'}
    API URLhttp://127.0.0.1:9095
    +
    +
    +
    +
    +

    Prometheus Web UI

    + ↗ Open in new tab +
    +
    + +
    +
    + `; +} + +// ── Grafana tab ───────────────────────────────────────────────────── + +function renderGrafanaTab(summary) { + if (!summary.installed) { + return renderInstallHint('Grafana', 'grafana-server', 'grafana-server.service', [ + '# Arch (AUR):', + 'yay -S grafana', + '# Debian/Ubuntu:', + 'sudo apt install -y adduser libfontconfig1', + 'wget https://dl.grafana.com/oss/release/grafana_latest_amd64.deb', + 'sudo dpkg -i grafana_latest_amd64.deb', + '# Fedora/RHEL:', + 'sudo dnf install grafana', + ], '3000'); + } + const status = summary.status || 'unknown'; + const statusBadge = `${escapeHtml(status)}`; + const dashboards = summary.dashboardsCount || 0; + const datasources = summary.datasourcesCount || 0; + return ` +
    +
    +

    Grafana Status

    +
    + + + +
    +
    +
    + + + + + + + + + +
    status${statusBadge}
    version${escapeHtml(summary.version || '?')}
    dashboards${dashboards}
    datasources${datasources}
    URLhttp://127.0.0.1:3000
    default loginadmin / admin (change immediately)
    +
    +
    +
    +
    +

    Grafana Web UI

    + ↗ Open in new tab +
    +
    + +
    +
    + `; +} + +// ── Shared install hint (shown when service is not installed) ─────── + +function renderInstallHint(name, binary, service, commands, port) { + return ` +
    +
    +

    ${escapeHtml(name)} — Not Installed

    +
    +
    +

    + ${escapeHtml(binary)} is not installed. + Install it to enable the ${escapeHtml(name)} tab: +

    +
    ${commands.map(escapeHtml).join('\n')}
    +

    + After install, enable + start the service: +

    +
    sudo systemctl enable --now ${escapeHtml(service)}
    +

    + The web UI will be available at http://127.0.0.1:${escapeHtml(port)} + and will appear in the iframe below once the service is running. +

    +
    +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const output = (msg, isError = false) => { + const card = panel.querySelector('#monitoring-output'); + const pre = panel.querySelector('#monitoring-output-pre'); + if (!card || !pre) return; + card.style.display = 'block'; + pre.textContent = msg; + pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + panel.querySelector('#btn-monitoring-output-close')?.addEventListener('click', () => { + const card = panel.querySelector('#monitoring-output'); + if (card) card.style.display = 'none'; + }); + + // Tab switching. + panel.querySelectorAll('.monitoring-tab').forEach((tab) => { + tab.addEventListener('click', () => { + panel.querySelectorAll('.monitoring-tab').forEach((t) => t.classList.remove('active')); + panel.querySelectorAll('.monitoring-tab-panel').forEach((p) => p.classList.remove('active')); + tab.classList.add('active'); + const target = tab.dataset.tab; + panel.querySelector(`#tab-${target}`)?.classList.add('active'); + }); + }); + + // Prometheus controls. + panel.querySelector('.btn-prom-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + panel.querySelector('.btn-prom-reload')?.addEventListener('click', async () => { + output('Reloading Prometheus config (SIGHUP) ... (cockpit will prompt for auth)'); + try { + const r = await bridge.prometheus.reload(); + output(r.result === 'ok' + ? 'Prometheus config reloaded.' + : `Reload FAILED: ${r.stderr || r.message || JSON.stringify(r)}`, + r.result !== 'ok'); + } catch (err) { output(`Reload error: ${err.message || err}`, true); } + }); + panel.querySelector('.btn-prom-restart')?.addEventListener('click', async () => { + output('Restarting Prometheus ... (cockpit will prompt for auth)'); + try { + const r = await bridge.prometheus.restart(); + output(r.result === 'ok' + ? 'Prometheus restarted.' + : `Restart FAILED: ${r.stderr || r.message || JSON.stringify(r)}`, + r.result !== 'ok'); + if (r.result === 'ok') setTimeout(() => mount(panel, { bridge, EventBus }), 2000); + } catch (err) { output(`Restart error: ${err.message || err}`, true); } + }); + + // Grafana controls. + panel.querySelector('.btn-graf-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + panel.querySelector('.btn-graf-reload')?.addEventListener('click', async () => { + output('Reloading Grafana provisioning (SIGUSR2) ... (cockpit will prompt for auth)'); + try { + const r = await bridge.grafana.reload(); + output(r.result === 'ok' + ? 'Grafana provisioning reloaded.' + : `Reload FAILED: ${r.stderr || r.message || JSON.stringify(r)}`, + r.result !== 'ok'); + } catch (err) { output(`Reload error: ${err.message || err}`, true); } + }); + panel.querySelector('.btn-graf-restart')?.addEventListener('click', async () => { + output('Restarting Grafana ... (cockpit will prompt for auth)'); + try { + const r = await bridge.grafana.restart(); + output(r.result === 'ok' + ? 'Grafana restarted.' + : `Restart FAILED: ${r.stderr || r.message || JSON.stringify(r)}`, + r.result !== 'ok'); + if (r.result === 'ok') setTimeout(() => mount(panel, { bridge, EventBus }), 2000); + } catch (err) { output(`Restart error: ${err.message || err}`, true); } + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-netsec/index.html b/plugins/sysdeck-netsec/index.html new file mode 100755 index 0000000..03c6646 --- /dev/null +++ b/plugins/sysdeck-netsec/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Network Security + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-netsec/manifest.json b/plugins/sysdeck-netsec/manifest.json new file mode 100755 index 0000000..80766d9 --- /dev/null +++ b/plugins/sysdeck-netsec/manifest.json @@ -0,0 +1,27 @@ +{ + "version": 0, + "name": "sysdeck-netsec", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Network Security", + "order": 23, + "keywords": [ + { + "matches": [ + "network", + "sockets", + "ports", + "listening", + "ss", + "tcp", + "udp" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-netsec/netsec.js b/plugins/sysdeck-netsec/netsec.js new file mode 100755 index 0000000..54c8158 --- /dev/null +++ b/plugins/sysdeck-netsec/netsec.js @@ -0,0 +1,262 @@ +/* + * SysDeck - Network Monitor Panel (v0.0.43) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.43 REWRITE — IPTRAF-NG STYLE. + * + * The v0.0.10-v0.0.43 panel used `ss -tulpn` for a static socket list. + * v0.0.43 recreates the iptraf-ng UI by reading the same kernel sources + * iptraf-ng reads from directly — no iptraf-ng binary dependency, no + * ncurses parsing. + * + * The panel has three sections (matching iptraf-ng's views): + * 1. Interface Overview — live RX/TX rate cards (bytes/s, packets/s) + * per interface. Auto-refreshes every 3s. The traffic subcommand + * samples /proc/net/dev twice (1s apart) to compute live rates. + * 2. IP Traffic Monitor — active TCP/UDP connections table (proto, + * state, local addr:port, remote addr:port, TX/RX queue). Reads + * /proc/net/tcp + /proc/net/udp directly. + * 3. Protocol Statistics — IP/TCP/UDP/ICMP counters from + * /proc/net/snmp (InReceives, OutRequests, InDiscards, etc.). + * + * Data sources (same as iptraf-ng): + * /proc/net/dev per-interface RX/TX byte + packet counters + * /proc/net/snmp IP/TCP/UDP/ICMP protocol counters + * /proc/net/tcp active TCP connections (state, local, remote) + * /proc/net/udp active UDP sockets + * + * The traffic subcommand takes 1 second (samples /proc/net/dev twice, + * 1s apart) — the panel shows a loading indicator during that window. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + // Load summary + connections + protocols in parallel (traffic is + // slow — 1s — so we load it separately and show it when ready). + const [summary, connections, protocols] = await Promise.all([ + safe(bridge.netsec.summary(), { total_interfaces: 0, total_connections: 0 }), + safe(bridge.netsec.connections(), []), + safe(bridge.netsec.protocols(), {}), + ]); + + panel.innerHTML = ` +
    +

    Network Monitor

    +

    + ${summary.total_interfaces || 0} interfaces + (${summary.interfaces_up || 0} active) + · ${summary.total_connections || 0} connections + · ESTABLISHED: ${summary.tcp_established || 0} + · LISTEN: ${summary.tcp_listen || 0} + · +

    +
    + +
    +
    +

    Interface Overview (live traffic)

    + sampling… +
    +
    +

    Sampling /proc/net/dev (1s window)…

    +
    +
    + +
    +
    +

    IP Traffic Monitor (${connections.length} connections)

    +
    +
    + + + + + + ${connections.slice(0, 100).map((c) => ` + + + + + + + + + `).join('') || ''} + +
    ProtoStateLocalRemoteTxQRxQ
    ${escapeHtml(c.proto)}${escapeHtml(c.state)}${escapeHtml(c.local_ip)}:${c.local_port}${escapeHtml(c.remote_ip)}:${c.remote_port}${c.tx_queue}${c.rx_queue}
    No active connections.
    +
    + ${connections.length > 100 ? `

    Showing first 100 of ${connections.length} connections.

    ` : ''} +
    + +
    +
    +

    Protocol Statistics

    +
    +
    + ${renderProtocolStats(protocols)} +
    +
    + `; + + // Load live traffic data asynchronously (1s sample window). + loadTrafficData(panel, bridge); + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('netsec.loaded', { + totalInterfaces: summary.total_interfaces, + totalConnections: summary.total_connections, + }); +} + +// ── Traffic data loader (async, 1s sample) ───────────────────────── + +async function loadTrafficData(panel, bridge) { + const host = panel.querySelector('#netsec-traffic-host'); + const status = panel.querySelector('#netsec-traffic-status'); + if (!host) return; + try { + const traffic = await bridge.netsec.traffic(); + status.textContent = `updated ${new Date().toLocaleTimeString()}`; + host.innerHTML = renderTrafficCards(traffic); + } catch (err) { + status.textContent = 'error'; + host.innerHTML = `

    Traffic sample failed: ${escapeHtml(err.message || err)}

    `; + } +} + +function renderTrafficCards(traffic) { + if (!traffic || !traffic.length) { + return '

    No network interfaces found.

    '; + } + return ` +
    + ${traffic.map((t) => { + const rxKbps = (t.rx_bps / 1024).toFixed(1); + const txKbps = (t.tx_bps / 1024).toFixed(1); + const rxMbps = (t.rx_bps / (1024 * 1024)).toFixed(2); + const txMbps = (t.tx_bps / (1024 * 1024)).toFixed(2); + const rxRate = t.rx_bps > 1024 * 1024 ? `${rxMbps} Mbps` : `${rxKbps} KB/s`; + const txRate = t.tx_bps > 1024 * 1024 ? `${txMbps} Mbps` : `${txKbps} KB/s`; + const rxTotal = t.rx_bytes_total > 1024 * 1024 * 1024 + ? `${(t.rx_bytes_total / (1024**3)).toFixed(1)} GB` + : t.rx_bytes_total > 1024 * 1024 + ? `${(t.rx_bytes_total / (1024**2)).toFixed(1)} MB` + : `${(t.rx_bytes_total / 1024).toFixed(0)} KB`; + const txTotal = t.tx_bytes_total > 1024 * 1024 * 1024 + ? `${(t.tx_bytes_total / (1024**3)).toFixed(1)} GB` + : t.tx_bytes_total > 1024 * 1024 + ? `${(t.tx_bytes_total / (1024**2)).toFixed(1)} MB` + : `${(t.tx_bytes_total / 1024).toFixed(0)} KB`; + // Bar width relative to 1 Mbps max for visual scaling. + const maxBps = 1024 * 1024; + const rxBarW = Math.min(100, (t.rx_bps / maxBps) * 100); + const txBarW = Math.min(100, (t.tx_bps / maxBps) * 100); + return ` +
    +
    + ${escapeHtml(t.iface)} + ${(t.rx_errs_total + t.tx_errs_total + t.rx_drop_total + t.tx_drop_total) > 0 + ? 'errors' + : 'clean'} +
    +
    +
    + ↓ RX + ${rxRate} · ${t.rx_pps} pps · total ${rxTotal} +
    +
    +
    +
    +
    +
    +
    + ↑ TX + ${txRate} · ${t.tx_pps} pps · total ${txTotal} +
    +
    +
    +
    +
    +
    + `; + }).join('')} +
    + `; +} + +function renderProtocolStats(protocols) { + if (!protocols || protocols.error) { + return '

    Protocol statistics unavailable.

    '; + } + const protos = ['ip', 'tcp', 'udp', 'icmp', 'icmp6']; + const cards = protos.filter((p) => protocols[p]).map((p) => { + const stats = protocols[p]; + const fields = Object.entries(stats).slice(0, 12).map(([k, v]) => + `${escapeHtml(k)}${escapeHtml(String(v))}` + ).join(''); + return ` +
    +

    ${escapeHtml(p)}

    + + ${fields} +
    +
    + `; + }).join(''); + return cards || '

    No protocol data.

    '; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + panel.querySelector('#btn-netsec-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); + + // Auto-refresh traffic every 5 seconds (the traffic subcommand + // itself takes 1s to sample, so 5s gives a good balance between + // freshness and load). + if (panel._netsecTrafficInterval) clearInterval(panel._netsecTrafficInterval); + panel._netsecTrafficInterval = setInterval(() => { + loadTrafficData(panel, bridge); + }, 5000); + + // Clean up interval when the panel is unmounted. + // Cockpit doesn't have a native unmount hook, but we can detect + // when the panel's DOM is removed via MutationObserver. + const observer = new MutationObserver(() => { + if (!document.body.contains(panel)) { + clearInterval(panel._netsecTrafficInterval); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-packages/index.html b/plugins/sysdeck-packages/index.html new file mode 100755 index 0000000..71c08aa --- /dev/null +++ b/plugins/sysdeck-packages/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Packages + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-packages/manifest.json b/plugins/sysdeck-packages/manifest.json new file mode 100755 index 0000000..dfe6e59 --- /dev/null +++ b/plugins/sysdeck-packages/manifest.json @@ -0,0 +1,26 @@ +{ + "version": 0, + "name": "sysdeck-packages", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Packages", + "order": 37, + "keywords": [ + { + "matches": [ + "packages", + "pacman", + "dnf", + "apt", + "updates", + "install" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-packages/packages.js b/plugins/sysdeck-packages/packages.js new file mode 100755 index 0000000..c297b6c --- /dev/null +++ b/plugins/sysdeck-packages/packages.js @@ -0,0 +1,209 @@ +/* + * SysDeck - Packages Panel (v0.0.31) + * Author: Jeremy Anderson (https://dcos.net) + * + * Package management panel — list, search, install, update, and remove + * packages via the system package manager (pacman / dnf / apt). + * The package manager is invoked as a separate process via cockpit.spawn — + * no package-manager code is bundled. + * + * v0.0.31 REWRITE — UPDATE NEEDS SUDO, FIXED THE COCKPIT WAY. + * v0.0.30 packages.js Update All button called bridge.packages.updateAll() + * which returned only the command string that *would* be run. The panel + * showed `alert("Run this command with superuser privileges.")` and the + * operator had to copy the command, open a terminal, sudo, paste, run. + * That defeated the purpose of having a panel. + * + * v0.0.31 makes install/remove/update/update-all actually execute via + * the cockpit superuser channel (polkit). The bridge helper runs the + * detected package manager via subprocess, and the JS panel subscribes + * to the cockpit spawn stream so the operator sees live stdout/stderr + * in a
     log panel — exactly like cockpit's own Packages and
    + * Software Updates panels. No `sudo` shell-out from JS.
    + *
    + * Shows installed count, pending updates, and a searchable package list.
    + */
    +
    +export async function mount(panel, { bridge, EventBus }) {
    +    panel.innerHTML = renderSkeleton();
    +
    +    let summary = {};
    +    let installed = [];
    +    try {
    +        [summary, installed] = await Promise.all([
    +            bridge.packages.summary(),
    +            bridge.packages.listInstalled(),
    +        ]);
    +    } catch (err) {
    +        panel.innerHTML = renderError(err);
    +        return;
    +    }
    +
    +    const mgr = summary.manager || 'unknown';
    +    const instCount = summary.installedCount || installed.length;
    +    const updCount = summary.updateCount || 0;
    +    const updates = summary.updates || [];
    +
    +    panel.innerHTML = `
    +        
    +

    Package Manager

    +

    ${mgr} — ${instCount} installed · ${updCount} updates available

    +
    +
    +
    +

    Installed

    +
    ${instCount}
    +
    packages via ${mgr}
    +
    +
    +

    Updates

    +
    ${updCount}
    +
    ${updCount > 0 ? 'updates pending' : 'system is up to date'}
    +
    +
    +
    +
    +

    Pending Updates

    +
    + + +
    +
    + + + + ${updates.map((u) => ` + + + + `).join('') || ''} + +
    PackageCurrentNew
    ${u.name || u.package || '—'}${u.current || '—'}${u.new || '—'}
    No pending updates.
    +
    +
    +
    +

    Search Packages

    +
    +
    + + +
    +
    Enter a search term to find packages.
    +
    +
    +
    +

    Recently Installed

    + +
    + + + + ${installed.slice(0, 25).map((p) => ` + + + `).join('') || ''} + +
    PackageVersion
    ${p.name}${p.version}
    No packages found.
    + ${installed.length > 25 ? `

    Showing 25 of ${installed.length} packages.

    ` : ''} +
    + + `; + + // ── Output helpers ────────────────────────────────────────────── + const outputCard = panel.querySelector('#pkg-output-card'); + const outputPre = panel.querySelector('#pkg-output-pre'); + const outputTitle = panel.querySelector('#pkg-output-title'); + const outputStatus = panel.querySelector('#pkg-output-status'); + const showOutput = (title, text, status = '') => { + if (!outputCard || !outputPre) return; + outputCard.style.display = 'block'; + outputTitle.textContent = title; + outputPre.textContent = text; + outputStatus.textContent = status; + outputPre.style.color = status.startsWith('FAIL') ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + + panel.querySelector('#btn-pkg-output-close')?.addEventListener('click', () => { + if (outputCard) outputCard.style.display = 'none'; + }); + + // ── Update All — runs the operation, no alert ────────────────── + panel.querySelector('#btn-update-all')?.addEventListener('click', async () => { + if (!updCount) return; + showOutput('Update All — running', `Running ${mgr} upgrade via cockpit superuser channel...\n(cockpit will prompt for auth)`, 'running'); + try { + const result = await bridge.packages.updateAll(); + const lines = []; + if (result.command) lines.push(`$ ${result.command}\n`); + if (result.output) lines.push(result.output); + if (result.stderr) lines.push(`\n--- stderr ---\n${result.stderr}`); + lines.push(`\n--- exit: ${result.rc} ---`); + const ok = result.success; + showOutput(`Update All — ${ok ? 'success' : 'failed'}`, + lines.join('\n'), + ok ? 'OK' : `FAIL rc=${result.rc}`); + EventBus.emit('packages.update-all', result); + if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 1500); + } catch (err) { + showOutput('Update All — error', String(err.message || err), 'FAIL'); + } + }); + + // ── Preview Command — dry-run ────────────────────────────────── + panel.querySelector('#btn-update-preview')?.addEventListener('click', async () => { + try { + const r = await bridge.packages.dryRun('update-all'); + showOutput('Preview — command that will run', + `Action: ${r.action}\nManager: ${r.manager}\n\n$ ${r.command || '(no command)'}\n\nThis command will be run with root privileges via the cockpit superuser channel (polkit org.sysdeck.packages.modify).`, + 'preview'); + } catch (err) { + showOutput('Preview — error', String(err.message || err), 'FAIL'); + } + }); + + // ── Search ────────────────────────────────────────────────────── + panel.querySelector('#btn-search')?.addEventListener('click', async () => { + const term = panel.querySelector('#pkg-search')?.value?.trim(); + if (!term) return; + const resultsDiv = panel.querySelector('#pkg-search-results'); + if (resultsDiv) resultsDiv.textContent = 'Searching...'; + try { + const results = await bridge.packages.search(term); + if (resultsDiv) { + resultsDiv.innerHTML = results.length + ? `${results.slice(0, 20).map((r) => ``).join('')}
    PackageVersion
    ${r.name}${r.version || r.description || '—'}
    ` + : 'No packages found.'; + } + } catch (err) { + if (resultsDiv) resultsDiv.textContent = `Search failed: ${err.message || err}`; + } + }); + + panel.querySelector('#btn-pkg-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} + +function renderError(err) { + return `
    +

    Package manager unavailable

    +

    ${err.message || err}. Ensure pacman, dnf, or apt is installed.

    +
    `; +} diff --git a/plugins/sysdeck-photos/index.html b/plugins/sysdeck-photos/index.html new file mode 100755 index 0000000..a76ba54 --- /dev/null +++ b/plugins/sysdeck-photos/index.html @@ -0,0 +1,64 @@ + + + + + SysDeck Photos + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-photos/manifest.json b/plugins/sysdeck-photos/manifest.json new file mode 100755 index 0000000..792614e --- /dev/null +++ b/plugins/sysdeck-photos/manifest.json @@ -0,0 +1,32 @@ +{ + "version": 0, + "name": "sysdeck-photos", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Photos", + "order": 41, + "keywords": [ + { + "matches": [ + "photos", + "photo", + "gallery", + "photoprism", + "piwigo", + "lychee", + "nextcloud", + "memories", + "librephotos", + "image", + "album", + "media" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval' frame-src 'self' http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:*; img-src 'self' data: http://127.0.0.1:*" +} diff --git a/plugins/sysdeck-photos/photos.js b/plugins/sysdeck-photos/photos.js new file mode 100755 index 0000000..2f62f6a --- /dev/null +++ b/plugins/sysdeck-photos/photos.js @@ -0,0 +1,252 @@ +/* + * SysDeck - Photos Panel (v0.0.35) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.35 directive: "as well as a photo manager of equal quality. + * with its own module." Following the Jellyfin pattern: each + * supported backend ships as a systemd service with a built-in admin + * web UI; the bridge starts/stops/restarts the service via systemctl + * and the panel iframes the running admin UI. Equal quality means + * the same service-control + iframe-load shape as Jellyfin. + * + * Multi-backend design — same shape as the DB Control module + * (plugins/sysdeck-db/db.js): the operator chooses the backend + * installed on the host; the bridge auto-detects each backend via + * CLI availability and/or systemd unit presence. + * + * Supported backends (see bridge/photos.py BACKEND_REGISTRY): + * PhotoPrism — single Go binary · MIT · port 2342 + * Piwigo — single PHP-FPM app · GPL-2.0 · port 80 + * Lychee — single PHP-FPM app · MIT · port 80 + * Nextcloud Memories — Nextcloud plugin · AGPL-3.0 · port 80 + * LibrePhotos — Django + React · MIT · port 3000 + * + * Excluded (intentionally): NFS-mounted photo libraries (no admin + * panel — use the Remote FS module for storage management), Amanda + * (backup system, not a photo manager), Google Photos / iCloud / + * etc. (cloud-only, no on-host admin panel reachable). + * + * Bridge surface (see shared/bridge.js → bridge.photos): + * summary() → {backends, totalBackends, installedCount, runningCount} + * status(id) → single-backend detail dict + * start(id) → {action, rc, success, output, stderr} + * stop(id) → same shape + * restart(id) → same shape + * webStatus(id) → {running, url, port, ...} + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + let summary = {}; + try { + summary = await bridge.photos.summary(); + } catch (err) { + panel.innerHTML = renderError(err); + return; + } + + const backends = summary.backends || []; + const installed = backends.filter((b) => b.status !== 'uninstalled'); + const running = backends.filter((b) => b.status === 'running'); + + panel.innerHTML = ` +
    +

    Photo Manager

    +

    + Self-hosted photo & album management · multi-backend + · ${summary.totalBackends || backends.length} supported + · ${summary.installedCount || installed.length} installed + · ${summary.runningCount || running.length} running +

    +
    + +
    +
    +

    Backends

    +
    ${summary.totalBackends || backends.length}
    +
    total supported
    +
    +
    +

    Installed

    +
    ${installed.length}
    +
    ${installed.length ? 'detected on host' : 'none — install one'}
    +
    +
    +

    Running

    +
    ${running.length}
    +
    ${running.length ? 'admin UIs live' : 'all stopped'}
    +
    +
    + + ${renderBackendsGrid(backends, { bridge, EventBus })} + +
    +
    +

    + The bridge runs systemctl start/stop/restart for + the chosen backend via the cockpit superuser channel (polkit + org.sysdeck.photos.modify). The admin UI loads in + an iframe once the service is running. Each backend is a + separate process — no third-party code is bundled in the suite. +

    +
    +
    + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('photos.loaded', { installed: installed.length, running: running.length }); +} + +// ── Backend cards ────────────────────────────────────────────────── + +function renderBackendsGrid(backends, { bridge, EventBus }) { + if (!backends.length) { + return ` +
    +

    Backends (0)

    +
    +

    No photo backends registered.

    +
    +
    + `; + } + return backends.map((b) => renderBackendCard(b)).join(''); +} + +function renderBackendCard(b) { + if (b.status === 'uninstalled') { + return ` +
    +
    +

    ${escapeHtml(b.name)} · ${escapeHtml(b.id)}

    + ${escapeHtml(b.family)} +
    +
    +

    + Not installed. ${escapeHtml(b.name)} is ${escapeHtml(b.license)} + licensed — ${escapeHtml(b.homepage)} +

    +

    ${escapeHtml(b.installHint)}

    +
    +
    + `; + } + + const running = b.status === 'running'; + return ` +
    +
    +

    ${escapeHtml(b.name)} · ${escapeHtml(b.id)}

    +
    + + + + +
    +
    +
    + + + + + + + + +
    Status${statusBadge(b.status)}
    Service${escapeHtml(b.serviceUnit || '—')}
    Port${b.port}
    URL${running ? `${escapeHtml(b.url)}` : '— (service stopped)'}
    License${escapeHtml(b.license)} · ${escapeHtml(b.homepage)}
    + ${running ? renderWebIframe(b) : `

    Click ▶ Start to launch ${escapeHtml(b.name)}. The admin UI will load in an iframe below.

    `} +
    +
    + `; +} + +function renderWebIframe(b) { + return ` +
    + +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const action = async (btn, method, label) => { + const id = btn.dataset.id; + if (!id) return; + btn.disabled = true; + const original = btn.textContent; + btn.textContent = `${label} ...`; + try { + const r = await bridge.photos[method](id); + if (r?.error) { + alert(`${label} ${id} failed:\n${r.error}`); + } else if (!r?.success && method !== 'restart') { + alert(`${label} ${id} failed:\n${r?.stderr || r?.output || 'unknown'}`); + } + setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { + btn.disabled = false; + btn.textContent = original; + alert(`${label} ${id} error: ${err.message || err}`); + } + }; + + panel.querySelectorAll('.btn-photos-start').forEach((btn) => { + btn.addEventListener('click', (ev) => action(ev.currentTarget, 'start', 'Start')); + }); + panel.querySelectorAll('.btn-photos-stop').forEach((btn) => { + btn.addEventListener('click', (ev) => action(ev.currentTarget, 'stop', 'Stop')); + }); + panel.querySelectorAll('.btn-photos-restart').forEach((btn) => { + btn.addEventListener('click', (ev) => action(ev.currentTarget, 'restart', 'Restart')); + }); + panel.querySelectorAll('.btn-photos-refresh').forEach((btn) => { + btn.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +function statusBadge(status) { + const map = { + 'running': 'running', + 'starting': 'starting', + 'stopped': 'stopped', + 'error': 'error', + 'uninstalled': 'uninstalled', + 'unknown': 'unknown', + }; + return map[status] || `${escapeHtml(status || 'unknown')}`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} + +function renderError(err) { + return `
    +

    Photos bridge unavailable

    +

    ${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/photos.py.

    +
    `; +} diff --git a/plugins/sysdeck-policy/index.html b/plugins/sysdeck-policy/index.html new file mode 100755 index 0000000..1dd50a7 --- /dev/null +++ b/plugins/sysdeck-policy/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Policy + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-policy/manifest.json b/plugins/sysdeck-policy/manifest.json new file mode 100755 index 0000000..720bb20 --- /dev/null +++ b/plugins/sysdeck-policy/manifest.json @@ -0,0 +1,46 @@ +{ + "version": 0, + "name": "sysdeck-policy", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Policy", + "order": 38, + "keywords": [ + { + "matches": [ + "policy", + "acl", + "getfacl", + "setfacl", + "cgroups", + "cgroup", + "vlan", + "ebpf", + "bpf", + "namespace", + "lsns", + "apparmor", + "permissions", + "groups", + "mac", + "smack", + "tomoyo", + "yama", + "loadpin", + "lockdown", + "landlock", + "lsm", + "setcap", + "getcap", + "capabilities", + "ptrace" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-policy/policy.js b/plugins/sysdeck-policy/policy.js new file mode 100755 index 0000000..19a8826 --- /dev/null +++ b/plugins/sysdeck-policy/policy.js @@ -0,0 +1,1127 @@ +/* + * SysDeck - Policy & Permissions Panel (v0.0.32) + * Author: Jeremy Anderson (https://dcos.net) + * + * NEW MODULE in v0.0.32 — modern policy management and permissions + * manager for groups. Per user directive: + * + * "modern policy management and permissions manager for groups. + * such as acl, cgroups, vlans, ebpf namespace separation and + * related policies. we can skip selinux its native. we can + * implement apparmor but its not default on my machine so make + * it optional for sure." + * + * The panel surfaces five concerns, each with its own card: + * + * 1. ACL manager — list/set/remove/default POSIX ACLs on + * any path. Path picker + entry form. + * 2. Cgroup viewer — cgroups v2 unified hierarchy tree. + * Per-cgroup process list, controllers, + * and control file editor (memory.max, + * cpu.weight, etc.). Move PID into cgroup. + * 3. VLAN manager — list/create/delete 802.1Q VLANs on + * host interfaces via `ip link`. + * 4. eBPF programs — list loaded BPF programs (bpftool), + * list BPF maps, pin a program to bpffs. + * 5. Namespace separation — lsns output, with per-namespace + * process list. + * + * AppArmor is rendered as an OPTIONAL sixth card: the bridge auto- + * detects whether AppArmor is compiled into the kernel. If absent, + * the card shows an install hint instead of an empty table. + * + * SELinux is intentionally skipped per user directive — it is + * native to the host distro and SysDeck does not try to manage it. + * + * Mutating operations (acl-set, acl-remove, acl-default, cgroup- + * create, cgroup-move, cgroup-set, vlan-create, vlan-delete, + * ebpf-pin, apparmor-enforce, apparmor-complain) go through + * bridge.policy.() which passes { superuser: 'try' } to + * cockpit.spawn. The cockpit bridge prompts the operator via + * polkit for the org.sysdeck.policy.modify action (added in + * v0.0.32 — authorizes /usr/bin/setfacl, /bin/mkdir, /bin/mount, + * /usr/bin/ip, /usr/sbin/ip, /usr/bin/bpftool, /usr/sbin/bpftool, + * /usr/bin/aa-enforce, /usr/bin/aa-complain, /usr/bin/lsns). + * No `sudo` shell-out from JS — this is the cockpit way. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + const summary = await safe(bridge.policy.summary(), {}); + const cgroups = summary?.cgroups?.available + ? await safe(bridge.policy.cgroupList(), { cgroups: [] }) + : { available: false }; + const vlans = await safe(bridge.policy.vlanList(), { vlans: [] }); + const ebpf = summary?.ebpf?.available + ? await safe(bridge.policy.ebpfList(), {}) + : { available: false }; + const ns = summary?.namespaces?.available + ? await safe(bridge.policy.nsList(), {}) + : { available: false }; + const apparmor = await safe(bridge.policy.apparmorStatus(), {}); + + // v0.0.33: additional LSMs — fetch in parallel. Each is optional; + // the bridge returns { available: false, reason: ... } when absent. + // The render functions tolerate that shape and show an enable hint. + const [lsm, smack, tomoyo, yama, loadpin, lockdown, bpflsm, landlock, filecaps] = await Promise.all([ + safe(bridge.policy.lsmStatus(), { entries: [] }), + safe(bridge.policy.smackStatus(), {}), + safe(bridge.policy.tomoyoStatus(), {}), + safe(bridge.policy.yamaStatus(), {}), + safe(bridge.policy.loadpinStatus(), {}), + safe(bridge.policy.lockdownStatus(), {}), + safe(bridge.policy.bpflsmStatus(), {}), + safe(bridge.policy.landlockStatus(), {}), + safe(bridge.policy.filecapsList(), { entries: [] }), + ]); + + panel.innerHTML = ` +
    +

    Policy & Permissions

    +

    + ACLs · cgroups v2 · VLANs · eBPF · namespaces · filecaps + · LSMs: ${renderActiveLsmBadges(lsm)} + · SELinux skipped (native) +

    +
    + + ${renderAvailability(summary, lsm)} + + ${renderLsmStackCard(lsm)} + + ${renderAclCard()} + + ${renderCgroupsCard(cgroups, summary?.cgroups)} + + ${renderVlansCard(vlans, summary?.vlans)} + + ${renderEbpfCard(ebpf, summary?.ebpf)} + + ${renderNsCard(ns, summary?.namespaces)} + + ${renderFilecapsCard(filecaps)} + + ${renderApparmorCard(apparmor)} + + ${renderSmackCard(smack)} + + ${renderTomoyoCard(tomoyo)} + + ${renderYamaCard(yama)} + + ${renderLoadpinCard(loadpin)} + + ${renderLockdownCard(lockdown)} + + ${renderBpflsmCard(bpflsm)} + + ${renderLandlockCard(landlock)} + + + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('policy.loaded', { summary }); +} + +// ── Render helpers ─────────────────────────────────────────────────── + +// Lookup table for the capability-matrix rows. PEP 868 / MISRA: +// stable, declarative — adding a concern is one line. The probe +// lambdas are invoked during the .map() iteration below, replacing +// the previous hand-built rows array. +function _availabilityRows(summary, lsm) { + const lsmConcerns = (lsm?.entries || []).map((e) => [ + e.name, + e.active_in_stack && e.dir_present, + `kernel cmdline: lsm=...,${e.id}`, + ]); + return [ + ['ACLs (getfacl/setfacl)', summary?.acl?.available, 'pacman -S acl / apt install acl'], + ['cgroups v2', summary?.cgroups?.available, 'default on Arch/Debian 12+/Fedora 31+'], + ['VLANs (ip)', summary?.vlans?.available, 'iproute2 — universally installed'], + ['eBPF (bpftool)', summary?.ebpf?.available, 'pacman -S bpftool / apt install linux-tools-common'], + ['Namespaces (lsns)', summary?.namespaces?.available, 'util-linux — universally installed'], + ['File caps (setcap/getcap)', summary?.filecaps?.available ?? false, 'pacman -S libcap / apt install libcap-bin'], + ...lsmConcerns, + ['SELinux', false, 'skipped per user directive (native to host distro)'], + ]; +} + +function renderAvailability(summary, lsm) { + if (!summary) return ''; + const rows = _availabilityRows(summary, lsm); + const badge = (name, available) => available + ? 'yes' + : (name === 'SELinux' ? 'skipped' : 'no'); + return ` +
    +

    Capability Matrix

    + + + + ${rows.map((r) => ` + + + + + + `).join('')} + +
    ConcernAvailableInstall hint
    ${escapeHtml(r[0])}${badge(r[0], r[1])}${escapeHtml(r[2])}
    +
    + `; +} + +// Quick inline badges for the panel subtitle — shows the active LSM +// stack at a glance (e.g. "lockdown,capability,yama" → 3 badges). +function renderActiveLsmBadges(lsm) { + const stack = lsm?.active_stack || []; + if (!stack.length) return '(none active)'; + return stack.map((s) => `${escapeHtml(s)}`).join(' '); +} + +function renderLsmStackCard(lsm) { + if (!lsm || !lsm.entries || !lsm.entries.length) { + return ` +
    +

    LSM Stack

    +
    +

    + Could not probe the kernel LSM stack + (${escapeHtml(lsm?.lsm_list_file || '/sys/kernel/security/lsm')} + unreadable). securityfs may not be mounted. +

    +
    +
    + `; + } + const rows = lsm.entries.map((e) => ` + + ${escapeHtml(e.name)} + ${escapeHtml(e.id)} + ${e.active_in_stack ? 'in stack' : 'inactive'} + ${e.dir_present ? 'yes' : 'no'} + ${escapeHtml(e.path)} + + `).join(''); + return ` +
    +
    +

    LSM Stack (${lsm.active_count || 0} active)

    +
    + + + ${rows} +
    NameIDIn stacksecurityfsPath
    +

    + The active stack file is ${escapeHtml(lsm.lsm_list_file || '')}. + Set the order at boot via the lsm=... kernel cmdline. +

    +
    + `; +} + +function renderFilecapsCard(filecaps) { + if (!filecaps || filecaps.available === false) { + return ` +
    +

    File Capabilities

    +
    +

    + ${escapeHtml(filecaps?.reason || 'getcap not installed')} + ${filecaps?.install ? `
    ${escapeHtml(filecaps.install)}` : ''} +

    +
    +
    + `; + } + const entries = filecaps.entries || []; + const rows = entries.slice(0, 50).map((e) => ` + + ${escapeHtml(e.path)} + ${escapeHtml(e.caps)} + + `).join(''); + return ` +
    +
    +

    File Capabilities (${filecaps.count || 0})

    + +
    + + + + ${rows || ''} + +
    PathCaps
    No binaries with file caps.
    + ${entries.length > 50 || filecaps.truncated ? `

    Showing 50 of ${filecaps.count || 0} entries${filecaps.truncated ? ' (truncated)' : ''}.

    ` : ''} + +
    +
    + + + +
    +
    + + + +
    +
    +
    + `; +} + +// ── Per-LSM cards (v0.0.33) ──────────────────────────────────────── +// +// All eight new LSM cards follow the same shape: +// 1. If unavailable, render an enable hint with the kernel cmdline +// that activates the LSM. Decisive language — no "this is +// pending" or "to be implemented". +// 2. If available, render the live state + any management controls +// the LSM supports. +// +// The shape is uniform so the panel reads consistently across all +// LSMs even though each has its own surface. + +function renderSmackCard(smack) { + if (!smack || smack.available === false) { + return renderLsmHintCard('Smack', smack, 'security=smack / lsm=...,smack'); + } + const keys = Object.entries(smack) + .filter(([k]) => k !== 'available' && k !== 'path') + .map(([k, v]) => `${escapeHtml(k)}${escapeHtml(String(v).slice(0, 200))}`) + .join(''); + return ` +
    +
    +

    Smack

    + active +
    + ${keys}
    +
    +
    + + + +
    +
    +
    + `; +} + +function renderTomoyoCard(tomoyo) { + if (!tomoyo || tomoyo.available === false) { + return renderLsmHintCard('TOMOYO', tomoyo, 'security=tomoyo / lsm=...,tomoyo'); + } + const keys = Object.entries(tomoyo) + .filter(([k]) => k !== 'available' && k !== 'path') + .map(([k, v]) => `${escapeHtml(k)}${escapeHtml(String(v).slice(0, 200))}`) + .join(''); + return ` +
    +
    +

    TOMOYO

    + active +
    + ${keys}
    +
    +
    + + + +
    +
    +
    + `; +} + +function renderYamaCard(yama) { + if (!yama || yama.available === false) { + return renderLsmHintCard('Yama', yama, 'lsm=...,yama'); + } + const scope = yama.scope ?? -1; + const scopeName = yama.scope_name || 'unknown'; + const scopeOptions = [ + ['0', 'disabled (any ptrace)'], + ['1', 'restricted ptrace (default)'], + ['2', 'admin-only ptrace'], + ['3', 'no ptrace at all'], + ].map(([v, label]) => ``).join(''); + return ` +
    +
    +

    Yama (ptrace scope)

    + active +
    +
    +

    + Current scope: ${scope} (${escapeHtml(scopeName)}) + — file: ${escapeHtml(yama.file || '')} +

    +
    + + +
    +
    +
    + `; +} + +function renderLoadpinCard(loadpin) { + if (!loadpin || loadpin.available === false) { + return renderLsmHintCard('LoadPin', loadpin, 'lsm=...,loadpin'); + } + const keys = Object.entries(loadpin) + .filter(([k]) => k !== 'available' && k !== 'path') + .map(([k, v]) => `${escapeHtml(k)}${escapeHtml(String(v).slice(0, 200))}`) + .join(''); + return ` +
    +
    +

    LoadPin

    + active +
    + ${keys}
    +

    + LoadPin has no userspace management surface beyond the kernel + cmdline. Pinning is automatic once enabled. +

    +
    + `; +} + +function renderLockdownCard(lockdown) { + if (!lockdown || lockdown.available === false) { + return renderLsmHintCard('Lockdown', lockdown, 'UEFI secure boot enables this; no userspace toggle.'); + } + const keys = Object.entries(lockdown) + .filter(([k]) => k !== 'available' && k !== 'path') + .map(([k, v]) => `${escapeHtml(k)}${escapeHtml(String(v).slice(0, 200))}`) + .join(''); + return ` +
    +
    +

    Lockdown (UEFI secure boot)

    + active +
    + ${keys}
    +
    + `; +} + +function renderBpflsmCard(bpflsm) { + if (!bpflsm || bpflsm.available === false) { + return renderLsmHintCard('BPF-LSM', bpflsm, 'lsm=...,bpf'); + } + const progs = bpflsm.lsm_programs || []; + const rows = progs.slice(0, 30).map((p) => { + const id = p.id ?? '?'; + const name = p.name ?? '(unnamed)'; + return `${id}${escapeHtml(String(name))}`; + }).join(''); + return ` +
    +
    +

    BPF-LSM

    + active + ${bpflsm.lsm_program_count || 0} programs +
    + + + ${rows || ''} +
    IDName
    No BPF-LSM programs loaded.
    +

    + BPF-LSM programs are loaded via libbpf and queried via bpftool. + The eBPF card above shows the same programs under their full BPF list. +

    +
    + `; +} + +function renderLandlockCard(landlock) { + if (!landlock || landlock.available === false) { + return renderLsmHintCard('Landlock', landlock, 'lsm=...,landlock (requires Linux 5.13+)'); + } + const rulesets = landlock.rulesets || []; + const rows = rulesets.map((r) => ` + + ${escapeHtml(r.pid)} + ${escapeHtml(r.comm)} + ${escapeHtml(r.landlock)} + + `).join(''); + return ` +
    +
    +

    Landlock

    + active + ${landlock.processes_with_rulesets || 0} sandboxed +
    + + + ${rows || ''} +
    PIDCommandRuleset
    No processes currently sandboxed by Landlock.
    +
    + `; +} + +// Common shape for the "LSM not active" cards. Avoids duplicating +// the same install-hint scaffolding across every LSM card. +function renderLsmHintCard(name, data, hint) { + return ` +
    +

    ${escapeHtml(name)}

    +
    +

    + ${escapeHtml(data?.reason || `${name} is not active on this kernel.`)} +

    + ${data?.install_arch ? `

    Arch: ${escapeHtml(data.install_arch)}

    ` : ''} + ${data?.install_debian ? `

    Debian: ${escapeHtml(data.install_debian)}

    ` : ''} +

    + To enable: ${escapeHtml(hint)} on the kernel cmdline. + ${data?.note ? `
    ${escapeHtml(data.note)}` : ''} +

    +
    +
    + `; +} + +function renderAclCard() { + return ` +
    +
    +

    ACL Manager

    + +
    +
    +

    + POSIX ACLs extend the unix permission model with per-user and per-group entries. + Format: user:alice:rwx, group:devs:r-x, mask::rwx, + default:group:www-data:r-x (for directories — inherited by new files). +

    +
    + + +
    +
    + + + +
    +
    Click "List ACLs" to see entries on the path above.
    +
    +
    + `; +} + +function renderCgroupsCard(cgroups, summary) { + if (!summary?.available) { + return ` +
    +

    cgroups v2

    +
    +

    + cgroups v2 not mounted at /sys/fs/cgroup/. Modern Arch/Debian/Fedora + systems default to cgroups v2 — if you see this on a recent kernel, your host + may be running the v1 hierarchy only. +

    +
    +
    + `; + } + const groups = cgroups?.cgroups || []; + return ` +
    +
    +

    cgroups v2 (${groups.length})

    + +
    +
    + + + + ${groups.slice(0, 50).map((g) => ` + + + + + + + `).join('') || ''} + +
    PathControllersProcsSubtree
    ${escapeHtml(g.path)}${escapeHtml(g.controllers || '(none)')}${g.proc_count || 0}${escapeHtml(g.subtree_control || '(none)')}
    No cgroups under /sys/fs/cgroup/.
    + ${groups.length > 50 ? `

    Showing 50 of ${groups.length} cgroups.

    ` : ''} +
    +
    + +
    +
    +

    cgroup: show / set

    +
    +
    +
    + + +
    +
    Enter a cgroup path and click Show.
    +
    +
    + +
    +
    +

    cgroup: create / move

    +
    +
    +
    + + +
    +
    + + + +
    +
    + + + + +
    +
    +
    + `; +} + +function renderVlansCard(vlans, summary) { + if (!summary?.available) { + return ` +
    +

    VLANs

    +
    +

    iproute2 not installed — VLANs cannot be managed from this panel.

    +
    +
    + `; + } + const list = vlans?.vlans || []; + return ` +
    +
    +

    VLANs (${list.length})

    + +
    + + + + ${list.map((v) => ` + + + + + + `).join('') || ''} + +
    InterfaceVIDProtocol
    ${escapeHtml(v.interface)}${v.vid}${escapeHtml(v.protocol || '802.1Q')}
    No VLANs configured.
    +
    + +
    +

    VLAN: create / delete

    +
    +
    + + + +
    +
    + + + +
    +
    +
    + `; +} + +function renderEbpfCard(ebpf, summary) { + if (!summary?.available) { + return ` +
    +

    eBPF programs

    +
    +

    bpftool not installed. Install:

    +
    pacman -S bpftool        # Arch
    +apt install linux-tools-common  # Debian
    +
    +
    + `; + } + let progCount = 0; + let progList = []; + if (Array.isArray(ebpf?.programs)) { + progList = ebpf.programs; + progCount = progList.length; + } + return ` +
    +
    +

    eBPF programs (${progCount})

    +
    + + +
    +
    + + + + ${progList.slice(0, 30).map((p) => { + const id = p.id ?? p.get('id') ?? '?'; + const type = p.type ?? p.get('type') ?? '?'; + const name = p.name ?? p.get('name') ?? '(unnamed)'; + const loaded = p.loaded_at ?? p.get('loaded_at') ?? ''; + return ` + + + + + `; + }).join('') || ''} + +
    IDTypeNameLoad time
    ${id}${escapeHtml(String(type))}${escapeHtml(String(name))}${escapeHtml(String(loaded))}
    No BPF programs loaded.
    +
    + +
    +

    eBPF: pin program to bpffs

    +
    +

    + Pinning a program to /sys/fs/bpf/ makes it persistent until unmounted. +

    +
    + + + +
    +
    +
    + `; +} + +function renderNsCard(ns, summary) { + if (!summary?.available) { + return ` +
    +

    Namespaces

    +
    +

    lsns (util-linux) not installed.

    +
    +
    + `; + } + const namespaces = ns?.namespaces || []; + return ` +
    +
    +

    Namespaces (${namespaces.length})

    + +
    + + + + ${namespaces.slice(0, 50).map((n) => ` + + + + + + + + + `).join('') || ''} + +
    NSTypeNProcsPIDCommandPath
    ${escapeHtml(String(n.id ?? n.ns ?? '?'))}${escapeHtml(n.type || '?')}${n.nprocs || 0}${escapeHtml(String(n.pid ?? '?'))}${escapeHtml(n.command || '')}${escapeHtml(n.path || '')}
    No namespaces.
    + ${namespaces.length > 50 ? `

    Showing 50 of ${namespaces.length} namespaces.

    ` : ''} +
    + `; +} + +function renderApparmorCard(apparmor) { + if (!apparmor) { + return ` +
    +

    AppArmor (optional)

    +
    +

    Probing AppArmor status ...

    +
    +
    + `; + } + if (!apparmor.available) { + return ` +
    +

    AppArmor (optional — not present)

    +
    +

    + ${escapeHtml(apparmor.reason || 'AppArmor is not active on this host.')} +

    + ${apparmor.install_arch ? `

    Arch: ${escapeHtml(apparmor.install_arch)}

    ` : ''} + ${apparmor.install_debian ? `

    Debian: ${escapeHtml(apparmor.install_debian)}

    ` : ''} +

    + ${escapeHtml(apparmor.note || 'AppArmor is optional in SysDeck — the panel renders an install hint when absent.')} +

    +
    +
    + `; + } + if (apparmor.reason && apparmor.reason.includes("not installed")) { + return ` +
    +

    AppArmor (kernel active, userspace missing)

    +
    +

    ${escapeHtml(apparmor.reason)}

    + ${apparmor.install_arch ? `

    Arch: ${escapeHtml(apparmor.install_arch)}

    ` : ''} + ${apparmor.install_debian ? `

    Debian: ${escapeHtml(apparmor.install_debian)}

    ` : ''} +
    +
    + `; + } + const status = apparmor.status || {}; + const profiles = status.profiles || {}; + const mode = status.mode || 'unknown'; + return ` +
    +
    +

    AppArmor

    + kernel active + mode: ${escapeHtml(mode)} +
    +
    +

    + AppArmor is optional in SysDeck. Enforce mode blocks; Complain mode logs violations + without blocking. Switch profiles below via aa-enforce / aa-complain. +

    +
    + + + +
    +
    + Loaded profiles (${(profiles.enforce || []).length + (profiles.complain || []).length + (profiles.audit || [])}) +
    ${escapeHtml(JSON.stringify(profiles, null, 2))}
    +
    +
    +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const outputCard = panel.querySelector('#policy-output-card'); + const outputPre = panel.querySelector('#policy-output-pre'); + const outputTitle = panel.querySelector('#policy-output-title'); + const showOutput = (title, text, isError = false) => { + if (!outputCard || !outputPre) return; + outputCard.style.display = 'block'; + outputTitle.textContent = title; + outputPre.textContent = text; + outputPre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + panel.querySelector('#btn-policy-output-close')?.addEventListener('click', () => { + if (outputCard) outputCard.style.display = 'none'; + }); + + // ── ACL ──────────────────────────────────────────────────────── + panel.querySelector('#btn-acl-list')?.addEventListener('click', async () => { + const path = panel.querySelector('#acl-path')?.value?.trim(); + if (!path) { showOutput('ACL list', 'Enter a path first.', true); return; } + showOutput(`ACLs on ${path}`, 'Loading ...'); + try { + const r = await bridge.policy.aclList(path); + const out = panel.querySelector('#acl-output'); + if (r.error) { showOutput(`ACL list — error`, r.error, true); return; } + if (r.available === false) { + showOutput('ACLs unavailable', r.reason + '\n\nInstall: ' + (r.install || '(see docs)'), true); + return; + } + const lines = []; + lines.push('Base: ' + (r.base || '(unknown)')); + lines.push('Entries:'); + for (const e of (r.entries || [])) { + lines.push(` ${e.default ? 'default:' : ''}${e.kind}:${e.name || ''}:${e.perms}`); + } + lines.push(''); + lines.push('--- raw getfacl output ---'); + lines.push(r.raw || '(empty)'); + showOutput(`ACLs on ${path} (${(r.entries || []).length} entries)`, lines.join('\n')); + if (out) out.innerHTML = `${(r.entries || []).length} entries — see output below.`; + } catch (err) { showOutput('ACL list — error', String(err.message || err), true); } + }); + + const aclSet = async (op, label) => { + const path = panel.querySelector('#acl-path')?.value?.trim(); + const entry = panel.querySelector('#acl-entry')?.value?.trim(); + if (!path || !entry) { showOutput(label, 'Both path and entry are required.', true); return; } + showOutput(label, `Running setfacl on ${path} ... (cockpit will prompt for auth)`); + try { + const r = await op(path, entry); + const ok = r.applied !== undefined ? r.applied : (r.removed !== undefined ? r.removed : false); + showOutput(`${label} — ${ok ? 'success' : 'failed'}`, + `${ok ? 'OK' : 'FAILED'} rc=${r.rc ?? 'n/a'}\n\nstderr: ${r.stderr || '(empty)'}`, + !ok); + } catch (err) { showOutput(`${label} — error`, String(err.message || err), true); } + }; + panel.querySelector('#btn-acl-set')?.addEventListener('click', () => aclSet(bridge.policy.aclSet, 'setfacl -m')); + panel.querySelector('#btn-acl-remove')?.addEventListener('click', () => aclSet(bridge.policy.aclRemove, 'setfacl -x')); + panel.querySelector('#btn-acl-default')?.addEventListener('click', () => aclSet(bridge.policy.aclDefault, 'setfacl -d -m')); + + // ── cgroups ──────────────────────────────────────────────────── + panel.querySelector('#btn-cg-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + panel.querySelector('#btn-cg-show')?.addEventListener('click', async () => { + const path = panel.querySelector('#cg-path')?.value?.trim(); + if (!path) { showOutput('cgroup show', 'Path required.', true); return; } + showOutput(`cgroup ${path}`, 'Loading ...'); + try { + const r = await bridge.policy.cgroupShow(path); + if (r.error) { showOutput('cgroup show — error', r.error, true); return; } + const lines = []; + for (const [k, v] of Object.entries(r)) { + if (k === 'processes') continue; + lines.push(`${k}: ${v}`); + } + lines.push(''); + lines.push(`Processes (${r.processes?.length || 0}${r.process_count_truncated ? ' — truncated to 50' : ''}):`); + for (const p of (r.processes || [])) lines.push(` ${p.pid} ${p.comm}`); + showOutput(`cgroup ${path}`, lines.join('\n')); + } catch (err) { showOutput('cgroup show — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-cg-create')?.addEventListener('click', async () => { + const path = panel.querySelector('#cg-create-path')?.value?.trim(); + if (!path) { showOutput('cgroup create', 'Path required.', true); return; } + showOutput('cgroup create', `Creating ${path} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.cgroupCreate(path); + showOutput(`cgroup create — ${r.created ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.created); + if (r.created) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { showOutput('cgroup create — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-cg-move')?.addEventListener('click', async () => { + const pid = panel.querySelector('#cg-move-pid')?.value?.trim(); + const target = panel.querySelector('#cg-move-target')?.value?.trim(); + if (!pid || !target) { showOutput('cgroup move', 'Both PID and target required.', true); return; } + showOutput('cgroup move', `Moving PID ${pid} to ${target} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.cgroupMove(pid, target); + showOutput(`cgroup move — ${r.moved ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.moved); + } catch (err) { showOutput('cgroup move — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-cg-set')?.addEventListener('click', async () => { + const path = panel.querySelector('#cg-set-path')?.value?.trim(); + const ctrl = panel.querySelector('#cg-set-ctrl')?.value?.trim(); + const val = panel.querySelector('#cg-set-val')?.value?.trim(); + if (!path || !ctrl || !val) { showOutput('cgroup set', 'Path, control file, and value all required.', true); return; } + showOutput('cgroup set', `Writing ${val} to ${path}/${ctrl} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.cgroupSet(path, ctrl, val); + showOutput(`cgroup set — ${r.set ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.set); + } catch (err) { showOutput('cgroup set — error', String(err.message || err), true); } + }); + + // ── VLANs ────────────────────────────────────────────────────── + panel.querySelector('#btn-vlan-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + panel.querySelector('#btn-vlan-create')?.addEventListener('click', async () => { + const iface = panel.querySelector('#vlan-create-iface')?.value?.trim(); + const vid = panel.querySelector('#vlan-create-vid')?.value?.trim(); + if (!iface || !vid) { showOutput('vlan create', 'Both iface and VID required.', true); return; } + showOutput('vlan create', `Creating ${iface}.${vid} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.vlanCreate(iface, vid); + showOutput(`vlan create — ${r.created ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.created); + if (r.created) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { showOutput('vlan create — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-vlan-delete')?.addEventListener('click', async () => { + const iface = panel.querySelector('#vlan-del-iface')?.value?.trim(); + const vid = panel.querySelector('#vlan-del-vid')?.value?.trim(); + if (!iface || !vid) { showOutput('vlan delete', 'Both iface and VID required.', true); return; } + showOutput('vlan delete', `Deleting ${iface}.${vid} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.vlanDelete(iface, vid); + showOutput(`vlan delete — ${r.deleted ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.deleted); + if (r.deleted) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { showOutput('vlan delete — error', String(err.message || err), true); } + }); + + // ── eBPF ─────────────────────────────────────────────────────── + panel.querySelector('#btn-ebpf-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + panel.querySelector('#btn-ebpf-maps')?.addEventListener('click', async () => { + showOutput('eBPF maps', 'Loading ...'); + try { + const r = await bridge.policy.ebpfMaps(); + showOutput('eBPF maps', JSON.stringify(r, null, 2).slice(0, 50000)); + } catch (err) { showOutput('eBPF maps — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-ebpf-pin')?.addEventListener('click', async () => { + const id = panel.querySelector('#ebpf-pin-id')?.value?.trim(); + const path = panel.querySelector('#ebpf-pin-path')?.value?.trim(); + if (!id || !path) { showOutput('eBPF pin', 'Both program id and path required.', true); return; } + showOutput('eBPF pin', `Pinning prog ${id} to ${path} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.ebpfPin(id, path); + showOutput(`eBPF pin — ${r.pinned ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.pinned); + } catch (err) { showOutput('eBPF pin — error', String(err.message || err), true); } + }); + + // ── Namespaces ───────────────────────────────────────────────── + panel.querySelector('#btn-ns-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + + // ── AppArmor ─────────────────────────────────────────────────── + panel.querySelector('#btn-aa-enforce')?.addEventListener('click', async () => { + const profile = panel.querySelector('#aa-profile')?.value?.trim(); + if (!profile) { showOutput('aa-enforce', 'Profile name required.', true); return; } + showOutput('aa-enforce', `Switching ${profile} to enforce ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.apparmorEnforce(profile); + showOutput(`aa-enforce — ${r.enforced ? 'success' : 'failed'}`, + `output: ${r.output || '(empty)'}\nstderr: ${r.stderr || '(empty)'}`, + !r.enforced); + } catch (err) { showOutput('aa-enforce — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-aa-complain')?.addEventListener('click', async () => { + const profile = panel.querySelector('#aa-profile')?.value?.trim(); + if (!profile) { showOutput('aa-complain', 'Profile name required.', true); return; } + showOutput('aa-complain', `Switching ${profile} to complain ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.apparmorComplain(profile); + showOutput(`aa-complain — ${r.complain ? 'success' : 'failed'}`, + `output: ${r.output || '(empty)'}\nstderr: ${r.stderr || '(empty)'}`, + !r.complain); + } catch (err) { showOutput('aa-complain — error', String(err.message || err), true); } + }); + + // ── v0.0.33 LSM event wiring ─────────────────────────────────── + panel.querySelector('#btn-smack-load')?.addEventListener('click', async () => { + const f = panel.querySelector('#smack-load-file')?.value?.trim(); + if (!f) { showOutput('smackload', 'Rules file path required.', true); return; } + showOutput('smackload', `Loading Smack rules from ${f} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.smackLoad(f); + showOutput(`smackload — ${r.loaded ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.loaded); + } catch (err) { showOutput('smackload — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-smack-labels')?.addEventListener('click', async () => { + showOutput('Smack labels', 'Loading ...'); + try { + const r = await bridge.policy.smackLabels(); + showOutput(`Smack labels (${r.label_count || 0})`, + JSON.stringify(r, null, 2).slice(0, 50000)); + } catch (err) { showOutput('Smack labels — error', String(err.message || err), true); } + }); + + panel.querySelector('#btn-tomoyo-save')?.addEventListener('click', async () => { + const p = panel.querySelector('#tomoyo-save-path')?.value?.trim(); + if (!p) { showOutput('tomoyo-save', 'Output path required.', true); return; } + showOutput('tomoyo-save', `Saving TOMOYO snapshot to ${p} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.tomoyoSavePolicy(p); + showOutput(`tomoyo-save — ${r.saved ? 'success' : 'failed'}`, + JSON.stringify(r, null, 2), !r.saved); + } catch (err) { showOutput('tomoyo-save — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-tomoyo-profiles')?.addEventListener('click', async () => { + showOutput('TOMOYO profiles', 'Loading ...'); + try { + const r = await bridge.policy.tomoyoProfiles(); + showOutput('TOMOYO profiles', JSON.stringify(r, null, 2).slice(0, 50000)); + } catch (err) { showOutput('TOMOYO profiles — error', String(err.message || err), true); } + }); + + panel.querySelector('#btn-yama-set')?.addEventListener('click', async () => { + const scope = panel.querySelector('#yama-scope-select')?.value; + if (scope === undefined || scope === null) { showOutput('yama-set', 'Select a scope value.', true); return; } + showOutput('yama-set', `Setting ptrace scope to ${scope} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.yamaSetScope(parseInt(scope, 10)); + showOutput(`yama-set — ${r.set ? 'success' : 'failed'}`, + `scope=${r.scope}\nname=${r.scope_name || 'unknown'}\nfile=${r.file || ''}`, + !r.set); + } catch (err) { showOutput('yama-set — error', String(err.message || err), true); } + }); + + panel.querySelector('#btn-filecaps-refresh')?.addEventListener('click', () => mount(panel, { bridge, EventBus })); + panel.querySelector('#btn-filecaps-set')?.addEventListener('click', async () => { + const caps = panel.querySelector('#filecaps-set-caps')?.value?.trim(); + const path = panel.querySelector('#filecaps-set-path')?.value?.trim(); + if (!caps || !path) { showOutput('setcap', 'Both caps and path required.', true); return; } + showOutput('setcap', `setcap ${caps} ${path} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.filecapsSet(caps, path); + showOutput(`setcap — ${r.set ? 'success' : 'failed'}`, + `rc=${r.rc}\nstderr: ${r.stderr || '(empty)'}`, + !r.set); + if (r.set) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { showOutput('setcap — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-filecaps-show')?.addEventListener('click', async () => { + const path = panel.querySelector('#filecaps-show-path')?.value?.trim(); + if (!path) { showOutput('getcap', 'Path required.', true); return; } + showOutput(`getcap ${path}`, 'Loading ...'); + try { + const r = await bridge.policy.filecapsShow(path); + showOutput(`getcap ${path}`, `caps: ${r.caps || '(none)'}\nrc=${r.rc}\nstderr: ${r.stderr || ''}`); + } catch (err) { showOutput('getcap — error', String(err.message || err), true); } + }); + panel.querySelector('#btn-filecaps-remove')?.addEventListener('click', async () => { + const path = panel.querySelector('#filecaps-show-path')?.value?.trim(); + if (!path) { showOutput('setcap -r', 'Path required.', true); return; } + showOutput('setcap -r', `Removing caps from ${path} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.policy.filecapsRemove(path); + showOutput(`setcap -r — ${r.removed ? 'success' : 'failed'}`, + `rc=${r.rc}\nstderr: ${r.stderr || '(empty)'}`, + !r.removed); + if (r.removed) setTimeout(() => mount(panel, { bridge, EventBus }), 800); + } catch (err) { showOutput('setcap -r — error', String(err.message || err), true); } + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-remotefs/index.html b/plugins/sysdeck-remotefs/index.html new file mode 100755 index 0000000..ec95b10 --- /dev/null +++ b/plugins/sysdeck-remotefs/index.html @@ -0,0 +1,64 @@ + + + + + SysDeck Remote FS + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-remotefs/manifest.json b/plugins/sysdeck-remotefs/manifest.json new file mode 100755 index 0000000..4a07691 --- /dev/null +++ b/plugins/sysdeck-remotefs/manifest.json @@ -0,0 +1,34 @@ +{ + "version": 0, + "name": "sysdeck-remotefs", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Remote FS", + "order": 42, + "keywords": [ + { + "matches": [ + "remotefs", + "remote", + "filesystem", + "distributed", + "ceph", + "cephfs", + "gluster", + "glusterfs", + "moosefs", + "beegfs", + "orangefs", + "cluster", + "storage", + "shared" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-remotefs/remotefs.js b/plugins/sysdeck-remotefs/remotefs.js new file mode 100755 index 0000000..2e68395 --- /dev/null +++ b/plugins/sysdeck-remotefs/remotefs.js @@ -0,0 +1,296 @@ +/* + * SysDeck - Remote FS Panel (v0.0.35) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.35 directive: "then a remote fs manager such as ceph, and + * others but not nfs or amanada fs." Manages remote / distributed + * filesystem backends as systemd services and surfaces cluster + * status from each backend's CLI tool. Each backend card has + * Start / Stop / Restart controls + a cluster-info viewer. + * + * Backends shipped (see bridge/remotefs.py BACKEND_REGISTRY): + * Ceph — distributed object storage · LGPL-2.1 + * GlusterFS — scale-out network filesystem · GPL-2.0 + * MooseFS — distributed fault-tolerant FS · GPL-2.0 + * BeeGFS — parallel cluster filesystem · BeeGFS EULA (free) + * OrangeFS — parallel FS (PVFS2 successor) · BSD-3 + * + * EXCLUDED per directive — documented in the panel footer: + * NFS — kernel-builtin; no cluster; no remote-FS-as-data-store + * Amanda — backup system, not a remote/distributed filesystem + * + * Bridge surface (see shared/bridge.js → bridge.remotefs): + * summary() → {backends, totalBackends, installedCount, runningCount, excluded} + * status(id) → single-backend detail dict + * start(id) → {action, rc, success, output, stderr} + * stop(id) → same shape + * restart(id) → same shape + * clusterInfo(id) → backend-specific cluster status + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + let summary = {}; + try { + summary = await bridge.remotefs.summary(); + } catch (err) { + panel.innerHTML = renderError(err); + return; + } + + const backends = summary.backends || []; + const installed = backends.filter((b) => b.status !== 'uninstalled'); + const running = backends.filter((b) => b.status === 'running'); + + panel.innerHTML = ` +
    +

    Remote Filesystems

    +

    + Distributed & cluster filesystem management + · ${summary.totalBackends || backends.length} backends supported + · ${summary.installedCount || installed.length} installed + · ${summary.runningCount || running.length} running +

    +
    + +
    +
    +

    Backends

    +
    ${summary.totalBackends || backends.length}
    +
    total supported
    +
    +
    +

    Installed

    +
    ${installed.length}
    +
    ${installed.length ? 'detected on host' : 'none — install one'}
    +
    +
    +

    Running

    +
    ${running.length}
    +
    ${running.length ? 'clusters live' : 'all stopped'}
    +
    +
    + + ${renderBackendsGrid(backends, { bridge, EventBus })} + + ${renderExcludedCard(summary.excluded)} + +
    +
    +

    Cluster Info

    +
    +
    +

    + Click 🔍 Cluster Info on a backend card above to + query its cluster status (ceph status --format=json, + gluster pool list, moosefs-cli info, + beegfs-ctl --listnodes, or + pvfs2-server -m). The output renders below. +

    +
    +
    (no cluster info requested yet)
    +
    + `; + + wireEvents(panel, { bridge, EventBus }); + EventBus.emit('remotefs.loaded', { installed: installed.length, running: running.length }); +} + +// ── Backend cards ────────────────────────────────────────────────── + +function renderBackendsGrid(backends, { bridge, EventBus }) { + if (!backends.length) { + return ` +
    +

    Backends (0)

    +
    +

    No remote FS backends registered.

    +
    +
    + `; + } + return backends.map((b) => renderBackendCard(b)).join(''); +} + +function renderBackendCard(b) { + if (b.status === 'uninstalled') { + return ` +
    +
    +

    ${escapeHtml(b.name)} · ${escapeHtml(b.id)}

    + ${escapeHtml(b.family)} +
    +
    +

    + Not installed. ${escapeHtml(b.name)} is ${escapeHtml(b.license)} + licensed — ${escapeHtml(b.homepage)} +

    +

    ${escapeHtml(b.installHint)}

    +
    +
    + `; + } + + const running = b.status === 'running'; + return ` +
    +
    +

    ${escapeHtml(b.name)} · ${escapeHtml(b.id)}

    +
    + + + + + +
    +
    +
    + + + + + + + + + +
    Status${statusBadge(b.status)}
    Service${escapeHtml(b.serviceUnit || '—')}
    CLI tool${escapeHtml(b.cli || '—')}
    Default port${b.port}
    Config${escapeHtml(b.configPath || '—')}
    License${escapeHtml(b.license)} · ${escapeHtml(b.homepage)}
    +
    +
    + `; +} + +function renderExcludedCard(excluded) { + if (!excluded || !Object.keys(excluded).length) return ''; + const rows = Object.entries(excluded).map(([name, reason]) => ` + + ${escapeHtml(name)} + ${escapeHtml(reason)} + + `).join(''); + return ` +
    +
    +

    Intentionally excluded

    + per directive +
    +
    +

    + Per v0.0.35 directive: "and others but not nfs or amanada fs." + The following backends are intentionally excluded from this module: +

    + + + ${rows} +
    BackendReason
    +
    +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const output = panel.querySelector('#remotefs-cluster-output'); + const showCluster = (title, text, isError = false) => { + if (!output) return; + output.textContent = `${title}\n\n${text}`; + output.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + + const action = async (btn, method, label) => { + const id = btn.dataset.id; + if (!id) return; + btn.disabled = true; + const original = btn.textContent; + btn.textContent = `${label} ...`; + try { + const r = await bridge.remotefs[method](id); + if (r?.error) { + alert(`${label} ${id} failed:\n${r.error}`); + } else if (!r?.success && method !== 'restart') { + alert(`${label} ${id} failed:\n${r?.stderr || r?.output || 'unknown'}`); + } + setTimeout(() => mount(panel, { bridge, EventBus }), 1000); + } catch (err) { + btn.disabled = false; + btn.textContent = original; + alert(`${label} ${id} error: ${err.message || err}`); + } + }; + + panel.querySelectorAll('.btn-remotefs-start').forEach((btn) => { + btn.addEventListener('click', (ev) => action(ev.currentTarget, 'start', 'Start')); + }); + panel.querySelectorAll('.btn-remotefs-stop').forEach((btn) => { + btn.addEventListener('click', (ev) => action(ev.currentTarget, 'stop', 'Stop')); + }); + panel.querySelectorAll('.btn-remotefs-restart').forEach((btn) => { + btn.addEventListener('click', (ev) => action(ev.currentTarget, 'restart', 'Restart')); + }); + panel.querySelectorAll('.btn-remotefs-cluster').forEach((btn) => { + btn.addEventListener('click', async (ev) => { + const id = ev.currentTarget.dataset.id; + if (!id) return; + showCluster(`Cluster Info: ${id}`, 'Loading ...'); + try { + const r = await bridge.remotefs.clusterInfo(id); + if (r.error) { + showCluster(`Cluster Info: ${id} — error`, r.error, true); + return; + } + if (r.summary) { + showCluster(`Cluster Info: ${id} (parsed)`, + JSON.stringify(r.summary, null, 2) + '\n\n--- raw ---\n' + (r.rawText || '')); + } else { + showCluster(`Cluster Info: ${id} (raw)`, r.rawText || JSON.stringify(r, null, 2)); + } + } catch (err) { + showCluster(`Cluster Info: ${id} — error`, String(err.message || err), true); + } + }); + }); + panel.querySelectorAll('.btn-remotefs-refresh').forEach((btn) => { + btn.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +function statusBadge(status) { + const map = { + 'running': 'running', + 'starting': 'starting', + 'stopped': 'stopped', + 'error': 'error', + 'uninstalled': 'uninstalled', + 'unknown': 'unknown', + }; + return map[status] || `${escapeHtml(status || 'unknown')}`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} + +function renderError(err) { + return `
    +

    Remote FS bridge unavailable

    +

    ${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/remotefs.py.

    +
    `; +} diff --git a/plugins/sysdeck-sensors/index.html b/plugins/sysdeck-sensors/index.html new file mode 100755 index 0000000..c422d93 --- /dev/null +++ b/plugins/sysdeck-sensors/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Sensors + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-sensors/manifest.json b/plugins/sysdeck-sensors/manifest.json new file mode 100755 index 0000000..07df760 --- /dev/null +++ b/plugins/sysdeck-sensors/manifest.json @@ -0,0 +1,25 @@ +{ + "version": 0, + "name": "sysdeck-sensors", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Sensors", + "order": 35, + "keywords": [ + { + "matches": [ + "sensors", + "temperature", + "fan", + "voltage", + "lm_sensors" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-sensors/sensors.js b/plugins/sysdeck-sensors/sensors.js new file mode 100755 index 0000000..c11ac44 --- /dev/null +++ b/plugins/sysdeck-sensors/sensors.js @@ -0,0 +1,136 @@ +/* + * SysDeck - Sensors Panel (v0.0.11) + * Author: Jeremy Anderson (https://dcos.net) + * + * Hardware sensor readings from lm_sensors. Attributes the + * cockpit-sensors plugin (MIT, ocristopfer) whose standalone plugin + * lives at https://github.com/ocristopfer/cockpit-sensors. + * + * This panel invokes `sensors -j` via cockpit.spawn as a separate + * process — no cockpit-sensors code is bundled. + * + * Shows temperature, fan speed, and voltage readings grouped by + * hardware adapter. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + let data = {}; + try { + data = await bridge.sensors.summary(); + } catch (err) { + panel.innerHTML = renderError(err); + return; + } + + const temps = filterSensors(data, (key) => /temp|core|tctl/i.test(key)); + const fans = filterSensors(data, (key) => /fan/i.test(key)); + const voltages = filterSensors(data, (key) => /^in|vcore|vbat/i.test(key)); + + panel.innerHTML = ` +
    +

    Hardware Sensors

    +

    lm_sensors + cockpit-sensors integration

    + MIT · ocristopfer +
    +
    +
    +

    Temperatures

    + +
    + + + + ${Object.entries(temps).flatMap(([adapter, sensors]) => + Object.entries(sensors).map(([key, val]) => ` + + + + + `) + ).join('') || ''} + +
    AdapterSensorValueCritical
    ${adapter}${key}${sensorVal(val, 'temp')}°C${sensorCrit(val)}
    No temperature sensors.
    +
    +
    +

    Fan Speeds

    + + + + ${Object.entries(fans).flatMap(([adapter, sensors]) => + Object.entries(sensors).map(([key, val]) => ` + + + + `) + ).join('') || ''} + +
    AdapterSensorRPM
    ${adapter}${key}${sensorVal(val, 'fan')} RPM
    No fan sensors.
    +
    +
    +

    Voltages

    + + + + ${Object.entries(voltages).flatMap(([adapter, sensors]) => + Object.entries(sensors).map(([key, val]) => ` + + + + `) + ).join('') || ''} + +
    AdapterSensorVolts
    ${adapter}${key}${sensorVal(val, 'in')} V
    No voltage sensors.
    +
    + `; + + panel.querySelector('#btn-sensors-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); +} + +function filterSensors(data, predicate) { + const result = {}; + Object.entries(data).forEach(([adapter, sensors]) => { + const filtered = Object.fromEntries( + Object.entries(sensors).filter(([key]) => predicate(key)) + ); + if (Object.keys(filtered).length > 0) result[adapter] = filtered; + }); + return result; +} + +function sensorVal(val, prefix) { + if (typeof val === 'number') return val.toFixed(1); + if (typeof val === 'object' && val !== null) { + // lm_sensors JSON: { input: 45.0, crit: 100.0, max: 80.0 } + const inputKey = Object.keys(val).find((k) => k.startsWith(prefix) && k.endsWith('_input')) || 'input'; + return (val[inputKey] ?? val.input ?? 0).toFixed(1); + } + return String(val); +} + +function sensorCrit(val) { + if (typeof val === 'object' && val !== null) { + const critKey = Object.keys(val).find((k) => k.includes('crit')); + if (critKey && val[critKey] != null) return val[critKey].toFixed(1) + '°C'; + } + return '—'; +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} + +function renderError(err) { + return `
    +

    Sensors unavailable

    +

    ${err.message || err}. Install lm_sensors (pacman -S lm_sensors) and ensure sensors-detect has been run.

    +

    cockpit-sensors (MIT) by ocristopfer — https://github.com/ocristopfer/cockpit-sensors

    +
    `; +} diff --git a/plugins/sysdeck-services/index.html b/plugins/sysdeck-services/index.html new file mode 100755 index 0000000..f1f700c --- /dev/null +++ b/plugins/sysdeck-services/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Service / Ports + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-services/manifest.json b/plugins/sysdeck-services/manifest.json new file mode 100755 index 0000000..6faf1df --- /dev/null +++ b/plugins/sysdeck-services/manifest.json @@ -0,0 +1,47 @@ +{ + "version": 0, + "name": "sysdeck-services", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Service / Ports", + "order": 45, + "keywords": [ + { + "matches": [ + "service", + "services", + "port", + "ports", + "editor", + "listen", + "listening", + "tcp", + "socket", + "sshd", + "ssh", + "cockpit", + "caddy", + "varnish", + "mariadb", + "mysql", + "ollama", + "openwebui", + "open-webui", + "hermes", + "odysseus", + "forgejo", + "config", + "restart", + "set port", + "change port", + "edit port" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-services/services.js b/plugins/sysdeck-services/services.js new file mode 100755 index 0000000..0cf3abb --- /dev/null +++ b/plugins/sysdeck-services/services.js @@ -0,0 +1,439 @@ +/* + * SysDeck - Service / Port Editor Panel (v0.0.47) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.47 — PROMOTED TO ITS OWN SIDEBAR ENTRY. Per user directive: + * "we should move the service/ports editor to its own module entry + * for ease of access." The editor previously lived as a card at the + * bottom of the Firewall panel (v0.0.44); v0.0.47 lifts it into a + * first-class sidebar entry at order 45 so the operator can manage + * service ports without scrolling past the firewall ruleset table. + * + * The bridge surface is `bridge.services.*` — a thin proxy (added in + * v0.0.47) over the existing firewall.py subcommands: services, + * service-info, set-service-port, restart-service. No new bridge + * helper file was needed; the SERVICES_REGISTRY, atomic-write logic, + * and CONFIG_BASE_DIRS allowlist remain in bridge/firewall.py as the + * single source of truth. + * + * Panel layout: + * - Header (counts: N services, M editable, K listeners) + * - Filter row (search box + show-only-editable toggle + refresh) + * - Services table — one row per SERVICES_REGISTRY entry: + * · Service (name + id + editable/restartable badges) + * · Port (editable input + Save & Restart button + ↻ Restart button) + * · Config port (the value parsed from the config file) + * · Default (upstream default port) + * · Listening (ports actually bound on the host) + * · Process / PID (from ss -tlnp) + * · Config file (the resolved path under /etc/ or /usr/share/sysdeck/) + * - Unmapped listeners card — ports that didn't match any registry + * entry. The operator can spot services the editor doesn't yet + * know about and request a SERVICES_REGISTRY entry. + * - Operation output card — shows the result of the last Save / + * Restart action (success message or stderr). + * + * Security (unchanged from v0.0.44 — the bridge helper enforces all + * of this; the JS just renders the response): + * - service_id validated against SERVICES_REGISTRY (CVE-2024-2947 + * — attacker cannot trick the bridge into editing /etc/shadow). + * - Port validated with strict integer regex 1..65535, + * `re.fullmatch` to reject trailing newlines (CVE-2019-15107). + * - Config path resolved with `os.path.realpath` + base-dir + * allowlist (/etc/ or /usr/share/sysdeck/ — CVE-2022-30708 + * symlink-escape defense). + * - Port substitution uses a strict per-service regex (NOT + * freeform sed) so only the port digits are replaced. + * - systemctl invoked with `shell=False`, list argv, env scrubbed + * (CVE-2024-6126). + * - Atomic write via tmpfile + fsync + rename defeats partial-write + * corruption. + * - The `org.sysdeck.firewall.modify` polkit action (shipped since + * v0.0.17) already authorizes /usr/bin/systemctl — no polkit + * changes required. + */ + +export async function mount(panel, { bridge, EventBus }) { + panel.innerHTML = renderSkeleton(); + + const servicesResp = await safe( + bridge.services.list(), + { services: [], unmapped_listeners: [], listener_count: 0 }, + ); + + panel.innerHTML = renderPanel(servicesResp); + wireEvents(panel, { bridge, EventBus }); + + EventBus.emit('services.loaded', { + serviceCount: (servicesResp?.services || []).length, + listenerCount: servicesResp?.listener_count || 0, + }); +} + +// ── Panel render ──────────────────────────────────────────────────── + +function renderPanel(servicesResp) { + const services = servicesResp?.services || []; + const unmapped = servicesResp?.unmapped_listeners || []; + const listenerCount = servicesResp?.listener_count || 0; + const editableCount = services.filter((s) => s.editable).length; + + return ` +
    +

    Service / Port Editor

    +

    + ${services.length} services + · ${editableCount} editable + · ${listenerCount} listeners + · bridge.firewall.services() +

    +
    + + ${renderIntroCard(services, listenerCount)} + + ${services.length ? renderFilterRow() : ''} + + ${services.length + ? renderServicesTable(services) + : renderServicesEmpty()} + + ${unmapped.length ? renderUnmappedListeners(unmapped) : ''} + + ${renderNotesCard()} + + + `; +} + +function renderIntroCard(services, listenerCount) { + return ` +
    +
    +

    What this panel does

    +
    +
    +

    + The bridge enumerates every listening TCP socket on the host + (ss -tlnp, falling back to /proc/net/tcp + if unavailable) and cross-references it against the + SERVICES_REGISTRY in bridge/firewall.py + — currently ${services.length} registered services covering + SSH, Cockpit, Caddy, Varnish, MariaDB, Ollama, OpenWebUI, + Hermes, and Odysseus. Each row shows the port parsed from + the service's config file alongside any listening sockets + that match it. Edit the port in the input and click + Save & Restart — the bridge writes + the new port to the config file atomically (tmpfile + + fsync + rename) and runs systemctl restart + on the service. ${listenerCount} listening sockets + detected on this host. +

    +
    +
    + `; +} + +function renderFilterRow() { + return ` +
    +
    +
    + + + +
    +
    +
    + `; +} + +function renderServicesTable(services) { + const rows = services.map((s) => renderServiceRow(s)).join(''); + return ` +
    +
    +

    Registered Services (${services.length})

    +
    +
    + + + + + + + + + + + + + + ${rows} +
    ServicePort (editable)Config portDefaultListeningProcessPIDConfig file
    +
    +
    + `; +} + +function renderServiceRow(s) { + const listening = (s.listening_ports || []).join(', ') || '—'; + const processes = (s.processes || []).join(', ') || '—'; + const pids = (s.pids || []).join(', ') || '—'; + const editableBadge = s.editable + ? 'editable' + : 'no config'; + const restartBadge = s.restart_supported + ? 'restartable' + : 'no unit'; + const portValue = s.current_port_in_config ?? s.default_port; + const portInput = s.editable + ? `` + : `${escapeHtml(String(portValue))} (default, not detected in config)`; + const saveBtn = s.editable + ? `` + : ''; + const restartBtn = s.restart_supported + ? `` + : ''; + const configCell = s.config_file + ? `${escapeHtml(s.config_file)}` + : '—'; + const description = s.description ? `
    ${escapeHtml(s.description)}
    ` : ''; + return ` + + + ${escapeHtml(s.name)} +
    ${escapeHtml(s.id)}
    + ${editableBadge}${restartBadge} + ${description} + + ${portInput}${saveBtn}${restartBtn} + ${escapeHtml(String(s.current_port_in_config ?? '—'))} + ${escapeHtml(String(s.default_port))} + ${escapeHtml(listening)} + ${escapeHtml(processes)} + ${escapeHtml(pids)} + ${configCell} + + `; +} + +function renderServicesEmpty() { + return ` +
    +
    +

    Registered Services

    +
    +
    +

    + No services detected. This usually means the + bridge/firewall.py services subcommand + failed — check the cockpit bridge log. Listening- + socket enumeration requires ss + (iproute2) or readable /proc/net/tcp. +

    +
    +
    + `; +} + +function renderUnmappedListeners(unmapped) { + const rows = unmapped.map((l) => ` + + ${escapeHtml(String(l.port))} + ${escapeHtml(l.proto || '—')} + ${escapeHtml(l.process || '—')} + ${escapeHtml(String(l.pid || '—'))} + + `).join(''); + return ` +
    +
    +

    Unmapped Listeners (${unmapped.length})

    +
    +
    +

    + These listening sockets did not match any service in the + SERVICES_REGISTRY. To add support for a new + service, add an entry to SERVICES_REGISTRY + in bridge/firewall.py with its config file + paths and port-extraction regex. +

    + + + + + ${rows} +
    PortProtoProcessPID
    +
    +
    + `; +} + +function renderNotesCard() { + return ` +
    +
    +

    Notes & Security

    +
    +
    +

    + Save & Restart prompts for the cockpit + superuser password via polkit. The + org.sysdeck.firewall.modify action authorizes + /usr/bin/systemctl. Config-file writes are + atomic — the bridge writes to a sibling .tmp + file, fsyncs, then renames over the original. Edits are + restricted to files under /etc/ or + /usr/share/sysdeck/ (symlink-escape attacks + rejected via os.path.realpath + base-dir + allowlist). Port substitution uses a strict per-service + regex (NOT freeform sed) so only the port digits are + replaced — comments and other content on the line are + preserved. +

    +

    + This panel proxies to bridge.services.* + (added in v0.0.47), which in turn calls the existing + bridge.firewall.services / + service-info / + set-service-port / + restart-service subcommands. The + SERVICES_REGISTRY and atomic-write logic + remain in bridge/firewall.py as the single + source of truth. +

    +
    +
    + `; +} + +// ── Event wiring ──────────────────────────────────────────────────── + +function wireEvents(panel, { bridge, EventBus }) { + const output = (msg, isError = false) => { + const card = panel.querySelector('#svc-output'); + const pre = panel.querySelector('#svc-output-pre'); + if (!card || !pre) return; + card.style.display = 'block'; + pre.textContent = msg; + pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; + }; + + panel.querySelector('#btn-svc-output-close')?.addEventListener('click', () => { + const card = panel.querySelector('#svc-output'); + if (card) card.style.display = 'none'; + }); + + // Filter box: live-filter the services table by name/id/port/process. + panel.querySelector('#svc-filter')?.addEventListener('input', (ev) => { + const q = String(ev.target.value || '').toLowerCase().trim(); + const onlyEditable = panel.querySelector('#svc-only-editable')?.checked || false; + panel.querySelectorAll('.svc-row').forEach((row) => { + const text = row.dataset.searchText || ''; + const editable = row.dataset.editable === '1'; + const matchesText = !q || text.includes(q); + const matchesEditable = !onlyEditable || editable; + row.style.display = (matchesText && matchesEditable) ? '' : 'none'; + }); + }); + + // Show-only-editable toggle: re-apply the filter. + panel.querySelector('#svc-only-editable')?.addEventListener('change', () => { + panel.querySelector('#svc-filter')?.dispatchEvent(new Event('input')); + }); + + // Refresh button: re-mount the panel. + panel.querySelector('#btn-svc-refresh')?.addEventListener('click', () => { + mount(panel, { bridge, EventBus }); + }); + + // Save & Restart: read the port from the sibling input, validate + // client-side, then call bridge.services.setPort(id, port). + panel.querySelectorAll('.btn-svc-save').forEach((btn) => { + btn.addEventListener('click', async () => { + const sid = btn.dataset.serviceId; + if (!sid) return; + const input = panel.querySelector(`.svc-port-input[data-service-id="${CSS.escape(sid)}"]`); + if (!input) { output(`Could not find port input for ${sid}`, true); return; } + const portStr = String(input.value || '').trim(); + const port = Number.parseInt(portStr, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + output(`Invalid port '${portStr}' for ${sid}. Must be 1..65535.`, true); + return; + } + output(`Setting ${sid} port to ${port} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.services.setPort(sid, port); + if (r.error) { + output(`Save FAILED for ${sid}: ${r.error}`, true); + return; + } + const msg = r.restarted + ? `${r.name}: port ${r.old_port} → ${r.new_port} (config: ${r.config_file})\nService restarted via ${r.restart_method}.` + : `${r.name}: port ${r.old_port} → ${r.new_port} (config: ${r.config_file})\n⚠ Service restart FAILED (rc=${r.restart_rc}): ${r.restart_stderr || '(no stderr)'}`; + output(msg, !r.restarted); + if (r.new_port) setTimeout(() => mount(panel, { bridge, EventBus }), 1200); + } catch (err) { output(`Save error: ${err.message || err}`, true); } + }); + }); + + // Restart-only: useful when the operator edited the config by hand. + panel.querySelectorAll('.btn-svc-restart').forEach((btn) => { + btn.addEventListener('click', async () => { + const sid = btn.dataset.serviceId; + if (!sid) return; + output(`Restarting ${sid} ... (cockpit will prompt for auth)`); + try { + const r = await bridge.services.restart(sid); + if (r.error) { + output(`Restart FAILED for ${sid}: ${r.error}`, true); + return; + } + output(r.restarted + ? `${r.name} restarted via ${r.restart_method}.` + : `${r.name} restart FAILED (rc=${r.restart_rc}): ${r.restart_stderr || '(no stderr)'}`, + !r.restarted); + } catch (err) { output(`Restart error: ${err.message || err}`, true); } + }); + }); +} + +// ── Utilities ─────────────────────────────────────────────────────── + +async function safe(p, fallback) { + try { + const v = await p; + return v ?? fallback; + } catch { + return fallback; + } +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderSkeleton() { + return `
    +
    +
    +
    +
    `; +} diff --git a/plugins/sysdeck-themes/index.html b/plugins/sysdeck-themes/index.html new file mode 100755 index 0000000..2dc1a33 --- /dev/null +++ b/plugins/sysdeck-themes/index.html @@ -0,0 +1,69 @@ + + + + + SysDeck Themes + + + + + +
    +
    Loading…
    +
    + + + diff --git a/plugins/sysdeck-themes/manifest.json b/plugins/sysdeck-themes/manifest.json new file mode 100755 index 0000000..7a7aff8 --- /dev/null +++ b/plugins/sysdeck-themes/manifest.json @@ -0,0 +1,24 @@ +{ + "version": 0, + "name": "sysdeck-themes", + "requires": { + "cockpit": "239" + }, + "menu": { + "index": { + "label": "Themes", + "order": 32, + "keywords": [ + { + "matches": [ + "themes", + "appearance", + "cockpit.conf", + "styling" + ] + } + ] + } + }, + "content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'" +} diff --git a/plugins/sysdeck-themes/themes.js b/plugins/sysdeck-themes/themes.js new file mode 100755 index 0000000..108f4df --- /dev/null +++ b/plugins/sysdeck-themes/themes.js @@ -0,0 +1,427 @@ +/* + * SysDeck - Theme Engine Panel (v0.0.34) + * Author: Jeremy Anderson (https://dcos.net) + * + * v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive: + * "themes and mining they need to be expanded for maximum ui + * control. think 1999 power tool style here." The Theme Engine + * panel surfaces every theming knob SysDeck exposes: + * + * - Preset gallery 6 built-in presets (Midnight, Alpine, + * Forest, Amber, Violet, High Contrast) + * + operator-dropped JSON presets. + * - Cockpit.conf editor raw text editor + parsed section/key + * view. Set/unset individual keys. + * - CSS variable surface every --sysdeck-* custom property in + * shared/sysdeck.css, with color pickers, + * number inputs, and density select. + * - Live preview applied overrides inject a + + + + + + + + + + + diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/web/scripts/make-master-tarball.sh b/web/scripts/make-master-tarball.sh new file mode 100755 index 0000000..ded4047 --- /dev/null +++ b/web/scripts/make-master-tarball.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# SysDeck master tarball builder +# +# Bundles into ONE distributable (sysdeck--master.tar.bz2): +# / — the cockpit edition (bridge/ + plugins/ + shared/ + docs) +# /web — the SysDeck Web Edition (this Next.js project) +# /web/mini-services/fester — Fester, vendored + pre-integrated +# +# Preconditions: +# - master-build/cockpit/ holds the staged + upgraded cockpit tree +# (fester.py / fester.js / bridge.js upgraded, Makefile at 0.2.0) +# +# Output: +# public/download/sysdeck--master.tar.bz2 (+ .sha256) +# download/ (sandbox mirror) +set -euo pipefail + +VERSION="0.2.0" +NAME="sysdeck-${VERSION}-master" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STAGE_PARENT="$ROOT/master-build" +STAGE="$STAGE_PARENT/$NAME" +OUT_DIR="$ROOT/public/download" +OUT="$OUT_DIR/$NAME.tar.bz2" + +echo ">>> Building $NAME" + +# ── guards: the cockpit tree must be upgraded before packing ────────── +grep -q 'start-build' "$STAGE_PARENT/cockpit/bridge/fester.py" || { + echo "FAIL: master-build/cockpit/bridge/fester.py is not upgraded (no start-build subcommand)"; exit 1; } +grep -q 'VERSION := 0.2.0' "$STAGE_PARENT/cockpit/Makefile" || { + echo "FAIL: master-build/cockpit/Makefile is not bumped to 0.2.0"; exit 1; } + +rm -rf "$STAGE" +mkdir -p "$STAGE" "$OUT_DIR" "$ROOT/download" + +# ── 1. cockpit edition (staged + upgraded tree) ──────────────────────── +tar -C "$STAGE_PARENT/cockpit" -cf - \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='*.tar.bz2' \ + . | tar -C "$STAGE" -xf - + +# ── 2. web edition (this project; no build output, no node_modules) ─── +mkdir -p "$STAGE/web" "$STAGE/web/db" +tar -C "$ROOT" -cf - \ + --exclude='node_modules' \ + --exclude='.next' \ + --exclude='dev.log' \ + --exclude='server.log' \ + --exclude='worklog.md' \ + --exclude='worklog-*.md' \ + --exclude='tool-results' \ + --exclude='upload' \ + --exclude='download' \ + --exclude='master-build' \ + --exclude='public/download' \ + --exclude='.git' \ + --exclude='.zscripts' \ + --exclude='skills' \ + --exclude='examples' \ + --exclude='tests' \ + --exclude='tsconfig.tsbuildinfo' \ + --exclude='mini-services/fester/fester.db' \ + --exclude='mini-services/fester/fester.db-shm' \ + --exclude='mini-services/fester/fester.db-wal' \ + --exclude='mini-services/fester/node_modules' \ + src prisma public mini-services scripts package.json bun.lock \ + next.config.ts postcss.config.mjs tailwind.config.ts tsconfig.json \ + components.json eslint.config.mjs Caddyfile \ + | tar -C "$STAGE/web" -xf - + +# portable DATABASE_URL for the bundled copy (prisma resolves relative to +# prisma/schema.prisma → /db/custom.db) +printf 'DATABASE_URL=file:../db/custom.db\n' > "$STAGE/web/.env" + +cat > "$STAGE/web/README.md" <<'EOF' +# SysDeck Web Edition + +The browser-native rendition of SysDeck: 28 bridge modules behind one +console — real /proc + /sys collectors where the host allows, honest demo +datasets (clearly badged) where backends are absent, and the Fester DAG +orchestrator vendored as a dedicated service (`mini-services/fester`). + +## Run (Bun) + + bun install + bun run db:push # create + migrate db/custom.db (SQLite) + (cd mini-services/fester && bun install) + bun run dev # Next.js on :3000 + (cd mini-services/fester && bun run dev) # fester service on :3010 + +Then open http://localhost:3000. The Fester panel proxies REST through +`/api/fester` and streams WebSocket events through the gateway +(`/?XTransformPort=3010`). + +## Layout + +- `src/lib/sysdeck/` — module registry + bridge dispatcher modules +- `src/app/api/bridge` — the bridge endpoint (POST {module, command, args}) +- `src/app/api/fester` — server-side proxy to the fester service +- `src/app/api/release` — master-tarball release metadata +- `src/components/sysdeck/` — panels + shared UI primitives +- `mini-services/fester` — the vendored Fester service (own package, v0.2.1) + +`db/custom.db` is created by `bun run db:push` using `DATABASE_URL` from +`.env`. The master tarball builder lives at `scripts/make-master-tarball.sh` +in the canonical development tree. +EOF + +# ── 3. pack ──────────────────────────────────────────────────────────── +tar -cjf "$OUT" -C "$STAGE_PARENT" "$NAME" +SHA="$(sha256sum "$OUT" | cut -d' ' -f1)" +SIZE="$(stat -c %s "$OUT")" +printf '%s %s\n' "$SHA" "$NAME.tar.bz2" > "$OUT.sha256" + +# sandbox mirror + summary +cp -f "$OUT" "$OUT.sha256" "$ROOT/download/" + +FILES="$(tar -tjf "$OUT" | wc -l)" +echo ">>> $OUT" +echo " files: $FILES size: $SIZE bytes" +echo " sha256: $SHA" diff --git a/web/src/app/api/bridge/route.ts b/web/src/app/api/bridge/route.ts new file mode 100644 index 0000000..085f5ab --- /dev/null +++ b/web/src/app/api/bridge/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { BRIDGE_MODULES } from '@/lib/sysdeck/bridge' + +export const dynamic = 'force-dynamic' + +export async function POST(req: Request) { + let body: { module?: string; command?: string; args?: Record } + try { + body = await req.json() + } catch { + return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }) + } + + const modName = body.module?.trim() + const command = body.command?.trim() + if (!modName || !command) { + return NextResponse.json( + { ok: false, error: 'module and command are required' }, + { status: 400 }, + ) + } + + // Guards: module must be registered; command must be declared in its + // COMMANDS map — same contract the python bridge enforces per-file. + const mod = BRIDGE_MODULES[modName] as + | { commands: Record) => Promise> } + | undefined + if (!mod) { + return NextResponse.json({ ok: false, error: `unknown module: ${modName}` }, { status: 404 }) + } + const handler = mod.commands[command] + if (!handler) { + return NextResponse.json( + { ok: false, error: `unknown command: ${modName}.${command}` }, + { status: 404 }, + ) + } + + const args = body.args ?? {} + try { + const out = await handler(args) + // Commands may return {data, source, note} or plain data. + if ( + out && + typeof out === 'object' && + ('data' in (out as Record) || 'source' in (out as Record)) && + 'ok' in (out as Record) + ) { + return NextResponse.json({ ...(out as object), module: modName, command }) + } + return NextResponse.json({ ok: true, data: out, module: modName, command }) + } catch (err) { + return NextResponse.json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + module: modName, + command, + }, + { status: 200 }, + ) + } +} + +// Health probe for the shell: confirms the bridge dispatcher + db client are alive. +export async function GET() { + let dbOk = true + try { + await db.sdKv.count() + } catch { + dbOk = false + } + return NextResponse.json({ + ok: true, + db: dbOk, + modules: Object.keys(BRIDGE_MODULES).length, + version: '0.2.0', + }) +} diff --git a/web/src/app/api/fester/route.ts b/web/src/app/api/fester/route.ts new file mode 100644 index 0000000..5c7f5e4 --- /dev/null +++ b/web/src/app/api/fester/route.ts @@ -0,0 +1,79 @@ +// Fester sub-app server-side proxy — the only sanctioned path from the +// browser to the fester mini-service REST surface (port 3010). +// +// Browser contract (keeps the browser on relative URLs only): +// POST /api/fester { path: '/api/builds', method?: 'GET'|'POST', body?: object } +// → fetches http://127.0.0.1:3010${path} with method/body, 4s timeout, +// and streams back the upstream JSON + status verbatim. +// GET /api/fester → proxy of the fester service health endpoint. +// +// The browser WebSocket goes DIRECT (never through this route): +// new WebSocket(`${ws|wss}://${location.host}/?XTransformPort=3010`) +import { NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' + +const UPSTREAM = 'http://127.0.0.1:3010' +const TIMEOUT_MS = 4000 + +/** Guard: only /api/… paths, no traversal, no foreign schemes, sane length. */ +function safePath(p: unknown): string | null { + if (typeof p !== 'string') return null + if (!p.startsWith('/api/')) return null + if (p.length > 512) return null + if (p.includes('..') || p.includes('\\') || p.includes('\0')) return null + // RFC 3986 pchar + '/' — rejects anything the fester router would never use + if (!/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?%]+$/.test(p)) return null + return p +} + +/** Shared upstream fetch — returns the upstream JSON body + status verbatim. */ +async function proxyFetch(path: string, method: 'GET' | 'POST', body?: unknown): Promise { + try { + const res = await fetch(`${UPSTREAM}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: method === 'POST' && body !== undefined ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(TIMEOUT_MS), + cache: 'no-store', + }) + const text = await res.text() + return new NextResponse(text === '' ? JSON.stringify({ ok: false, error: 'empty upstream body' }) : text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (err) { + return NextResponse.json( + { + ok: false, + error: `fester service unreachable on 127.0.0.1:3010 (${err instanceof Error ? err.message : String(err)})`, + }, + { status: 502 }, + ) + } +} + +export async function POST(req: Request) { + let payload: { path?: unknown; method?: unknown; body?: unknown } + try { + payload = (await req.json()) as typeof payload + } catch { + return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }) + } + + const path = safePath(payload.path) + if (!path) { + return NextResponse.json({ ok: false, error: 'path is required and must start with /api/' }, { status: 403 }) + } + + const method = payload.method === undefined || payload.method === null ? 'GET' : payload.method + if (method !== 'GET' && method !== 'POST') { + return NextResponse.json({ ok: false, error: "method must be 'GET' or 'POST'" }, { status: 403 }) + } + + return proxyFetch(path, method as 'GET' | 'POST', payload.body) +} + +export async function GET() { + return proxyFetch('/api/health', 'GET') +} diff --git a/web/src/app/api/release/route.ts b/web/src/app/api/release/route.ts new file mode 100644 index 0000000..ed9cc67 --- /dev/null +++ b/web/src/app/api/release/route.ts @@ -0,0 +1,64 @@ +// Master tarball release metadata — reports the sha256/size of the +// sysdeck-0.2.0-master bundle in public/download (built by +// scripts/make-master-tarball.sh). The tarball itself is served +// statically at /download/sysdeck-0.2.0-master.tar.bz2. +// +// The bundle: cockpit edition (26 plugins + python bridge) + web edition +// (this Next.js app) + fester pre-integrated (vendored at +// web/mini-services/fester, its own version). +import { NextResponse } from 'next/server' +import { createHash } from 'node:crypto' +import { readFile, stat } from 'node:fs/promises' +import path from 'node:path' + +export const dynamic = 'force-dynamic' + +const FILE = 'sysdeck-0.2.0-master.tar.bz2' +const DOWNLOAD_DIR = path.join(process.cwd(), 'public', 'download') + +// hash cache — recompute when the file size OR mtime changes (rebuild) +let cache: { sizeBytes: number; sha256: string; builtAt: string; mtimeMs: number } | null = null + +export async function GET() { + const filePath = path.join(DOWNLOAD_DIR, FILE) + try { + const st = await stat(filePath) + if (!cache || cache.sizeBytes !== st.size || cache.mtimeMs !== st.mtimeMs) { + const buf = await readFile(filePath) + cache = { + sizeBytes: st.size, + sha256: createHash('sha256').update(buf).digest('hex'), + builtAt: st.mtime.toISOString(), + mtimeMs: st.mtimeMs, + } + } + return NextResponse.json({ + ok: true, + version: '0.2.0', + edition: 'master', + file: FILE, + url: `/download/${FILE}`, + sizeBytes: cache.sizeBytes, + sha256: cache.sha256, + builtAt: cache.builtAt, + fester: { + version: '0.2.1', + vendoredAt: 'web/mini-services/fester', + transport: 'rest+ws :3010', + }, + bundle: { + cockpit: '26 cockpit plugins + python bridge (upstream layout)', + web: 'SysDeck Web Edition — Next.js 16, 28 bridge modules', + fester: 'Fester DAG orchestration service, pre-integrated', + }, + }) + } catch { + return NextResponse.json( + { + ok: false, + error: 'master tarball not built yet — run scripts/make-master-tarball.sh', + }, + { status: 404 }, + ) + } +} diff --git a/web/src/app/api/route.ts b/web/src/app/api/route.ts new file mode 100644 index 0000000..0277710 --- /dev/null +++ b/web/src/app/api/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ message: "Hello, world!" }); +} \ No newline at end of file diff --git a/web/src/app/globals.css b/web/src/app/globals.css new file mode 100644 index 0000000..2f09002 --- /dev/null +++ b/web/src/app/globals.css @@ -0,0 +1,394 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* ════════════════════════════════════════════════════════════════════ + SysDeck Web Edition — theme engine (v0.2.0) + Six console themes, swappable live from the Themes module. + Ported from the cockpit edition's bridge/themes.py palette registry. + ════════════════════════════════════════════════════════════════════ */ + +/* Midnight — the SysDeck default: #1e1e1e console, teal accent (port of + --sysdeck-accent-success #3c9; the tarball's #06c is demoted to secondary) */ +[data-sd-theme='midnight'] { + --background: oklch(0.16 0.005 260); + --foreground: oklch(0.93 0 0); + --card: oklch(0.205 0.005 260); + --card-foreground: oklch(0.93 0 0); + --popover: oklch(0.205 0.005 260); + --popover-foreground: oklch(0.93 0 0); + --primary: oklch(0.72 0.13 178); + --primary-foreground: oklch(0.14 0.02 178); + --secondary: oklch(0.26 0.005 260); + --secondary-foreground: oklch(0.93 0 0); + --muted: oklch(0.26 0.005 260); + --muted-foreground: oklch(0.65 0 0); + --accent: oklch(0.26 0.01 178); + --accent-foreground: oklch(0.93 0 0); + --destructive: oklch(0.62 0.21 25); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.72 0.13 178); + --chart-1: oklch(0.72 0.13 178); + --chart-2: oklch(0.75 0.15 85); + --chart-3: oklch(0.68 0.2 25); + --chart-4: oklch(0.75 0.1 260); + --chart-5: oklch(0.8 0.07 178); + --sidebar: oklch(0.18 0.005 260); + --sidebar-foreground: oklch(0.93 0 0); + --sidebar-primary: oklch(0.72 0.13 178); + --sidebar-primary-foreground: oklch(0.14 0.02 178); + --sidebar-accent: oklch(0.26 0.01 178); + --sidebar-accent-foreground: oklch(0.93 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.72 0.13 178); +} + +/* Carbon — near-black, monochrome with a faint teal edge */ +[data-sd-theme='carbon'] { + --background: oklch(0.11 0 0); + --foreground: oklch(0.9 0 0); + --card: oklch(0.15 0 0); + --card-foreground: oklch(0.9 0 0); + --popover: oklch(0.15 0 0); + --popover-foreground: oklch(0.9 0 0); + --primary: oklch(0.75 0.09 178); + --primary-foreground: oklch(0.12 0 0); + --secondary: oklch(0.2 0 0); + --secondary-foreground: oklch(0.9 0 0); + --muted: oklch(0.2 0 0); + --muted-foreground: oklch(0.58 0 0); + --accent: oklch(0.2 0.005 178); + --accent-foreground: oklch(0.9 0 0); + --destructive: oklch(0.6 0.2 25); + --border: oklch(1 0 0 / 9%); + --input: oklch(1 0 0 / 13%); + --ring: oklch(0.75 0.09 178); + --chart-1: oklch(0.75 0.09 178); + --chart-2: oklch(0.7 0 0); + --chart-3: oklch(0.6 0.15 25); + --chart-4: oklch(0.5 0 0); + --chart-5: oklch(0.85 0.05 178); + --sidebar: oklch(0.125 0 0); + --sidebar-foreground: oklch(0.9 0 0); + --sidebar-primary: oklch(0.75 0.09 178); + --sidebar-primary-foreground: oklch(0.12 0 0); + --sidebar-accent: oklch(0.2 0.005 178); + --sidebar-accent-foreground: oklch(0.9 0 0); + --sidebar-border: oklch(1 0 0 / 9%); + --sidebar-ring: oklch(0.75 0.09 178); +} + +/* Phosphor — green CRT terminal */ +[data-sd-theme='phosphor'] { + --background: oklch(0.13 0.02 145); + --foreground: oklch(0.88 0.14 145); + --card: oklch(0.17 0.02 145); + --card-foreground: oklch(0.88 0.14 145); + --popover: oklch(0.17 0.02 145); + --popover-foreground: oklch(0.88 0.14 145); + --primary: oklch(0.8 0.18 145); + --primary-foreground: oklch(0.15 0.03 145); + --secondary: oklch(0.22 0.02 145); + --secondary-foreground: oklch(0.88 0.14 145); + --muted: oklch(0.22 0.02 145); + --muted-foreground: oklch(0.65 0.1 145); + --accent: oklch(0.22 0.04 145); + --accent-foreground: oklch(0.88 0.14 145); + --destructive: oklch(0.65 0.2 25); + --border: oklch(0.5 0.08 145); + --input: oklch(0.4 0.08 145); + --ring: oklch(0.8 0.18 145); + --chart-1: oklch(0.8 0.18 145); + --chart-2: oklch(0.75 0.12 100); + --chart-3: oklch(0.7 0.15 25); + --chart-4: oklch(0.65 0.1 145); + --chart-5: oklch(0.85 0.1 145); + --sidebar: oklch(0.15 0.02 145); + --sidebar-foreground: oklch(0.88 0.14 145); + --sidebar-primary: oklch(0.8 0.18 145); + --sidebar-primary-foreground: oklch(0.15 0.03 145); + --sidebar-accent: oklch(0.22 0.04 145); + --sidebar-accent-foreground: oklch(0.88 0.14 145); + --sidebar-border: oklch(0.5 0.08 145); + --sidebar-ring: oklch(0.8 0.18 145); +} + +/* Amber — amber CRT terminal */ +[data-sd-theme='amber'] { + --background: oklch(0.14 0.02 70); + --foreground: oklch(0.87 0.12 75); + --card: oklch(0.18 0.02 70); + --card-foreground: oklch(0.87 0.12 75); + --popover: oklch(0.18 0.02 70); + --popover-foreground: oklch(0.87 0.12 75); + --primary: oklch(0.78 0.15 70); + --primary-foreground: oklch(0.16 0.03 70); + --secondary: oklch(0.23 0.02 70); + --secondary-foreground: oklch(0.87 0.12 75); + --muted: oklch(0.23 0.02 70); + --muted-foreground: oklch(0.64 0.09 75); + --accent: oklch(0.23 0.04 70); + --accent-foreground: oklch(0.87 0.12 75); + --destructive: oklch(0.62 0.2 25); + --border: oklch(0.5 0.08 70); + --input: oklch(0.4 0.08 70); + --ring: oklch(0.78 0.15 70); + --chart-1: oklch(0.78 0.15 70); + --chart-2: oklch(0.75 0.1 100); + --chart-3: oklch(0.65 0.2 25); + --chart-4: oklch(0.65 0.1 70); + --chart-5: oklch(0.85 0.08 70); + --sidebar: oklch(0.16 0.02 70); + --sidebar-foreground: oklch(0.87 0.12 75); + --sidebar-primary: oklch(0.78 0.15 70); + --sidebar-primary-foreground: oklch(0.16 0.03 70); + --sidebar-accent: oklch(0.23 0.04 70); + --sidebar-accent-foreground: oklch(0.87 0.12 75); + --sidebar-border: oklch(0.5 0.08 70); + --sidebar-ring: oklch(0.78 0.15 70); +} + +/* Paper — light reading theme */ +[data-sd-theme='paper'] { + --background: oklch(0.975 0 0); + --foreground: oklch(0.2 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.2 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.2 0 0); + --primary: oklch(0.55 0.11 178); + --primary-foreground: oklch(0.99 0 0); + --secondary: oklch(0.95 0 0); + --secondary-foreground: oklch(0.2 0 0); + --muted: oklch(0.95 0 0); + --muted-foreground: oklch(0.45 0 0); + --accent: oklch(0.93 0.02 178); + --accent-foreground: oklch(0.2 0 0); + --destructive: oklch(0.55 0.2 25); + --border: oklch(0.87 0 0); + --input: oklch(0.87 0 0); + --ring: oklch(0.55 0.11 178); + --chart-1: oklch(0.55 0.11 178); + --chart-2: oklch(0.7 0.13 85); + --chart-3: oklch(0.6 0.19 25); + --chart-4: oklch(0.6 0.1 260); + --chart-5: oklch(0.7 0.07 178); + --sidebar: oklch(0.96 0 0); + --sidebar-foreground: oklch(0.2 0 0); + --sidebar-primary: oklch(0.55 0.11 178); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.93 0.02 178); + --sidebar-accent-foreground: oklch(0.2 0 0); + --sidebar-border: oklch(0.87 0 0); + --sidebar-ring: oklch(0.55 0.11 178); +} + +/* Arctic — cool light theme */ +[data-sd-theme='arctic'] { + --background: oklch(0.97 0.005 230); + --foreground: oklch(0.25 0.01 230); + --card: oklch(1 0 0); + --card-foreground: oklch(0.25 0.01 230); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.25 0.01 230); + --primary: oklch(0.6 0.1 200); + --primary-foreground: oklch(0.99 0 0); + --secondary: oklch(0.94 0.01 230); + --secondary-foreground: oklch(0.25 0.01 230); + --muted: oklch(0.94 0.01 230); + --muted-foreground: oklch(0.5 0.01 230); + --accent: oklch(0.92 0.02 200); + --accent-foreground: oklch(0.25 0.01 230); + --destructive: oklch(0.55 0.2 25); + --border: oklch(0.86 0.01 230); + --input: oklch(0.86 0.01 230); + --ring: oklch(0.6 0.1 200); + --chart-1: oklch(0.6 0.1 200); + --chart-2: oklch(0.7 0.12 145); + --chart-3: oklch(0.65 0.18 25); + --chart-4: oklch(0.62 0.12 260); + --chart-5: oklch(0.72 0.08 200); + --sidebar: oklch(0.955 0.008 230); + --sidebar-foreground: oklch(0.25 0.01 230); + --sidebar-primary: oklch(0.6 0.1 200); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.92 0.02 200); + --sidebar-accent-foreground: oklch(0.25 0.01 230); + --sidebar-border: oklch(0.86 0.01 230); + --sidebar-ring: oklch(0.6 0.1 200); +} + +/* ── SysDeck component classes ───────────────────────────────────── */ + +.sd-scroll::-webkit-scrollbar, +.sd-scroll-dark::-webkit-scrollbar { + width: 8px; + height: 8px; +} +.sd-scroll::-webkit-scrollbar-thumb, +.sd-scroll-dark::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--muted-foreground) 35%, transparent); + border-radius: 4px; +} +.sd-scroll::-webkit-scrollbar-thumb:hover, +.sd-scroll-dark::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--muted-foreground) 55%, transparent); +} +.sd-scroll::-webkit-scrollbar-track, +.sd-scroll-dark::-webkit-scrollbar-track { + background: transparent; +} + +.sd-glow { + box-shadow: 0 0 24px color-mix(in oklab, var(--primary) 22%, transparent); +} + +/* subtle grid backdrop for the overview hero */ +.sd-grid-bg { + background-image: + linear-gradient(color-mix(in oklab, var(--border) 60%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in oklab, var(--border) 60%, transparent) 1px, transparent 1px); + background-size: 28px 28px; +} + +@keyframes sd-pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} +.sd-live-dot { + animation: sd-pulse-dot 2s ease-in-out infinite; +} + +@keyframes sd-flow { + 0% { stroke-dashoffset: 24; } + 100% { stroke-dashoffset: 0; } +} +.sd-flow-edge { + stroke-dasharray: 6 6; + animation: sd-flow 1.2s linear infinite; +} + +@keyframes sd-shimmer { + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 0000000..08f5315 --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Toaster } from "@/components/ui/toaster"; +import { QueryProvider } from "@/components/sysdeck/query-provider"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "SysDeck — Web Edition", + description: + "SysDeck v0.2.0 — twenty-six domain modules behind one dashboard. Containers, firewall, integrity, mesh, vaults, fleet, Kata, firmware, image building, Fester DAG orchestration and more.", + keywords: [ + "SysDeck", + "system dashboard", + "Linux operations", + "Cockpit", + "Fester", + "DAG builds", + "monitoring", + ], + authors: [{ name: "Jeremy Anderson", url: "https://dcos.net" }], +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 0000000..34ad9ab --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,356 @@ +'use client' + +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import { PANEL_MAP } from '@/components/sysdeck/panels-map' +import { MODULE_MAP, GROUP_LABELS, modulesByGroup, SYSDECK_VERSION } from '@/lib/sysdeck/registry' +import { bridgeCall, useBridgeQuery } from '@/lib/sysdeck/client' +import type { HostTicker } from '@/lib/sysdeck/types' +import { Button } from '@/components/ui/button' +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '@/components/ui/sheet' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { Toaster } from '@/components/ui/sonner' +import { PanelSkeleton } from '@/components/sysdeck/ui' +import { cn } from '@/lib/utils' +import { + Activity, + ArrowDownToLine, + Boxes, + Cpu, + Database, + Flame, + Gauge, + HardDrive, + LayoutDashboard, + MemoryStick, + Menu, + Moon, + Network, + Palette, + Server, + Shield, + Terminal, + Waves, + type LucideIcon, +} from 'lucide-react' + +const GROUP_ICONS: Record = { + system: Gauge, + security: Shield, + compute: Boxes, + build: Terminal, + media: Palette, + integrations: Activity, +} + +const MODULE_ICONS: Record = { + overview: LayoutDashboard, + glances: Activity, + sensors: Flame, + fleet: Server, + services: Network, + packages: Boxes, + benchmark: Gauge, + firmware: HardDrive, + themes: Palette, + firewall: Shield, + netsec: Network, + integrity: Shield, + vault: Database, + auth: HardDrive, + hwalert: Shield, + policy: Shield, + containers: Boxes, + mesh: Network, + kata: Boxes, + remotefs: HardDrive, + db: Database, + fester: Terminal, + builder: HardDrive, + mining: Cpu, + jellyfin: Palette, + photos: Palette, + monitoring: Activity, + modules: Boxes, +} + +export const SD_THEMES = [ + { id: 'midnight', name: 'Midnight', swatch: '#1e1e1e' }, + { id: 'carbon', name: 'Carbon', swatch: '#0b0b0b' }, + { id: 'phosphor', name: 'Phosphor', swatch: '#0f1f12' }, + { id: 'amber', name: 'Amber', swatch: '#1f1405' }, + { id: 'paper', name: 'Paper', swatch: '#f5f5f4' }, + { id: 'arctic', name: 'Arctic', swatch: '#eef2f5' }, +] + +function fmtUptime(s: number): string { + const d = Math.floor(s / 86400) + const h = Math.floor((s % 86400) / 3600) + const m = Math.floor((s % 3600) / 60) + if (d > 0) return `${d}d ${h}h` + if (h > 0) return `${h}h ${m}m` + return `${m}m` +} + +export default function Home() { + const [active, setActive] = useState('overview') + const [navOpen, setNavOpen] = useState(false) + const [paletteOpen, setPaletteOpen] = useState(false) + const [theme, setTheme] = useState('midnight') + + const meta = MODULE_MAP[active] + + // theme boot + persistence through the themes bridge module + useEffect(() => { + bridgeCall('themes', 'getActive').then((r) => { + const t = r.ok && typeof r.data === 'string' ? r.data : 'midnight' + setTheme(t) + document.documentElement.dataset.sdTheme = t + }) + }, []) + + const applyTheme = useCallback((t: string) => { + setTheme(t) + document.documentElement.dataset.sdTheme = t + bridgeCall('themes', 'setActive', { theme: t }) + }, []) + + // Ctrl/Cmd+K opens the module palette + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + setPaletteOpen((o) => !o) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + // live host ticker for the status bar (real /proc data via the overview bridge) + const ticker = useBridgeQuery('overview', 'ticker', undefined, { refetchInterval: 5000 }) + const t = ticker.data?.data + + const groups = useMemo(() => modulesByGroup(), []) + + const goto = useCallback((id: string) => { + setActive(id) + setNavOpen(false) + setPaletteOpen(false) + }, []) + + const ActivePanel = PANEL_MAP[active] ?? PANEL_MAP.overview + + const sidebar = ( + + ) + + return ( +
    +
    + {/* desktop sidebar */} + + +
    + {/* header */} +
    + + + + + + SysDeck modules + {sidebar} + + + +
    +

    {meta?.name ?? 'Overview'}

    +

    + {meta?.description ?? ''} +

    +
    + + + + + + + + + + + Theme engine + + {SD_THEMES.map((th) => ( + applyTheme(th.id)} className="gap-2"> + + {th.name} + {theme === th.id ? ✓ : null} + + ))} + + +
    + + {/* main panel */} +
    + }> + + +
    +
    +
    + + {/* sticky footer status bar */} +
    +
    + + + cpu {t ? `${t.cpuPct.toFixed(0)}%` : '—'} + + + + mem{' '} + + {t ? `${(t.memUsedMb / 1024).toFixed(1)}/${(t.memTotalMb / 1024).toFixed(1)}G` : '—'} + + + + load {t ? t.load1.toFixed(2) : '—'} + + + up {t ? fmtUptime(t.uptimeS) : '—'} + + + procs {t ? t.procs : '—'} + + + + fester {t?.fester ?? 'offline'} + + SysDeck v{SYSDECK_VERSION} · web edition +
    +
    + + {/* command palette */} + + + + No module found. + {groups.map(({ group, modules }) => ( + + {modules.map((m) => ( + goto(m.id)}> + # + {m.name} + + ))} + + ))} + + + + {/* single app-wide sonner toaster */} + +
    + ) +} diff --git a/web/src/components/sysdeck/panels-map.tsx b/web/src/components/sysdeck/panels-map.tsx new file mode 100644 index 0000000..c6d9d8c --- /dev/null +++ b/web/src/components/sysdeck/panels-map.tsx @@ -0,0 +1,38 @@ +'use client' + +// Panel import map — one lazy entry per SysDeck module. +// UI workstreams rewrite individual panel files; this map never changes. + +import { lazy } from 'react' +import type { ComponentType } from 'react' + +export const PANEL_MAP: Record = { + overview: lazy(() => import('./panels/overviewPanel')), + glances: lazy(() => import('./panels/glancesPanel')), + sensors: lazy(() => import('./panels/sensorsPanel')), + fleet: lazy(() => import('./panels/fleetPanel')), + services: lazy(() => import('./panels/servicesPanel')), + packages: lazy(() => import('./panels/packagesPanel')), + benchmark: lazy(() => import('./panels/benchmarkPanel')), + firmware: lazy(() => import('./panels/firmwarePanel')), + themes: lazy(() => import('./panels/themesPanel')), + firewall: lazy(() => import('./panels/firewallPanel')), + netsec: lazy(() => import('./panels/netsecPanel')), + integrity: lazy(() => import('./panels/integrityPanel')), + vault: lazy(() => import('./panels/vaultPanel')), + auth: lazy(() => import('./panels/authPanel')), + hwalert: lazy(() => import('./panels/hwalertPanel')), + policy: lazy(() => import('./panels/policyPanel')), + containers: lazy(() => import('./panels/containersPanel')), + mesh: lazy(() => import('./panels/meshPanel')), + kata: lazy(() => import('./panels/kataPanel')), + remotefs: lazy(() => import('./panels/remotefsPanel')), + db: lazy(() => import('./panels/dbPanel')), + fester: lazy(() => import('./panels/festerPanel')), + builder: lazy(() => import('./panels/builderPanel')), + mining: lazy(() => import('./panels/miningPanel')), + jellyfin: lazy(() => import('./panels/jellyfinPanel')), + photos: lazy(() => import('./panels/photosPanel')), + monitoring: lazy(() => import('./panels/monitoringPanel')), + modules: lazy(() => import('./panels/modulesPanel')), +} diff --git a/web/src/components/sysdeck/panels/_stub.tsx b/web/src/components/sysdeck/panels/_stub.tsx new file mode 100644 index 0000000..9a41831 --- /dev/null +++ b/web/src/components/sysdeck/panels/_stub.tsx @@ -0,0 +1,26 @@ +'use client' + +// Placeholder shown while a module panel is under construction. +import { PanelHeader } from '@/components/sysdeck/ui' +import { MODULE_MAP } from '@/lib/sysdeck/registry' +import { Skeleton } from '@/components/ui/skeleton' + +export default function ModuleUnderConstruction({ id }: { id: string }) { + const meta = MODULE_MAP[id] + return ( +
    + +
    + + +

    + panel assembly in progress — bridge module {id} online +

    +
    +
    + ) +} diff --git a/web/src/components/sysdeck/panels/authPanel.tsx b/web/src/components/sysdeck/panels/authPanel.tsx new file mode 100644 index 0000000..661609e --- /dev/null +++ b/web/src/components/sysdeck/panels/authPanel.tsx @@ -0,0 +1,381 @@ +'use client' + +// Auth panel — PKCS#11 smartcard identity (readers, certificates, session). +// opensc/pcsc-lite are absent in this sandbox, so the bridge keeps a demo +// inventory (honestly labeled): 2 readers, 4 realistic certs (one expiring +// in 12 days, one expired), and a simulated C_Login with the ISO 7816 +// '6982' status word on wrong PINs. Demo PIN: 123456. + +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { CreditCard, IdCard, KeyRound, Lock, LockOpen, ShieldCheck, Smartphone } from 'lucide-react' +import { useBridgeAction, useBridgeQuery } from '@/lib/sysdeck/client' +import { + DataTable, + ErrorCard, + InstallHint, + KV, + Mono, + PanelCard, + PanelHeader, + PanelSkeleton, + StatCard, + StateBadge, +} from '@/components/sysdeck/ui' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { TableCell } from '@/components/ui/table' +// panel mounts its own (only one panel is live at a time, so no duplicates) + +// ── bridge shapes ──────────────────────────────────────────────────── + +interface Reader { + id: string + name: string + vendor: string + slots: number + pinSupport: string + present: boolean +} + +interface Cert { + id: string + label: string + kind: string + keyType: string + subject: string + notBefore: string + notAfter: string + status: 'valid' | 'expiring' | 'expired' + daysLeft: number +} + +interface SessionState { + loggedIn: boolean + reader: string | null + slot: number | null + mechanism: string | null + ts: string | null +} + +interface SessionsData { + session: SessionState + slots: { reader: string; slot: number; token: string }[] + availableCerts: number +} + +// ── helpers ────────────────────────────────────────────────────────── + +function fmtUtc(iso: string): string { + return `${iso.slice(0, 10)}` +} + +function certBadge(status: string) { + if (status === 'expired') { + return expired + } + if (status === 'expiring') { + return <30d + } + return valid +} + +// ── unlock dialog ──────────────────────────────────────────────────── + +function UnlockDialog({ reader, onUnlock }: { reader: Reader; onUnlock: (pin: string) => Promise }) { + const [open, setOpen] = useState(false) + const [pin, setPin] = useState('') + const [busy, setBusy] = useState(false) + + async function submit() { + setBusy(true) + try { + const ok = await onUnlock(pin) + if (ok) { + setOpen(false) + setPin('') + } + } finally { + setBusy(false) + } + } + + return ( + + + + + + + + unlock {reader.name} + + + PKCS#11 C_Login (CKU_USER, slot 0) — card PIN verification. Wrong PINs return the ISO 7816{' '} + SW=6982 status word and burn an attempt (3 before the card locks). Demo PIN: 123456. + + +
    +
    + + setPin(e.target.value)} + placeholder="••••••" + className="font-mono tracking-widest" + onKeyDown={(e) => { + if (e.key === 'Enter' && pin.length > 0 && !busy) void submit() + }} + /> +
    +

    + {reader.id} · {reader.pinSupport} +

    +
    + + + + +
    +
    + ) +} + +// ── panel ──────────────────────────────────────────────────────────── + +export default function AuthPanel() { + const readersQ = useBridgeQuery<{ readers: Reader[]; count: number; pcscd: string }>('auth', 'readers', undefined, { + refetchInterval: 10000, + }) + const certsQ = useBridgeQuery<{ certs: Cert[]; count: number }>('auth', 'certs', undefined, { refetchInterval: 10000 }) + const sessionsQ = useBridgeQuery('auth', 'sessions', undefined, { refetchInterval: 8000 }) + const action = useBridgeAction() + + const readers = useMemo(() => readersQ.data?.data?.readers ?? [], [readersQ.data]) + const certs = useMemo(() => certsQ.data?.data?.certs ?? [], [certsQ.data]) + const session = sessionsQ.data?.data?.session + const slots = sessionsQ.data?.data?.slots ?? [] + const expiringSoon = certs.filter((c) => c.status === 'expiring').length + const presentReaders = readers.filter((r) => r.present).length + + async function unlock(reader: Reader, pin: string): Promise { + const res = await action('auth', 'unlock', { reader: reader.id, pin }) + if (res.ok) { + toast.success('PKCS#11 session opened', { + description: `C_Login CKU_USER on slot 0 — ${reader.name} (${reader.id})`, + }) + return true + } + toast.error('PIN verification failed', { + description: res.error, + duration: 8000, + }) + return false + } + + async function lockSession() { + const res = await action('auth', 'lock') + if (res.ok) { + toast.success('PKCS#11 session closed', { description: 'the card is locked again' }) + } else { + toast.error('lock failed', { description: res.error }) + } + } + + if (readersQ.isLoading && !readersQ.data) return + if (readersQ.data && !readersQ.data.ok) return + + return ( +
    + + + {/* stat cards */} +
    + } + hint={`${presentReaders} with a card present`} + /> + } + hint="PIV / openvpn slots" + /> + + } + hint={session?.loggedIn ? session.reader ?? '' : 'no card logged in'} + /> +
    + +
    + {/* readers */} +
    + {readersQ.data?.data?.pcscd ?? 'pcscd n/a'}}> +
    + {readers.map((r) => { + const unlocked = session?.loggedIn && session.reader === r.name + return ( +
    +
    +
    +

    + + {r.name} + {unlocked ? : null} +

    +

    + {r.vendor} · {r.id} · {r.slots} slot{r.slots === 1 ? '' : 's'} +

    +

    {r.pinSupport}

    +
    +
    + + {r.present ? 'card present' : 'empty'} + + {r.present ? ( + unlocked ? null : ( + unlock(r, pin)} /> + ) + ) : null} +
    +
    +
    + ) + })} + {readers.length === 0 ?

    no readers detected

    : null} +
    +
    + + +
    + + {/* session + certs */} +
    + void lockSession()}> + + lock + + ) : null + } + > +
    + : 'no'} mono={false} /> + + + {session?.mechanism ?? '—'}} /> + + +
    +
    +

    token slots

    + `${s.reader}-${s.slot}`} + maxH="10rem" + renderRow={(s) => ( + <> + {s.reader} + {s.slot} + + + {s.token} + + + + )} + /> +
    +
    + + + + {certs.length} on card + + } + > + c.id} + maxH="24rem" + renderRow={(c) => ( + <> + + {c.label} + + + {c.kind} + + {c.keyType} + + {fmtUtc(c.notBefore)} → {fmtUtc(c.notAfter)} + + + + {c.daysLeft} + + + {certBadge(c.status)} + + )} + /> +

    + expiring <30d amber · expired red — the VPN client cert is seeded 12 days out on purpose. +

    +
    +
    +
    +
    + ) +} diff --git a/web/src/components/sysdeck/panels/benchmarkPanel.tsx b/web/src/components/sysdeck/panels/benchmarkPanel.tsx new file mode 100644 index 0000000..c7ad026 --- /dev/null +++ b/web/src/components/sysdeck/panels/benchmarkPanel.tsx @@ -0,0 +1,282 @@ +'use client' + +// Benchmark panel — real micro-benchmarks (cpu / memory / disk suites) +// executed by the bridge, persisted to BenchmarkResult + audit log. +// Run buttons stream loading state while the suite actually executes +// (hundreds of ms to seconds), then toast the measured score. + +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { Cpu, HardDrive, Loader2, MemoryStick, Play } from 'lucide-react' +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { useBridgeAction, useBridgeQuery } from '@/lib/sysdeck/client' +import { + DataTable, + ErrorCard, + KV, + Mono, + PanelCard, + PanelHeader, + PanelSkeleton, + StatCard, +} from '@/components/sysdeck/ui' +import { Button } from '@/components/ui/button' +import { TableCell } from '@/components/ui/table' +// panel mounts its own (only one panel is live at a time, so no duplicates) + +// ── bridge shapes ──────────────────────────────────────────────────── + +interface SuiteRun { + score: number + ts: string +} + +interface BenchmarkHistory { + suites: Record + total: number + last: { suite: string; score: number; ts: string } | null +} + +interface RunResult { + suite: string + score: number + metric: string + detail: Record + durationMs: number +} + +// ── suite metadata ─────────────────────────────────────────────────── + +const SUITES: { id: string; name: string; icon: typeof Cpu; blurb: string }[] = [ + { id: 'cpu', name: 'CPU', icon: Cpu, blurb: '3 rounds · 50M int adds + 50M sqrt + 100k string concats (checksum-guarded)' }, + { id: 'memory', name: 'Memory', icon: MemoryStick, blurb: '4 × 64MB buffers — write pattern, read back, verify' }, + { id: 'disk', name: 'Disk', icon: HardDrive, blurb: '64MB through /tmp in 1MB chunks — write, read back, unlink' }, +] + +const CHART_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)'] + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour12: false }) +} + +// ── panel ──────────────────────────────────────────────────────────── + +export default function BenchmarkPanel() { + const history = useBridgeQuery('benchmark', 'history') + const action = useBridgeAction() + const [running, setRunning] = useState(null) + const [suite, setSuite] = useState('cpu') + const [lastRun, setLastRun] = useState(null) + + const h = history.data?.data + + const runs = useMemo(() => { + const flat: (SuiteRun & { suite: string })[] = [] + for (const [s, rows] of Object.entries(h?.suites ?? {})) { + for (const r of rows) flat.push({ ...r, suite: s }) + } + flat.sort((a, b) => (a.ts < b.ts ? 1 : -1)) + return flat + }, [h]) + + const bestOf = (id: string): { score: number; ts: string } | null => { + const rows = h?.suites?.[id] + if (!rows || rows.length === 0) return null + return rows.reduce((best, r) => (r.score > best.score ? r : best), rows[0]!) + } + + const chartData = useMemo(() => (h?.suites?.[suite] ?? []).map((r) => ({ ts: fmtTime(r.ts), score: r.score })), [h, suite]) + + async function run(id: string) { + setRunning(id) + try { + const res = await action('benchmark', 'run', { suite: id }) + if (res.ok) { + const d = res.data as RunResult + setLastRun(d) + toast.success(`${d.suite} benchmark complete — score ${d.score}`, { + description: `${d.metric} · measured in ${d.durationMs} ms`, + }) + } else { + toast.error('benchmark failed', { description: res.error }) + } + } finally { + setRunning(null) + } + } + + if (history.isLoading && !history.data) return + if (history.data && !history.data.ok) return + + return ( +
    + + + {/* suite cards */} +
    + {SUITES.map((s) => { + const rows = h?.suites?.[s.id] ?? [] + const last = rows.length > 0 ? rows[0] : null // bridge returns newest-first + const best = bestOf(s.id) + const busy = running === s.id + return ( + +
    +
    +
    + +
    +
    +

    {s.name} suite

    + {rows.length} runs recorded +
    +
    + +
    +
    +

    last score

    +

    {last ? last.score : '—'}

    +

    + {last ? `${fmtTime(last.ts)}` : 'never run'}{best ? ` · best ${best.score}` : ''} +

    +
    +

    {s.blurb}

    +
    + ) + })} +
    + + {/* score history chart */} + + {SUITES.map((s, i) => ( + + ))} +
    + } + > +
    + {chartData.length < 2 ? ( +
    + {chartData.length === 1 ? 'one run recorded — run again for a trend' : 'no runs recorded yet — hit Run'} +
    + ) : ( + + + + + + + + + + )} +
    + + + {/* history table + last run detail */} +
    + {h?.total ?? 0} runs · newest first}> + `${r.suite}-${r.ts}-${i}`} + maxH="24rem" + empty="no benchmark runs recorded" + renderRow={(r) => ( + <> + {r.suite} + {r.score} + + {metricOf(r.suite)} + + {new Date(r.ts).toLocaleString()} + + )} + /> + + +
    + {h?.last ? ( + + ) : null} + + {lastRun ? ( +
    + + {lastRun.score}} /> + + {lastRun.metric}} /> +
    +

    measured

    + {Object.entries(lastRun.detail).map(([k, v]) => ( + {String(v)}} /> + ))} +
    +
    + ) : ( +

    + run a suite to see the measured detail block — ops counts, timings, bandwidths and the checksum sink. +

    + )} +
    +
    +
    + + ) +} + +function metricOf(suite: string): string { + if (suite === 'cpu') return 'score = round(1e6 / total ms) — higher is better' + if (suite === 'memory') return 'MB/s combined write+read bandwidth (4 × 64MB buffers)' + return 'MB/s (average of 64MB write + 64MB read on /tmp)' +} diff --git a/web/src/components/sysdeck/panels/builderPanel.tsx b/web/src/components/sysdeck/panels/builderPanel.tsx new file mode 100644 index 0000000..b066c7e --- /dev/null +++ b/web/src/components/sysdeck/panels/builderPanel.tsx @@ -0,0 +1,766 @@ +'use client' + +// Builder panel — the image-builder workbench (mkosi / vmdb2 / archiso / +// live-build). HYBRID: no build backends exist in this sandbox (builds are +// simulated with staged logs + artifacts), but importHostPackages reads +// the REAL dpkg database (932 packages on this host). Profiles are a +// Prisma registry; every mutation is audited. + +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { Boxes, Copy, Download, FileArchive, Hammer, PackagePlus, Play, Trash2 } from 'lucide-react' +import { useBridgeAction, useBridgeQuery } from '@/lib/sysdeck/client' +import { + DataTable, + ErrorCard, + KV, + Mono, + PanelCard, + PanelHeader, + PanelSkeleton, + StatCard, + StateBadge, +} from '@/components/sysdeck/ui' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { TableCell } from '@/components/ui/table' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' + +// ── bridge shapes ──────────────────────────────────────────────────── + +interface SdProfile { + id: string + name: string + backend: string + base: string | null + packages: string[] + createdAt: string + updatedAt: string + packageCount: number +} + +interface SdArtifact { + id: string + buildId: string + path: string + sizeBytes: number + createdAt: string + state?: string +} + +interface SdBuild { + id: string + buildId: string + profile: string + backend: string + state: 'queued' | 'running' | 'succeeded' | 'failed' + rc: number + durationMs: number + log: string + startedAt: string + finishedAt: string | null + artifacts: SdArtifact[] +} + +// ── helpers ────────────────────────────────────────────────────────── + +const BACKEND_CLS: Record = { + mkosi: 'border-teal-500/30 bg-teal-500/15 text-teal-400', + vmdb2: 'border-sky-500/30 bg-sky-500/15 text-sky-400', + archiso: 'border-violet-500/30 bg-violet-500/15 text-violet-300', + 'live-build': 'border-amber-500/30 bg-amber-500/15 text-amber-500', +} + +function backendBadge(backend: string) { + return ( + + {backend} + + ) +} + +function fmtBytes(b: number): string { + if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GB` + if (b >= 1024 ** 2) return `${(b / 1024 ** 2).toFixed(1)} MB` + return `${(b / 1024).toFixed(0)} KB` +} + +function fmtDuration(ms: number): string { + if (ms <= 0) return '—' + if (ms < 1000) return `${ms} ms` + return `${(ms / 1000).toFixed(1)} s` +} + +function fmtDate(iso: string): string { + return iso.slice(0, 19).replace('T', ' ') +} + +// ── panel ──────────────────────────────────────────────────────────── + +export default function BuilderPanel() { + const summary = useBridgeQuery<{ profiles: number; builds: number; backendsInstalled: string[] }>('builder', 'summary') + const profiles = useBridgeQuery<{ profiles: SdProfile[]; count: number }>('builder', 'profiles') + const action = useBridgeAction() + + const [tab, setTab] = useState('profiles') + const [expanded, setExpanded] = useState(null) + const [busyName, setBusyName] = useState(null) + + // create form + const [newName, setNewName] = useState('') + const [newBackend, setNewBackend] = useState('mkosi') + const [newPackages, setNewPackages] = useState('') + const [creating, setCreating] = useState(false) + + // copy dialog + const [copySrc, setCopySrc] = useState(null) + const [copyName, setCopyName] = useState('') + const [copying, setCopying] = useState(false) + + // import dialog + const [importTarget, setImportTarget] = useState(null) + const [importMode, setImportMode] = useState<'append' | 'replace'>('append') + const [importing, setImporting] = useState(false) + + // delete dialog + const [deleteTarget, setDeleteTarget] = useState(null) + + // artifact dialog + const [artifactRecord, setArtifactRecord] = useState(null) + const [artifactProfile, setArtifactProfile] = useState('myarch') + const [clearTarget, setClearTarget] = useState(null) + + const buildsActive = useMemo(() => { + // poll faster while a build is in flight (queued/running) + return tab === 'builds' ? 5000 : 20000 + }, [tab]) + + const builds = useBridgeQuery<{ builds: SdBuild[]; count: number }>('builder', 'builds', {}, { refetchInterval: buildsActive }) + const anyRunning = (builds.data?.data?.builds ?? []).some((b) => b.state === 'running' || b.state === 'queued') + const artifacts = useBridgeQuery<{ profile: string; artifacts: SdArtifact[]; count?: number; builds: number }>( + 'builder', + 'artifacts', + { profile: artifactProfile }, + { refetchInterval: anyRunning ? 5000 : 20000, enabled: tab === 'artifacts' }, + ) + + async function runBuild(p: SdProfile) { + setBusyName(p.name) + try { + const res = await action('builder', 'build', { profile: p.name }) + if (res.ok) { + const d = res.data as { buildId?: string; state?: string; build?: SdBuild } + toast.success(`build started: ${p.name}`, { + description: `${d.buildId ?? ''} → ${d.state ?? 'succeeded'} in ${fmtDuration(d.build?.durationMs ?? 0)} — see the Builds tab`, + }) + setTab('builds') + } else { + toast.error(`build ${p.name} failed`, { description: res.error, duration: 8000 }) + } + } finally { + setBusyName(null) + } + } + + async function doImport() { + const p = importTarget + if (!p) return + setImporting(true) + try { + const res = await action('builder', 'importHostPackages', { profile: p.name, mode: importMode }) + if (res.ok) { + const d = res.data as { count?: number } + toast.success(`${d.count ?? 0} packages imported from dpkg`, { + description: `${p.name} (${importMode}) — REAL dpkg-query read, capped at 300 stored names`, + }) + setImportTarget(null) + } else { + toast.error(`import into ${p.name} refused`, { description: res.error, duration: 8000 }) + } + } finally { + setImporting(false) + } + } + + async function doCopy() { + const src = copySrc + if (!src || !copyName.trim()) return + setCopying(true) + try { + const res = await action('builder', 'copy', { src: src.name, name: copyName.trim() }) + if (res.ok) { + toast.success(`copied ${src.name} → ${copyName.trim()}`, { + description: `${src.packageCount} packages duplicated`, + }) + setCopySrc(null) + setCopyName('') + } else { + toast.error(`copy refused`, { description: res.error, duration: 8000 }) + } + } finally { + setCopying(false) + } + } + + async function doCreate() { + const name = newName.trim() + if (!name) { + toast.error('profile name is required') + return + } + const pkgs = newPackages + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0) + setCreating(true) + try { + const res = await action('builder', 'create', { + name, + backend: newBackend, + packages: pkgs, + }) + if (res.ok) { + toast.success(`profile created: ${name}`, { + description: `${newBackend} · ${pkgs.length} packages`, + }) + setNewName('') + setNewPackages('') + } else { + toast.error('create refused', { description: res.error, duration: 8000 }) + } + } finally { + setCreating(false) + } + } + + async function doDelete() { + const p = deleteTarget + if (!p) return + setDeleteTarget(null) + setBusyName(p.name) + try { + const res = await action('builder', 'delete', { name: p.name }) + if (res.ok) { + const d = res.data as { deleted?: { builds?: number } } + toast.success(`deleted ${p.name}`, { + description: `cascade: ${d.deleted?.builds ?? 0} builds + their artifacts`, + }) + if (artifactProfile === p.name) setArtifactProfile('myarch') + } else { + toast.error(`delete ${p.name} refused`, { description: res.error, duration: 8000 }) + } + } finally { + setBusyName(null) + } + } + + async function cancelBuild(buildId: string) { + const res = await action('builder', 'cancel', { buildId }) + if (res.ok) { + toast.warning(`cancelled ${buildId}`, { + description: `state → failed (rc 130) — the schema has no 'cancelled' state, artifacts dropped`, + }) + } else { + toast.error(`cancel refused`, { description: res.error, duration: 8000 }) + } + } + + async function deleteArtifact(id: string) { + const res = await action('builder', 'deleteArtifact', { id }) + if (res.ok) { + toast.success('artifact deleted') + } else { + toast.error('deleteArtifact refused', { description: res.error, duration: 8000 }) + } + } + + async function doClearArtifacts() { + const profile = clearTarget + if (!profile) return + setClearTarget(null) + const res = await action('builder', 'clearArtifacts', { profile }) + if (res.ok) { + toast.success(`cleared all artifacts of ${profile}`) + } else { + toast.error('clearArtifacts refused', { description: res.error, duration: 8000 }) + } + } + + if (summary.isLoading || profiles.isLoading) { + return ( +
    + + +
    + ) + } + + if (!summary.data?.ok || !summary.data.data) { + return ( +
    + + +
    + ) + } + + const s = summary.data.data + const profileRows = profiles.data?.data?.profiles ?? [] + const buildRows = builds.data?.data?.builds ?? [] + const artifactRows = artifacts.data?.data?.artifacts ?? [] + + return ( +
    + + +
    + } /> + } hint={anyRunning ? 'build in flight' : 'none running'} /> + 0 ? 'good' : 'warn'} + icon={} + hint={s.backendsInstalled.length > 0 ? s.backendsInstalled.join(' · ') : 'none — builds are simulated'} + /> +
    + + + + + profiles {profileRows.length} + + + builds {buildRows.length} + + artifacts + + + {/* ── profiles ─────────────────────────────────────────────── */} + + builder create}> +
    +
    +
    + + setNewName(e.target.value)} + placeholder="mydistro" + className="font-mono text-xs" + /> +
    +
    + + +
    + +
    +
    + +