Compare commits

..

114 Commits

Author SHA1 Message Date
0c9eb0653d v1.24.2
All checks were successful
Release / build-and-release (push) Successful in 3m37s
2026-03-24 20:17:30 +00:00
ed6a35eb86 fix(deps): bump runtime and build tool dependencies 2026-03-24 20:17:30 +00:00
242677404b v1.24.1
Some checks failed
Release / build-and-release (push) Failing after 24s
2026-03-24 20:08:25 +00:00
8c6159c596 fix(repo): migrate smart build config to .smartconfig.json and tidy repository metadata 2026-03-24 20:08:25 +00:00
c210507951 v1.24.0
All checks were successful
Release / build-and-release (push) Successful in 3m6s
2026-03-24 19:54:56 +00:00
0799efadae feat(backup): add containerarchive-backed backup storage, restore, download, and pruning support 2026-03-24 19:54:56 +00:00
22a7e76645 v1.23.0
All checks were successful
Release / build-and-release (push) Successful in 3m24s
2026-03-21 19:36:25 +00:00
22f34e7de5 feat(appstore): add remote app store templates with service upgrades and Redis/MariaDB platform support 2026-03-21 19:36:25 +00:00
c0f9f979c7 v1.22.2
All checks were successful
Release / build-and-release (push) Successful in 2m59s
2026-03-18 09:48:12 +00:00
6e5743c837 fix(web-ui): stabilize app store service creation flow and add Ghost sqlite defaults 2026-03-18 09:48:12 +00:00
829f7e47f1 v1.22.1
Some checks failed
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m19s
Release / build-and-release (push) Successful in 3m45s
2026-03-18 02:44:14 +00:00
a36af5c3d5 fix(repo): no changes to commit 2026-03-18 02:44:14 +00:00
3c99ee5f83 v1.22.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m1s
CI / Build All Platforms (push) Successful in 2m11s
Release / build-and-release (push) Successful in 3m56s
2026-03-18 02:37:39 +00:00
2faa416895 feat(web-appstore): add an App Store view for quick service deployment from curated templates 2026-03-18 02:37:39 +00:00
acbf448c6f v1.21.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 27s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m13s
Release / build-and-release (push) Successful in 3m34s
2026-03-18 02:22:45 +00:00
5c48ae4156 feat(opsserver): add container workspace API and backend execution environment for services 2026-03-18 02:22:45 +00:00
3108408133 v1.20.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 39s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 2m52s
Release / build-and-release (push) Successful in 6m2s
2026-03-17 23:39:25 +00:00
6defdb4431 feat(ops-dashboard): stream user service logs to the ops dashboard and resolve service containers for Docker log streaming 2026-03-17 23:39:24 +00:00
f63be883ce v1.19.12
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
CI / Build All Platforms (push) Failing after 13m15s
CI / Build Test (Current Platform) (push) Failing after 13m17s
CI / Type Check & Lint (push) Failing after 13m19s
Release / build-and-release (push) Successful in 6m6s
2026-03-17 12:52:34 +00:00
87844bbb8e fix(repo): no changes to commit 2026-03-17 12:52:34 +00:00
02b7cda2be v1.19.11
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 1m0s
CI / Build All Platforms (push) Successful in 1m57s
Release / build-and-release (push) Successful in 5m51s
2026-03-17 10:40:40 +00:00
3b8f95e8e1 fix(repo): no changes to commit 2026-03-17 10:40:40 +00:00
ee774e3f41 v1.19.10
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 38s
CI / Build Test (Current Platform) (push) Successful in 1m6s
CI / Build All Platforms (push) Successful in 2m17s
Release / build-and-release (push) Successful in 7m46s
2026-03-17 02:09:50 +00:00
6d93dfa459 fix(repo): no changes to commit 2026-03-17 02:09:50 +00:00
ac394cfafc v1.19.9
Some checks failed
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m0s
Release / build-and-release (push) Successful in 2m51s
2026-03-17 01:53:38 +00:00
97e9f232fa fix(repo): no changes to commit 2026-03-17 01:53:38 +00:00
3dcb6a38e5 v1.19.8
Some checks failed
CI / Build All Platforms (push) Failing after 9s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 59s
Release / build-and-release (push) Failing after 1m24s
2026-03-17 01:37:18 +00:00
ca33970e9a fix(repo): no changes to commit 2026-03-17 01:37:18 +00:00
cd34b98a25 v1.19.7
Some checks failed
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 33s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 1m52s
Release / build-and-release (push) Successful in 3m50s
2026-03-17 01:03:14 +00:00
a089e5bedb fix(repo): no changes to commit 2026-03-17 01:03:14 +00:00
9786ff62f0 v1.19.6
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 31s
CI / Build Test (Current Platform) (push) Successful in 1m6s
CI / Build All Platforms (push) Successful in 2m15s
Release / build-and-release (push) Failing after 3m30s
2026-03-17 00:45:56 +00:00
4a5abc4a0a fix(repository): no changes to commit 2026-03-17 00:45:55 +00:00
893a532758 v1.19.5
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
Release / build-and-release (push) Failing after 3m34s
2026-03-17 00:45:37 +00:00
7ea286c0a9 fix(repo): no changes to commit 2026-03-17 00:45:37 +00:00
f94f47e313 v1.19.4
Some checks failed
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 2m8s
Release / build-and-release (push) Successful in 3m38s
2026-03-17 00:01:08 +00:00
b1a46f8757 fix(repository): no changes to commit 2026-03-17 00:01:08 +00:00
56c71226e5 v1.19.3
Some checks failed
CI / Build All Platforms (push) Failing after 3s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 36s
CI / Build Test (Current Platform) (push) Successful in 1m3s
Release / build-and-release (push) Successful in 2m48s
2026-03-16 20:06:11 +00:00
f53109a01e fix(repo): no changes to commit 2026-03-16 20:06:11 +00:00
bcb2473cc5 v1.19.2
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 34s
CI / Build Test (Current Platform) (push) Successful in 1m10s
CI / Build All Platforms (push) Successful in 2m34s
Release / build-and-release (push) Failing after 4m2s
2026-03-16 20:00:10 +00:00
689dcf295b fix(docs): remove outdated UI screenshot assets from project documentation 2026-03-16 20:00:10 +00:00
c1e14e9fc7 v1.19.1
Some checks failed
CI / Type Check & Lint (push) Failing after 30s
CI / Build Test (Current Platform) (push) Successful in 1m3s
CI / Build All Platforms (push) Successful in 2m5s
2026-03-16 19:55:27 +00:00
d5fd57e2c3 fix(dashboard): add updated dashboard screenshots for refresh and resource usage states 2026-03-16 19:55:27 +00:00
079e6a64a9 fix(dashboard): add aggregated resource usage stats to the dashboard
Some checks failed
Publish to npm / npm-publish (push) Failing after 4s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m1s
Release / build-and-release (push) Failing after 1m43s
CI / Build All Platforms (push) Successful in 2m8s
- Aggregate CPU, memory, and network stats across all running containers
- Extend ISystemStatus.docker with resource usage fields
- Fix getContainerStats Swarm service ID fallback
- Wire dashboard resource usage card to real backend data
2026-03-16 16:47:05 +00:00
a04cf053db v1.19.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 11s
CI / Type Check & Lint (push) Failing after 30s
CI / Build Test (Current Platform) (push) Successful in 58s
CI / Build All Platforms (push) Successful in 2m21s
Release / build-and-release (push) Successful in 4m11s
2026-03-16 16:19:39 +00:00
ec0e377ccb feat(opsserver,web): add real-time platform service log streaming to the dashboard 2026-03-16 16:19:39 +00:00
3b3d0433cb fix(platform-services): fix detail view navigation and log display
Some checks failed
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m6s
Release / build-and-release (push) Successful in 3m21s
- Add back button for returning to services list
- Fix DOM lifecycle when switching between platform services
- Fix timestamp format for dees-chart-log compatibility
- Clear previous stats/logs state before fetching new data
2026-03-16 14:48:46 +00:00
5f876449ca v1.18.4
Some checks failed
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 24s
CI / Build Test (Current Platform) (push) Successful in 54s
Release / build-and-release (push) Successful in 2m19s
2026-03-16 14:35:45 +00:00
8e781c7f9d fix(repo): no changes to commit 2026-03-16 14:35:45 +00:00
a3eefbe92c v1.18.3
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m1s
CI / Build All Platforms (push) Successful in 2m3s
Release / build-and-release (push) Successful in 3m2s
2026-03-16 14:22:37 +00:00
41679427c6 fix(deps): bump @serve.zone/catalog to ^2.6.1 2026-03-16 14:22:37 +00:00
c420a30341 v1.18.2
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 59s
CI / Build All Platforms (push) Successful in 2m0s
Release / build-and-release (push) Successful in 3m41s
2026-03-16 14:14:55 +00:00
fe109f0953 fix(repo): no changes to commit 2026-03-16 14:14:55 +00:00
012dce63b1 v1.18.1
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
Release / build-and-release (push) Successful in 4m0s
2026-03-16 14:14:34 +00:00
54780482c7 fix(repo): no changes to commit 2026-03-16 14:14:34 +00:00
7ab0fb3c1f v1.18.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 27s
CI / Build Test (Current Platform) (push) Successful in 58s
CI / Build All Platforms (push) Successful in 1m52s
Release / build-and-release (push) Successful in 2m58s
2026-03-16 13:51:43 +00:00
713fda2a86 feat(platform-services): add platform service log retrieval and display in the services UI 2026-03-16 13:51:43 +00:00
ec32c19300 v1.17.4
Some checks failed
CI / Type Check & Lint (push) Failing after 30s
Publish to npm / npm-publish (push) Failing after 24s
CI / Build Test (Current Platform) (push) Successful in 1m1s
CI / Build All Platforms (push) Successful in 2m12s
Release / build-and-release (push) Successful in 4m0s
2026-03-16 13:26:56 +00:00
7d1d91157c fix(docs): add hello world running screenshot for documentation 2026-03-16 13:26:56 +00:00
b69c96c240 v1.17.3
Some checks failed
CI / Build Test (Current Platform) (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build All Platforms (push) Successful in 2m4s
Release / build-and-release (push) Successful in 3m13s
2026-03-16 13:05:47 +00:00
9ee8851d03 fix(mongodb): downgrade the MongoDB service image to 4.4 and use the legacy mongo shell for container operations 2026-03-16 13:05:47 +00:00
7f6031f31a v1.17.2
Some checks failed
CI / Type Check & Lint (push) Failing after 29s
Publish to npm / npm-publish (push) Failing after 25s
CI / Build Test (Current Platform) (push) Successful in 1m4s
CI / Build All Platforms (push) Successful in 2m5s
Release / build-and-release (push) Successful in 4m12s
2026-03-16 12:45:44 +00:00
6f1b8469e0 fix(platform-services): provision ClickHouse, MinIO, and MongoDB resources via docker exec instead of host port access 2026-03-16 12:45:44 +00:00
cd06c74cc3 v1.17.1
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 41s
CI / Build Test (Current Platform) (push) Successful in 1m12s
Release / build-and-release (push) Failing after 1m54s
CI / Build All Platforms (push) Successful in 2m17s
2026-03-16 12:40:39 +00:00
d3acc720ca fix(repo): no changes to commit 2026-03-16 12:40:39 +00:00
1b6de75097 v1.17.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 56s
Release / build-and-release (push) Failing after 37s
CI / Build All Platforms (push) Successful in 2m7s
2026-03-16 12:36:02 +00:00
497f8f59a7 feat(web/services): add deploy service action to the services view 2026-03-16 12:36:02 +00:00
0c7d65e4ad v1.16.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 10s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 52s
CI / Build All Platforms (push) Successful in 1m50s
Release / build-and-release (push) Successful in 2m49s
2026-03-16 11:45:56 +00:00
3f2cd074ce feat(services): add platform service navigation and stats in the services UI 2026-03-16 11:45:56 +00:00
59ed7233bd v1.15.3
Some checks failed
CI / Build All Platforms (push) Failing after 3s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 25s
CI / Build Test (Current Platform) (push) Successful in 54s
Release / build-and-release (push) Successful in 2m35s
2026-03-16 11:07:00 +00:00
01e3ba16c4 fix(install): refresh systemd service configuration before restarting previously running installations 2026-03-16 11:07:00 +00:00
f5c1d5fcda v1.15.2
Some checks failed
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 1m7s
CI / Build All Platforms (push) Successful in 1m58s
Release / build-and-release (push) Successful in 3m9s
2026-03-16 10:58:08 +00:00
45b0971f2f fix(systemd): set HOME and DENO_DIR for the systemd service environment 2026-03-16 10:58:08 +00:00
178f440d7e v1.15.1
Some checks failed
CI / Type Check & Lint (push) Failing after 30s
Publish to npm / npm-publish (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 1m0s
CI / Build All Platforms (push) Successful in 2m8s
Release / build-and-release (push) Successful in 2m53s
2026-03-16 10:23:05 +00:00
7fff15a90c fix(systemd): move Docker installation and swarm initialization to systemd enable flow 2026-03-16 10:23:05 +00:00
69e23f667e v1.15.0
Some checks failed
CI / Build All Platforms (push) Failing after 7s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 26s
CI / Build Test (Current Platform) (push) Successful in 58s
Release / build-and-release (push) Successful in 2m58s
2026-03-16 10:02:59 +00:00
a2bf4df7c2 feat(systemd): replace smartdaemon-based service management with native systemd commands 2026-03-16 10:02:59 +00:00
9e0a0b5a89 v1.14.10
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 59s
CI / Build All Platforms (push) Successful in 2m1s
Release / build-and-release (push) Successful in 2m42s
2026-03-16 08:40:48 +00:00
3a227bd838 fix(services): stop auto-update monitoring during shutdown 2026-03-16 08:40:48 +00:00
f5a7fccfc2 v1.14.9
Some checks failed
CI / Type Check & Lint (push) Failing after 25s
Publish to npm / npm-publish (push) Failing after 32s
CI / Build Test (Current Platform) (push) Successful in 57s
CI / Build All Platforms (push) Successful in 2m20s
Release / build-and-release (push) Successful in 3m50s
2026-03-16 08:25:32 +00:00
a30d2029a5 fix(repo): no changes to commit 2026-03-16 08:25:32 +00:00
88727dd47d v1.14.8
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 45s
CI / Build Test (Current Platform) (push) Successful in 1m19s
CI / Build All Platforms (push) Successful in 2m46s
Release / build-and-release (push) Successful in 5m20s
2026-03-16 03:06:23 +00:00
9a5ed2220e fix(repo): no changes to commit 2026-03-16 03:06:23 +00:00
decd39e7c4 v1.14.7
Some checks failed
CI / Build All Platforms (push) Has been cancelled
CI / Type Check & Lint (push) Has been cancelled
CI / Build Test (Current Platform) (push) Has been cancelled
Publish to npm / npm-publish (push) Failing after 8s
Release / build-and-release (push) Successful in 5m34s
2026-03-16 03:06:17 +00:00
ad2e228208 fix(repo): no changes to commit 2026-03-16 03:06:17 +00:00
cf06019d79 v1.14.6
Some checks failed
CI / Build Test (Current Platform) (push) Has been cancelled
CI / Type Check & Lint (push) Has been cancelled
CI / Build All Platforms (push) Has been cancelled
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Successful in 5m30s
2026-03-16 03:06:08 +00:00
cf44b0047d fix(project): no changes to commit 2026-03-16 03:06:08 +00:00
260b5364e6 v1.14.5
Some checks failed
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 24s
CI / Build Test (Current Platform) (push) Successful in 51s
Release / build-and-release (push) Successful in 3m30s
2026-03-16 03:04:57 +00:00
51c1962042 fix(onebox): move Docker auto-install and swarm initialization into Onebox startup flow 2026-03-16 03:04:57 +00:00
d3b78054ad v1.14.4
Some checks failed
CI / Build Test (Current Platform) (push) Failing after 5s
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 30s
CI / Build All Platforms (push) Successful in 1m57s
Release / build-and-release (push) Successful in 3m15s
2026-03-16 02:37:59 +00:00
d2ae35f0ce fix(repo): no changes to commit 2026-03-16 02:37:59 +00:00
a605477663 v1.14.3
Some checks failed
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 29s
CI / Build Test (Current Platform) (push) Successful in 52s
CI / Build All Platforms (push) Successful in 1m54s
Release / build-and-release (push) Successful in 3m14s
2026-03-16 02:17:20 +00:00
ba98086548 fix(repo): no changes to commit 2026-03-16 02:17:20 +00:00
0b3c22556b v1.14.2
Some checks failed
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 23s
CI / Build Test (Current Platform) (push) Successful in 51s
Release / build-and-release (push) Has been cancelled
2026-03-16 02:11:41 +00:00
069e6e6c8f fix(repo): no changes to commit 2026-03-16 02:11:41 +00:00
10598520d8 v1.14.1
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 28s
CI / Build Test (Current Platform) (push) Successful in 57s
CI / Build All Platforms (push) Successful in 2m6s
Release / build-and-release (push) Successful in 3m32s
2026-03-16 01:33:07 +00:00
075b7946b1 fix(repo): no changes to commit 2026-03-16 01:33:07 +00:00
f47fca3304 v1.14.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 7s
CI / Type Check & Lint (push) Failing after 31s
CI / Build Test (Current Platform) (push) Successful in 1m2s
CI / Build All Platforms (push) Successful in 2m5s
Release / build-and-release (push) Successful in 4m57s
2026-03-16 01:19:58 +00:00
575e010a6b feat(daemon): auto-install Docker and initialize Swarm during daemon service setup 2026-03-16 01:19:58 +00:00
60a5dc4663 v1.13.17
Some checks failed
CI / Type Check & Lint (push) Failing after 39s
Publish to npm / npm-publish (push) Failing after 54s
CI / Build Test (Current Platform) (push) Successful in 1m49s
CI / Build All Platforms (push) Successful in 3m18s
Release / build-and-release (push) Successful in 4m38s
2026-03-16 01:10:23 +00:00
36d80b1e27 fix(ci): remove forced container image pulling from Gitea workflow jobs 2026-03-16 01:10:23 +00:00
465cf0ee72 v1.13.16
Some checks failed
CI / Type Check & Lint (push) Failing after 0s
CI / Build Test (Current Platform) (push) Failing after 0s
CI / Build All Platforms (push) Failing after 0s
Publish to npm / npm-publish (push) Failing after 0s
Release / build-and-release (push) Failing after 2m33s
2026-03-16 00:59:27 +00:00
bd5cd5c0cb fix(ci): refresh workflow container images on every run and bump @apiclient.xyz/docker to ^5.1.1 2026-03-16 00:59:27 +00:00
b622565e34 v1.13.15
Some checks failed
CI / Build Test (Current Platform) (push) Failing after 5s
CI / Type Check & Lint (push) Failing after 30s
Publish to npm / npm-publish (push) Failing after 35s
CI / Build All Platforms (push) Successful in 2m25s
Release / build-and-release (push) Successful in 3m43s
2026-03-15 21:13:56 +00:00
56376121ab fix(repo): no changes to commit 2026-03-15 21:13:56 +00:00
e3359d1235 v1.13.14
Some checks failed
CI / Type Check & Lint (push) Failing after 2s
CI / Build Test (Current Platform) (push) Failing after 2s
CI / Build All Platforms (push) Failing after 2s
Publish to npm / npm-publish (push) Failing after 2s
Release / build-and-release (push) Successful in 5m55s
2026-03-15 20:56:22 +00:00
f1eeec6922 fix(repo): no changes to commit 2026-03-15 20:56:22 +00:00
69362bb529 v1.13.13
Some checks failed
CI / Build All Platforms (push) Failing after 6s
Publish to npm / npm-publish (push) Failing after 8s
CI / Type Check & Lint (push) Failing after 32s
CI / Build Test (Current Platform) (push) Failing after 50s
Release / build-and-release (push) Failing after 4s
2026-03-15 20:49:49 +00:00
857fcc50ba fix(repo): no changes to commit 2026-03-15 20:49:49 +00:00
5d0df006eb v1.13.12
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Type Check & Lint (push) Failing after 30s
CI / Build Test (Current Platform) (push) Failing after 59s
Release / build-and-release (push) Failing after 1m10s
CI / Build All Platforms (push) Successful in 1m49s
2026-03-15 19:27:13 +00:00
e6256502ce fix(ci): run pnpm install with --ignore-scripts in CI and release workflows 2026-03-15 19:27:13 +00:00
d5dc141171 v1.13.11
Some checks failed
Publish to npm / npm-publish (push) Failing after 9s
CI / Build Test (Current Platform) (push) Failing after 22s
CI / Build All Platforms (push) Failing after 20s
CI / Type Check & Lint (push) Failing after 30s
Release / build-and-release (push) Failing after 15s
2026-03-15 19:11:46 +00:00
2538f5ae2c fix(project): no changes to commit 2026-03-15 19:11:46 +00:00
4613193dcc v1.13.10
Some checks failed
Publish to npm / npm-publish (push) Failing after 12s
CI / Type Check & Lint (push) Failing after 40s
CI / Build All Platforms (push) Failing after 1m28s
CI / Build Test (Current Platform) (push) Failing after 1m31s
Release / build-and-release (push) Failing after 1m21s
2026-03-15 18:39:16 +00:00
848b3afe54 fix(deps): bump @git.zone/tsdeno to ^1.2.0 2026-03-15 18:39:16 +00:00
69 changed files with 6033 additions and 1842 deletions

View File

@@ -1,114 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
check:
name: Type Check & Lint
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Install dependencies
run: deno install --entrypoint mod.ts
- name: Check TypeScript types
run: deno check mod.ts
- name: Lint code
run: deno lint
continue-on-error: true
- name: Format check
run: deno fmt --check
continue-on-error: true
build:
name: Build Test (Current Platform)
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Install dependencies
run: pnpm install
- name: Compile for current platform
run: |
echo "Testing compilation for Linux x86_64..."
npx tsdeno compile --allow-all --no-check \
--output onebox-test \
--target x86_64-unknown-linux-gnu mod.ts
- name: Test binary execution
run: |
chmod +x onebox-test
./onebox-test --version
./onebox-test --help
build-all:
name: Build All Platforms
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Install dependencies
run: pnpm install
- name: Compile all platform binaries
run: mkdir -p dist/binaries && npx tsdeno compile
- name: Upload all binaries as artifact
uses: actions/upload-artifact@v3
with:
name: onebox-binaries.zip
path: dist/binaries/*
retention-days: 30

View File

@@ -1,131 +0,0 @@
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
npm-publish:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Setup Node.js for npm publishing
uses: actions/setup-node@v4
with:
node-version: '18.x'
registry-url: 'https://registry.npmjs.org/'
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Publishing version: $VERSION"
- name: Verify deno.json version matches tag
run: |
DENO_VERSION=$(grep -o '"version": "[^"]*"' deno.json | cut -d'"' -f4)
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "deno.json version: $DENO_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$DENO_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
echo "deno.json has version $DENO_VERSION but tag is $TAG_VERSION"
exit 1
fi
- name: Compile binaries for npm package
run: |
echo "Compiling binaries for npm package..."
deno task compile
echo ""
echo "Binary sizes:"
ls -lh dist/binaries/
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS
cat SHA256SUMS
cd ../..
- name: Sync package.json version
run: |
VERSION="${{ steps.version.outputs.version_number }}"
echo "Syncing package.json to version ${VERSION}..."
npm version ${VERSION} --no-git-tag-version --allow-same-version
echo "package.json version: $(grep '"version"' package.json | head -1)"
- name: Create npm package
run: |
echo "Creating npm package..."
npm pack
echo ""
echo "Package created:"
ls -lh *.tgz
- name: Test local installation
run: |
echo "Testing local package installation..."
PACKAGE_FILE=$(ls *.tgz)
npm install -g ${PACKAGE_FILE}
echo ""
echo "Testing onebox command:"
onebox --version || echo "Note: Binary execution may fail in CI environment"
echo ""
echo "Checking installed files:"
npm ls -g @serve.zone/onebox || true
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "Publishing to npm registry..."
npm publish --access public
echo ""
echo "Successfully published @serve.zone/onebox to npm!"
echo ""
echo "Package info:"
npm view @serve.zone/onebox
- name: Verify npm package
run: |
echo "Waiting for npm propagation..."
sleep 30
echo ""
echo "Verifying published package..."
npm view @serve.zone/onebox
echo ""
echo "Testing installation from npm:"
npm install -g @serve.zone/onebox
echo ""
echo "Package installed successfully!"
which onebox || echo "Binary location check skipped"
- name: Publish Summary
run: |
echo "================================================"
echo " npm Publish Complete!"
echo "================================================"
echo ""
echo "Package: @serve.zone/onebox"
echo "Version: ${{ steps.version.outputs.version }}"
echo ""
echo "Installation:"
echo " npm install -g @serve.zone/onebox"
echo ""
echo "Registry:"
echo " https://www.npmjs.com/package/@serve.zone/onebox"
echo ""

View File

@@ -31,7 +31,7 @@ jobs:
run: corepack enable run: corepack enable
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install --ignore-scripts
- name: Get version from tag - name: Get version from tag
id: version id: version

29
.gitignore vendored
View File

@@ -1,3 +1,30 @@
.nogit/
# artifacts
coverage/
public/
# installs
node_modules/
# caches
.yarn/
.cache/
.rpt2_cache
# builds
dist/
dist_*/
# rust
rust/target/
dist_rust/
# AI
.claude/
.serena/
#------# custom
# Deno # Deno
.deno/ .deno/
deno.lock deno.lock
@@ -50,4 +77,4 @@ logs/
*.log *.log
.playwright-mcp .playwright-mcp
./dist/ ./dist/

View File

@@ -7,7 +7,12 @@
"outputMode": "base64ts", "outputMode": "base64ts",
"bundler": "esbuild", "bundler": "esbuild",
"production": true, "production": true,
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}] "includeFiles": [
{
"from": "./html/index.html",
"to": "index.html"
}
]
} }
] ]
}, },
@@ -40,7 +45,12 @@
"bundler": "esbuild", "bundler": "esbuild",
"production": true, "production": true,
"watchPatterns": ["./ts_web/**/*", "./html/**/*"], "watchPatterns": ["./ts_web/**/*", "./html/**/*"],
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}] "includeFiles": [
{
"from": "./html/index.html",
"to": "index.html"
}
]
} }
], ],
"watchers": [ "watchers": [
@@ -53,5 +63,17 @@
"runOnStart": true "runOnStart": true
} }
] ]
} },
"@git.zone/cli": {
"projectType": "denoSaaS",
"module": {
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "onebox",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"npmPackagename": "@serve.zone/onebox",
"license": "MIT"
}
},
"@ship.zone/szci": {}
} }

View File

@@ -1,36 +1,390 @@
# Changelog # Changelog
## 2026-03-15 - 1.13.9 - fix(repo) ## 2026-03-24 - 1.24.2 - fix(deps)
bump runtime and build tool dependencies
- update @design.estate/dees-catalog to ^3.49.0
- update development tooling packages @git.zone/tsbundle, @git.zone/tsdeno, and @git.zone/tswatch
## 2026-03-24 - 1.24.1 - fix(repo)
migrate smart build config to .smartconfig.json and tidy repository metadata
- Rename npmextra.json to .smartconfig.json and extend it with CLI project metadata for the repository.
- Mark the package as private and add an empty pnpm overrides block in package.json.
- Expand .gitignore to cover common build artifacts, caches, install directories, and local tooling folders.
- Reformat changelog and README files for cleaner spacing and Markdown table alignment without changing documented behavior.
## 2026-03-24 - 1.24.0 - feat(backup)
add containerarchive-backed backup storage, restore, download, and pruning support
- add database support for archive snapshot IDs and stored size tracking for backups
- initialize and close the backup archive during onebox lifecycle startup and shutdown
- allow backup download and restore flows to work with archive snapshots as well as legacy file-based backups
- schedule daily archive pruning based on the most generous configured retention policy
- replace smarts3 with smartstorage for registry-backed S3-compatible storage
## 2026-03-21 - 1.23.0 - feat(appstore)
add remote app store templates with service upgrades and Redis/MariaDB platform support
- introduces an App Store manager, API handlers, shared request types, and web UI flow for browsing remote templates and deploying services from template metadata
- tracks app template id and version on services, adds upgrade discovery and migration-based service upgrades, and includes a database migration for template version columns
- adds Redis and MariaDB platform service providers with provisioning plus backup and restore support, and exposes their requirements through service creation and app template config
## 2026-03-18 - 1.22.2 - fix(web-ui)
stabilize app store service creation flow and add Ghost sqlite defaults
- Defers App Store navigation to the services view to avoid destroying the current view during the deploy event handler.
- Processes pending app templates after services view updates so the create flow opens reliably.
- Adds default Ghost environment variables for sqlite3 and the database file path in the App Store template.
- Removes obsolete Gitea CI and npm publish workflow definitions.
## 2026-03-18 - 1.22.1 - fix(repo)
no changes to commit no changes to commit
## 2026-03-18 - 1.22.0 - feat(web-appstore)
add an App Store view for quick service deployment from curated templates
- adds a new App Store tab to the web UI with curated Docker app templates
- passes selected app templates through UI state into the services view for quick deployment
- supports quick deploy creation with prefilled image, port, environment variables, and optional platform service flags
- updates @serve.zone/catalog to ^2.8.0 to support the new app store view
## 2026-03-18 - 1.21.0 - feat(opsserver)
add container workspace API and backend execution environment for services
- introduces typed workspace handlers for reading, writing, listing, creating, removing, and executing commands inside service containers
- adds frontend backend-execution environment integration so the service view can open a workspace against a selected service
- extends Docker exec lookup to resolve Swarm service container IDs when a direct container ID is unavailable
## 2026-03-17 - 1.20.0 - feat(ops-dashboard)
stream user service logs to the ops dashboard and resolve service containers for Docker log streaming
- add typed socket support for pushing live user service log entries to the web app
- extend platform log streaming to include running user services with separate dashboard handlers
- fall back from direct container lookup to service-to-container resolution when streaming Docker logs
- update log parsing to preserve timestamps and infer log levels for service log entries
- bump @serve.zone/catalog to ^2.7.0
## 2026-03-17 - 1.19.12 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.11 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.10 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.9 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.8 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.7 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.6 - fix(repository)
no changes to commit
## 2026-03-17 - 1.19.5 - fix(repo)
no changes to commit
## 2026-03-17 - 1.19.4 - fix(repository)
no changes to commit
## 2026-03-16 - 1.19.3 - fix(repo)
no changes to commit
## 2026-03-16 - 1.19.2 - fix(docs)
remove outdated UI screenshot assets from project documentation
- Deletes multiple PNG screenshots that documented previous dashboard, service form, and hello-world states.
- Reduces repository clutter by removing obsolete image assets no longer needed in docs.
## 2026-03-16 - 1.19.1 - fix(dashboard)
add updated dashboard screenshots for refresh and resource usage states
- Adds new dashboard screenshots covering post-refresh, resource usage, and populated data views.
- Updates visual assets to document current dashboard behavior and UI states.
## 2026-03-16 - 1.19.1 - fix(dashboard)
add aggregated resource usage stats to the dashboard
- Aggregate CPU, memory, and network stats across all running user and platform service containers in getSystemStatus
- Extend ISystemStatus.docker interface with cpuUsage, memoryUsage, memoryTotal, networkIn, networkOut fields
- Fix getContainerStats to properly handle Swarm service IDs by catching exceptions and falling back to label-based container lookup
- Wire dashboard resource usage card to display real aggregated data from the backend
## 2026-03-16 - 1.19.0 - feat(opsserver,web)
add real-time platform service log streaming to the dashboard
- stream running platform service container logs from the ops server to connected dashboard clients via TypedSocket
- parse Docker log timestamps and levels for both pushed and fetched platform service log entries
- enhance the platform service detail view with mapped statuses and predefined host, port, version, and config metadata
- add the typedsocket dependency and update the catalog package for dashboard support
## 2026-03-16 - 1.18.5 - fix(platform-services)
fix platform service detail view navigation and log display
- Add back button to platform service detail view for returning to services list
- Fix DOM lifecycle when switching between platform services (destroy and recreate dees-chart-log)
- Fix timestamp format for log entries to use ISO 8601 for dees-chart-log compatibility
- Clear previous stats/logs state before fetching new platform service data
## 2026-03-16 - 1.18.4 - fix(repo)
no changes to commit
## 2026-03-16 - 1.18.3 - fix(deps)
bump @serve.zone/catalog to ^2.6.1
- Updates the @serve.zone/catalog runtime dependency from ^2.6.0 to ^2.6.1.
## 2026-03-16 - 1.18.2 - fix(repo)
no changes to commit
## 2026-03-16 - 1.18.1 - fix(repo)
no changes to commit
## 2026-03-16 - 1.18.0 - feat(platform-services)
add platform service log retrieval and display in the services UI
- add typed request support in the ops server to fetch Docker logs for platform service containers
- store fetched platform service logs in web app state and load them when opening platform service details
- render platform service logs in the services detail view and add sidebar icons for main navigation tabs
## 2026-03-16 - 1.17.4 - fix(docs)
add hello world running screenshot for documentation
- Adds a new PNG asset showing the application in a running hello world state.
- Supports project documentation or README usage without changing runtime behavior.
## 2026-03-16 - 1.17.3 - fix(mongodb)
downgrade the MongoDB service image to 4.4 and use the legacy mongo shell for container operations
- changes the default MongoDB container image from mongo:7 to mongo:4.4
- replaces mongosh with mongo for health checks, provisioning, and deprovisioning inside the container
## 2026-03-16 - 1.17.2 - fix(platform-services)
provision ClickHouse, MinIO, and MongoDB resources via docker exec instead of host port access
- switch ClickHouse provisioning and teardown to in-container client commands to avoid host port mapping issues
- replace MinIO host-side S3 API calls with in-container mc commands for bucket creation and removal
- run MongoDB provisioning and deprovisioning through mongosh inside the container and improve docker exec failure reporting
## 2026-03-16 - 1.17.1 - fix(repo)
no changes to commit
## 2026-03-16 - 1.17.0 - feat(web/services)
add deploy service action to the services view
- Adds a prominent "Deploy Service" button to the services page header.
- Routes users into the create service view directly from the services listing.
- Includes a new service creation form screenshot asset for the updated interface.
## 2026-03-16 - 1.16.0 - feat(services)
add platform service navigation and stats in the services UI
- add platform service stats state and fetch action
- show platform services in the services list and open a platform detail view
- enable dashboard clicks to jump directly to the selected platform service
- refresh platform service stats after start and restart actions
- bump @serve.zone/catalog to ^2.6.0 for the new platform service UI components
## 2026-03-16 - 1.15.3 - fix(install)
refresh systemd service configuration before restarting previously running installations
- Re-enable the systemd service during updates so unit file changes are applied before restart
- Add a log message indicating the service configuration is being refreshed
## 2026-03-16 - 1.15.2 - fix(systemd)
set HOME and DENO_DIR for the systemd service environment
- Adds HOME=/root to the generated onebox systemd unit
- Adds DENO_DIR=/root/.cache/deno so Deno cache paths are available when running as a service
## 2026-03-16 - 1.15.1 - fix(systemd)
move Docker installation and swarm initialization to systemd enable flow
- Ensures Docker is installed before writing and enabling the systemd unit that depends on docker.service.
- Removes Docker auto-installation from Onebox initialization so setup happens in the service management path.
## 2026-03-16 - 1.15.0 - feat(systemd)
replace smartdaemon-based service management with native systemd commands
- adds a dedicated OneboxSystemd manager for enabling, disabling, starting, stopping, checking status, and following logs
- introduces a new `onebox systemd` CLI command set and updates install and help output to use it
- removes the smartdaemon dependency and related service management code
## 2026-03-16 - 1.14.10 - fix(services)
stop auto-update monitoring during shutdown
- Track the auto-update polling interval in the services manager
- Clear the auto-update interval when Onebox shuts down to prevent background checks after shutdown
## 2026-03-16 - 1.14.9 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.8 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.7 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.6 - fix(project)
no changes to commit
## 2026-03-16 - 1.14.5 - fix(onebox)
move Docker auto-install and swarm initialization into Onebox startup flow
- removes Docker setup from daemon service installation
- ensures Docker is installed before Docker initialization during Onebox startup
- preserves automatic Docker Swarm initialization on fresh servers
## 2026-03-16 - 1.14.4 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.3 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.2 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.1 - fix(repo)
no changes to commit
## 2026-03-16 - 1.14.0 - feat(daemon)
auto-install Docker and initialize Swarm during daemon service setup
- Adds a Docker availability check before installing the Onebox daemon service
- Installs Docker automatically when it is missing using the standard installation script
- Attempts to initialize Docker Swarm after installation and handles already-initialized environments gracefully
## 2026-03-16 - 1.13.17 - fix(ci)
remove forced container image pulling from Gitea workflow jobs
- Drops the `--pull always` container option from CI, npm publish, and release workflows.
- Keeps workflow container images unchanged while avoiding forced pulls on every job run.
## 2026-03-16 - 1.13.16 - fix(ci)
refresh workflow container images on every run and bump @apiclient.xyz/docker to ^5.1.1
- add --pull always to CI, release, and npm publish workflow containers to avoid stale images
- update @apiclient.xyz/docker from ^5.1.0 to ^5.1.1 in deno.json
## 2026-03-15 - 1.13.15 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.14 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.13 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.12 - fix(ci)
run pnpm install with --ignore-scripts in CI and release workflows
- Update CI workflow dependency installation steps to skip lifecycle scripts during builds.
- Apply the same install change to the release workflow for consistent automation behavior.
## 2026-03-15 - 1.13.11 - fix(project)
no changes to commit
## 2026-03-15 - 1.13.10 - fix(deps)
bump @git.zone/tsdeno to ^1.2.0
- Updates the tsdeno development dependency from ^1.1.1 to ^1.2.0.
## 2026-03-15 - 1.13.9 - fix(repo)
no changes to commit
## 2026-03-15 - 1.13.8 - fix(repo) ## 2026-03-15 - 1.13.8 - fix(repo)
no changes to commit
no changes to commit
## 2026-03-15 - 1.13.7 - fix(repo) ## 2026-03-15 - 1.13.7 - fix(repo)
no changes to commit no changes to commit
## 2026-03-15 - 1.13.6 - fix(ci) ## 2026-03-15 - 1.13.6 - fix(ci)
correct workflow container image registry path correct workflow container image registry path
- Update Gitea CI, release, and npm publish workflows to use the corrected ht-docker-node image path - Update Gitea CI, release, and npm publish workflows to use the corrected ht-docker-node image path
- Align all workflow container references from hosttoday to host.today to prevent pipeline image resolution issues - Align all workflow container references from hosttoday to host.today to prevent pipeline image resolution issues
## 2026-03-15 - 1.13.5 - fix(workflows) ## 2026-03-15 - 1.13.5 - fix(workflows)
switch Gitea workflow containers from ht-docker-dbase to ht-docker-node switch Gitea workflow containers from ht-docker-dbase to ht-docker-node
- Updates the CI, release, and npm publish workflows to use the Node-focused container image consistently. - Updates the CI, release, and npm publish workflows to use the Node-focused container image consistently.
- Aligns workflow runtime images with the project's Node and Deno build and publish steps. - Aligns workflow runtime images with the project's Node and Deno build and publish steps.
## 2026-03-15 - 1.13.4 - fix(ci) ## 2026-03-15 - 1.13.4 - fix(ci)
run workflows in the shared build container and enable corepack for pnpm installs run workflows in the shared build container and enable corepack for pnpm installs
- adds the ht-docker-dbase container image to CI, release, and npm publish workflows - adds the ht-docker-dbase container image to CI, release, and npm publish workflows
- enables corepack before pnpm install in build and release jobs to ensure package manager availability - enables corepack before pnpm install in build and release jobs to ensure package manager availability
## 2026-03-15 - 1.13.3 - fix(build) ## 2026-03-15 - 1.13.3 - fix(build)
replace custom Deno compile scripts with tsdeno-based binary builds in CI and release workflows replace custom Deno compile scripts with tsdeno-based binary builds in CI and release workflows
- adds @git.zone/tsdeno as a dev dependency and configures compile targets in npmextra.json - adds @git.zone/tsdeno as a dev dependency and configures compile targets in npmextra.json
@@ -38,18 +392,21 @@ replace custom Deno compile scripts with tsdeno-based binary builds in CI and re
- removes the legacy scripts/compile-all.sh script and points the compile task to tsdeno compile - removes the legacy scripts/compile-all.sh script and points the compile task to tsdeno compile
## 2026-03-15 - 1.13.2 - fix(scripts) ## 2026-03-15 - 1.13.2 - fix(scripts)
install production dependencies before compiling binaries and exclude local node_modules from builds install production dependencies before compiling binaries and exclude local node_modules from builds
- Adds a dependency installation step using the application entrypoint before cross-platform compilation - Adds a dependency installation step using the application entrypoint before cross-platform compilation
- Updates all deno compile targets to use --node-modules-dir=none to avoid bundling local node_modules - Updates all deno compile targets to use --node-modules-dir=none to avoid bundling local node_modules
## 2026-03-15 - 1.13.1 - fix(deno) ## 2026-03-15 - 1.13.1 - fix(deno)
remove nodeModulesDir from Deno configuration remove nodeModulesDir from Deno configuration
- Drops the explicit nodeModulesDir setting from deno.json. - Drops the explicit nodeModulesDir setting from deno.json.
- Keeps the package version unchanged at 1.13.0 while simplifying runtime configuration. - Keeps the package version unchanged at 1.13.0 while simplifying runtime configuration.
## 2026-03-15 - 1.13.0 - feat(install) ## 2026-03-15 - 1.13.0 - feat(install)
improve installer with version selection, service restart handling, and upgrade documentation improve installer with version selection, service restart handling, and upgrade documentation
- Adds installer command-line options for help, specific version selection, and custom install directory. - Adds installer command-line options for help, specific version selection, and custom install directory.
@@ -57,12 +414,14 @@ improve installer with version selection, service restart handling, and upgrade
- Preserves Onebox data directories, stops and restarts the systemd service during updates, and refreshes installation instructions in the README including upgrade usage. - Preserves Onebox data directories, stops and restarts the systemd service during updates, and refreshes installation instructions in the README including upgrade usage.
## 2026-03-15 - 1.12.1 - fix(package.json) ## 2026-03-15 - 1.12.1 - fix(package.json)
update package metadata update package metadata
- Single metadata-only file changed (+1, -1) - Single metadata-only file changed (+1, -1)
- No source code or runtime behavior modified; safe patch release - No source code or runtime behavior modified; safe patch release
## 2026-03-15 - 1.12.0 - feat(cli,release) ## 2026-03-15 - 1.12.0 - feat(cli,release)
add self-upgrade command and automate CI, release, and npm publishing workflows add self-upgrade command and automate CI, release, and npm publishing workflows
- adds a new `onebox upgrade` CLI command that checks the latest release and reinstalls the current binary via the installer script - adds a new `onebox upgrade` CLI command that checks the latest release and reinstalls the current binary via the installer script
@@ -70,6 +429,7 @@ add self-upgrade command and automate CI, release, and npm publishing workflows
- adds a reusable release template describing installation options, supported platforms, and checksum availability - adds a reusable release template describing installation options, supported platforms, and checksum availability
## 2026-03-03 - 1.11.0 - feat(services) ## 2026-03-03 - 1.11.0 - feat(services)
map backend service data to UI components, add stats & logs parsing, fetch service stats, and fix logs request param map backend service data to UI components, add stats & logs parsing, fetch service stats, and fix logs request param
- Fix: rename service logs request property from 'lines' to 'tail' when calling typedRequest - Fix: rename service logs request property from 'lines' to 'tail' when calling typedRequest
@@ -79,21 +439,24 @@ map backend service data to UI components, add stats & logs parsing, fetch servi
- Parse and normalize logs into timestamp/message pairs for the detail view - Parse and normalize logs into timestamp/message pairs for the detail view
## 2026-03-02 - 1.10.3 - fix(bin) ## 2026-03-02 - 1.10.3 - fix(bin)
make bin/onebox-wrapper.js executable make bin/onebox-wrapper.js executable
- Metadata-only change: file mode updated for bin/onebox-wrapper.js to include the executable bit - Metadata-only change: file mode updated for bin/onebox-wrapper.js to include the executable bit
- No source or behavior changes to the code - No source or behavior changes to the code
## 2026-03-02 - 1.10.2 - fix(build) ## 2026-03-02 - 1.10.2 - fix(build)
update build/watch configuration, switch to esbuild bundler and tswatch, and bump catalog and tooling dependencies update build/watch configuration, switch to esbuild bundler and tswatch, and bump catalog and tooling dependencies
- Switch watch script to 'tswatch' (replaced previous concurrently command invoking deno + tswatch). - Switch watch script to 'tswatch' (replaced previous concurrently command invoking deno + tswatch).
- npmextra.json: set bundler to 'esbuild', enable production mode, include html/index.html in the bundle, and extend watchPatterns to include ./html/**/*. - npmextra.json: set bundler to 'esbuild', enable production mode, include html/index.html in the bundle, and extend watchPatterns to include ./html/\*_/_.
- Backend watcher: expanded watch globs and changed command to include --unstable-ffi and runtime flags (--ephemeral --monitor); restart and debounce kept. - Backend watcher: expanded watch globs and changed command to include --unstable-ffi and runtime flags (--ephemeral --monitor); restart and debounce kept.
- Bump runtime deps: @design.estate/dees-catalog -> ^3.43.3, @serve.zone/catalog -> ^2.5.0. - Bump runtime deps: @design.estate/dees-catalog -> ^3.43.3, @serve.zone/catalog -> ^2.5.0.
- Bump devDependencies: @git.zone/tsbundle -> ^2.9.0, @git.zone/tswatch -> ^3.2.0. - Bump devDependencies: @git.zone/tsbundle -> ^2.9.0, @git.zone/tswatch -> ^3.2.0.
## 2026-02-24 - 1.10.1 - fix(package.json) ## 2026-02-24 - 1.10.1 - fix(package.json)
update package metadata update package metadata
- Single metadata-only file changed (+1 -1) - Single metadata-only file changed (+1 -1)
@@ -101,6 +464,7 @@ update package metadata
- Current package version is 1.10.0; recommend patch bump to 1.10.1 - Current package version is 1.10.0; recommend patch bump to 1.10.1
## 2026-02-24 - 1.10.0 - feat(opsserver) ## 2026-02-24 - 1.10.0 - feat(opsserver)
introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces
- Add OpsServer (ts/opsserver) with TypedRequest handlers for admin, services, platform, dns, domains, registry, network, backups, schedules, settings and logs. - Add OpsServer (ts/opsserver) with TypedRequest handlers for admin, services, platform, dns, domains, registry, network, backups, schedules, settings and logs.
@@ -113,21 +477,24 @@ introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legac
- Note: This adds many new endpoints and internal API changes (TypedRequest-based); consumers of the old UI/HTTP endpoints should migrate to the new OpsServer TypedRequest API and web components. - Note: This adds many new endpoints and internal API changes (TypedRequest-based); consumers of the old UI/HTTP endpoints should migrate to the new OpsServer TypedRequest API and web components.
## 2025-12-03 - 1.9.2 - fix(ui) ## 2025-12-03 - 1.9.2 - fix(ui)
Add VS Code configs for the UI workspace and normalize dark theme CSS variables Add VS Code configs for the UI workspace and normalize dark theme CSS variables
- Add VS Code workspace files under ui/.vscode: - Add VS Code workspace files under ui/.vscode:
- - extensions.json: recommend the Angular language support extension - - extensions.json: recommend the Angular language support extension
- - launch.json: Chrome launch configurations for 'ng serve' and 'ng test' (preLaunchTask hooks) - - launch.json: Chrome launch configurations for 'ng serve' and 'ng test' (preLaunchTask hooks)
- - tasks.json: npm 'start' and 'test' tasks with a background TypeScript problem matcher to improve dev workflow - - tasks.json: npm 'start' and 'test' tasks with a background TypeScript problem matcher to improve dev workflow
- Update ui/src/styles.css dark theme variables to use neutral black/gray HSL values for background, foreground, cards, popovers, accents, borders, inputs and ring to improve contrast and consistency - Update ui/src/styles.css dark theme variables to use neutral black/gray HSL values for background, foreground, cards, popovers, accents, borders, inputs and ring to improve contrast and consistency
## 2025-11-27 - 1.9.1 - fix(ui) ## 2025-11-27 - 1.9.1 - fix(ui)
Correct import success toast and add VS Code launch/tasks recommendations for the UI Correct import success toast and add VS Code launch/tasks recommendations for the UI
- Fix backup import success toast in backups-tab.component to reference response.data.service.name (previously response.data.serviceName), preventing incorrect service name display. - Fix backup import success toast in backups-tab.component to reference response.data.service.name (previously response.data.serviceName), preventing incorrect service name display.
- Add VS Code workspace settings for the UI: extensions recommendation, launch configurations for 'ng serve' and 'ng test', and npm tasks for start/test to simplify local development and debugging. - Add VS Code workspace settings for the UI: extensions recommendation, launch configurations for 'ng serve' and 'ng test', and npm tasks for start/test to simplify local development and debugging.
## 2025-11-27 - 1.9.0 - feat(backups) ## 2025-11-27 - 1.9.0 - feat(backups)
Add backup import API and improve backup download/import flow in UI Add backup import API and improve backup download/import flow in UI
- Backend: add /api/backups/import endpoint to accept multipart file uploads or JSON with a URL and import backups (saves temp file, validates .tar.enc, calls backupManager.restoreBackup in import mode). - Backend: add /api/backups/import endpoint to accept multipart file uploads or JSON with a URL and import backups (saves temp file, validates .tar.enc, calls backupManager.restoreBackup in import mode).
@@ -137,6 +504,7 @@ Add backup import API and improve backup download/import flow in UI
- Dev: add VS Code launch, tasks and recommended extensions for the ui workspace to simplify local development. - Dev: add VS Code launch, tasks and recommended extensions for the ui workspace to simplify local development.
## 2025-11-27 - 1.8.0 - feat(backup) ## 2025-11-27 - 1.8.0 - feat(backup)
Add backup scheduling system with GFS retention, API and UI integration Add backup scheduling system with GFS retention, API and UI integration
- Introduce backup scheduling subsystem (BackupScheduler) and integrate it into Onebox lifecycle (init & shutdown) - Introduce backup scheduling subsystem (BackupScheduler) and integrate it into Onebox lifecycle (init & shutdown)
@@ -149,6 +517,7 @@ Add backup scheduling system with GFS retention, API and UI integration
- Type and repository updates across codebase to support schedule-aware backups, schedule CRUD, and retention enforcement - Type and repository updates across codebase to support schedule-aware backups, schedule CRUD, and retention enforcement
## 2025-11-27 - 1.7.0 - feat(backup) ## 2025-11-27 - 1.7.0 - feat(backup)
Add backup system: BackupManager, DB schema, API endpoints and UI support Add backup system: BackupManager, DB schema, API endpoints and UI support
Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization. Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
@@ -161,6 +530,7 @@ Introduce a complete service backup/restore subsystem with encrypted archives, d
- Integrate BackupManager into Onebox core (initialized in Onebox constructor) and wire HTTP handlers to use the new manager; add DB repository export/import glue so backups are stored and referenced by ID. - Integrate BackupManager into Onebox core (initialized in Onebox constructor) and wire HTTP handlers to use the new manager; add DB repository export/import glue so backups are stored and referenced by ID.
## 2025-11-27 - 1.6.0 - feat(ui.dashboard) ## 2025-11-27 - 1.6.0 - feat(ui.dashboard)
Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config
- Introduce ResourceUsageCardComponent and include it as a full-width row in the dashboard layout. - Introduce ResourceUsageCardComponent and include it as a full-width row in the dashboard layout.
@@ -169,6 +539,7 @@ Add Resource Usage card to dashboard and make dashboard cards full-height; add V
- Add VSCode workspace configuration: recommended Angular extension, launch configurations for ng serve/ng test, and npm tasks to run/start the UI in development. - Add VSCode workspace configuration: recommended Angular extension, launch configurations for ng serve/ng test, and npm tasks to run/start the UI in development.
## 2025-11-27 - 1.5.0 - feat(network) ## 2025-11-27 - 1.5.0 - feat(network)
Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting
- Add /api/network/traffic-stats GET endpoint to the HTTP API with an optional minutes query parameter (validated, 1-60). - Add /api/network/traffic-stats GET endpoint to the HTTP API with an optional minutes query parameter (validated, 1-60).
@@ -180,26 +551,29 @@ Add traffic stats endpoint and dashboard UI; enhance platform services and certi
- Add VSCode workspace launch/tasks recommendations for the UI development environment. - Add VSCode workspace launch/tasks recommendations for the UI development environment.
## 2025-11-26 - 1.4.0 - feat(platform-services) ## 2025-11-26 - 1.4.0 - feat(platform-services)
Add ClickHouse platform service support and improve related healthchecks and tooling Add ClickHouse platform service support and improve related healthchecks and tooling
- Add ClickHouse as a first-class platform service: register provider, provision/cleanup support and env var injection - Add ClickHouse as a first-class platform service: register provider, provision/cleanup support and env var injection
- Expose ClickHouse endpoints in the HTTP API routing (list/get/start/stop/stats) and map default port (8123) - Expose ClickHouse endpoints in the HTTP API routing (list/get/start/stop/stats) and map default port (8123)
- Enable services to request ClickHouse as a platform requirement (enableClickHouse / platformRequirements) during deploy/provision flows - Enable services to request ClickHouse as a platform requirement (enableClickHouse / platformRequirements) during deploy/provision flows
- Fix ClickHouse container health check to use absolute wget path (/usr/bin/wget) for more reliable in-container checks - Fix ClickHouse container health check to use absolute wget path (/usr/bin/wget) for more reliable in-container checks
- Add VS Code workspace launch/tasks/extensions configs for the UI (ui/.vscode/*) to improve local dev experience - Add VS Code workspace launch/tasks/extensions configs for the UI (ui/.vscode/\*) to improve local dev experience
## 2025-11-26 - 1.3.0 - feat(platform-services) ## 2025-11-26 - 1.3.0 - feat(platform-services)
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings) Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
- Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager - Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager
- Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources - Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources
- Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse - Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse
- Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE_* mappings) when provisioning resources - Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE\_\* mappings) when provisioning resources
- Expose ClickHouse default port (8123) in platform port mappings / network targets - Expose ClickHouse default port (8123) in platform port mappings / network targets
- UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse - UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse
- Add VS Code recommendations and launch/tasks for the UI development workflow - Add VS Code recommendations and launch/tasks for the UI development workflow
## 2025-11-26 - 1.2.1 - fix(platform-services/minio) ## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning
- MinIO provider now detects existing data directory and will reuse stored admin credentials when available instead of regenerating them. - MinIO provider now detects existing data directory and will reuse stored admin credentials when available instead of regenerating them.
@@ -210,15 +584,17 @@ Improve MinIO provider: reuse existing data and credentials, use host-bound port
- Added VSCode workspace files (extensions, launch, tasks) for the ui project to improve developer experience. - Added VSCode workspace files (extensions, launch, tasks) for the ui project to improve developer experience.
## 2025-11-26 - 1.2.0 - feat(ui) ## 2025-11-26 - 1.2.0 - feat(ui)
Sync UI tab state with URL and update routes/links Sync UI tab state with URL and update routes/links
- Add VSCode workspace recommendations, launch and tasks configs for the UI (ui/.vscode/*) - Add VSCode workspace recommendations, launch and tasks configs for the UI (ui/.vscode/\*)
- Update Angular routes to support tab URL segments and default redirects for services, network and registries - Update Angular routes to support tab URL segments and default redirects for services, network and registries
- Change service detail route to use explicit 'detail/:name' path and update links accordingly - Change service detail route to use explicit 'detail/:name' path and update links accordingly
- Make ServicesList, Registries and Network components read tab from route params and navigate on tab changes; add ngOnDestroy to unsubscribe - Make ServicesList, Registries and Network components read tab from route params and navigate on tab changes; add ngOnDestroy to unsubscribe
- Update Domain detail template link to point to the new services detail route - Update Domain detail template link to point to the new services detail route
## 2025-11-26 - 1.1.0 - feat(platform-services) ## 2025-11-26 - 1.1.0 - feat(platform-services)
Add platform service log streaming, improve health checks and provisioning robustness Add platform service log streaming, improve health checks and provisioning robustness
- Add WebSocket log streaming support for platform services (backend + UI) to stream MinIO/MongoDB/Caddy logs in real time - Add WebSocket log streaming support for platform services (backend + UI) to stream MinIO/MongoDB/Caddy logs in real time
@@ -238,6 +614,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Initial project structure - Initial project structure
- Core architecture classes - Core architecture classes
- Docker container management - Docker container management
@@ -256,4 +633,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.0] - TBD ## [1.0.0] - TBD
### Added ### Added
- First stable release - First stable release

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.13.9", "version": "1.24.2",
"exports": "./mod.ts", "exports": "./mod.ts",
"tasks": { "tasks": {
"test": "deno test --allow-all test/", "test": "deno test --allow-all test/",
@@ -15,18 +15,19 @@
"@std/assert": "jsr:@std/assert@^1.0.15", "@std/assert": "jsr:@std/assert@^1.0.15",
"@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/encoding": "jsr:@std/encoding@^1.0.10",
"@db/sqlite": "jsr:@db/sqlite@0.12.0", "@db/sqlite": "jsr:@db/sqlite@0.12.0",
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0", "@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.1",
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@^5.1.0",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3", "@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0", "@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0", "@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0", "@push.rocks/smartstorage": "npm:@push.rocks/smartstorage@^6.3.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0", "@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19", "@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6", "@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1", "@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0", "@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1" "@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.2",
"@serve.zone/containerarchive": "npm:@serve.zone/containerarchive@^0.1.3"
}, },
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [

View File

@@ -23,7 +23,7 @@ SPECIFIED_VERSION=""
INSTALL_DIR="/opt/onebox" INSTALL_DIR="/opt/onebox"
GITEA_BASE_URL="https://code.foss.global" GITEA_BASE_URL="https://code.foss.global"
GITEA_REPO="serve.zone/onebox" GITEA_REPO="serve.zone/onebox"
SERVICE_NAME="smartdaemon_onebox" SERVICE_NAME="onebox"
# Parse command line arguments # Parse command line arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -250,8 +250,10 @@ echo ""
mkdir -p /var/lib/onebox mkdir -p /var/lib/onebox
mkdir -p /var/www/certbot mkdir -p /var/www/certbot
# Restart service if it was running before update # Re-enable and restart service if it was previously running (refreshes unit file)
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "Refreshing systemd service..."
onebox systemd enable
echo "Restarting Onebox service..." echo "Restarting Onebox service..."
systemctl restart "$SERVICE_NAME" systemctl restart "$SERVICE_NAME"
echo "Service restarted successfully." echo "Service restarted successfully."
@@ -276,7 +278,7 @@ if [ -f "/var/lib/onebox/onebox.db" ]; then
if [ $SERVICE_WAS_RUNNING -eq 1 ]; then if [ $SERVICE_WAS_RUNNING -eq 1 ]; then
echo "The service has been restarted with your current settings." echo "The service has been restarted with your current settings."
else else
echo "Start the service with: onebox daemon start" echo "Start the service with: onebox systemd start"
fi fi
else else
echo "Get started:" echo "Get started:"
@@ -293,11 +295,11 @@ else
echo " 2. Configure ACME email:" echo " 2. Configure ACME email:"
echo " onebox config set acmeEmail <your@email.com>" echo " onebox config set acmeEmail <your@email.com>"
echo "" echo ""
echo " 3. Install daemon:" echo " 3. Enable systemd service:"
echo " onebox daemon install" echo " onebox systemd enable"
echo "" echo ""
echo " 4. Start daemon:" echo " 4. Start service:"
echo " onebox daemon start" echo " onebox systemd start"
echo "" echo ""
echo " 5. Deploy your first service:" echo " 5. Deploy your first service:"
echo " onebox service add myapp --image nginx:latest --domain app.example.com" echo " onebox service add myapp --image nginx:latest --domain app.example.com"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.13.9", "version": "1.24.2",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",
@@ -55,13 +55,18 @@
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34", "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"dependencies": { "dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19", "@api.global/typedrequest-interfaces": "^3.0.19",
"@design.estate/dees-catalog": "^3.43.3", "@api.global/typedsocket": "^4.1.2",
"@design.estate/dees-catalog": "^3.49.0",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.1.6",
"@serve.zone/catalog": "^2.5.0" "@serve.zone/catalog": "^2.9.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.9.0", "@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsdeno": "^1.1.1", "@git.zone/tsdeno": "^1.3.1",
"@git.zone/tswatch": "^3.2.0" "@git.zone/tswatch": "^3.3.2"
},
"private": true,
"pnpm": {
"overrides": {}
} }
} }

1329
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
## SSL Certificate Storage (November 2025) ## SSL Certificate Storage (November 2025)
SSL certificates are now stored directly in the SQLite database as PEM content instead of file paths: SSL certificates are now stored directly in the SQLite database as PEM content instead of file paths:
- `ISslCertificate` and `ICertificate` interfaces use `certPem`, `keyPem`, `fullchainPem` properties - `ISslCertificate` and `ICertificate` interfaces use `certPem`, `keyPem`, `fullchainPem` properties
- Database migration 8 converted the `certificates` table schema - Database migration 8 converted the `certificates` table schema
- No filesystem storage for certificates - everything in DB - No filesystem storage for certificates - everything in DB
@@ -16,6 +17,7 @@ SSL certificates are now stored directly in the SQLite database as PEM content i
The database layer has been refactored into a repository pattern: The database layer has been refactored into a repository pattern:
**Directory Structure:** **Directory Structure:**
``` ```
ts/database/ ts/database/
├── index.ts # Main OneboxDatabase class (composes repositories, handles migrations) ├── index.ts # Main OneboxDatabase class (composes repositories, handles migrations)
@@ -32,10 +34,12 @@ ts/database/
``` ```
**Import paths:** **Import paths:**
- Main: `import { OneboxDatabase } from './database/index.ts'` - Main: `import { OneboxDatabase } from './database/index.ts'`
- Legacy (deprecated): `import { OneboxDatabase } from './classes/database.ts'` (re-exports from new location) - Legacy (deprecated): `import { OneboxDatabase } from './classes/database.ts'` (re-exports from new location)
**API Compatibility:** **API Compatibility:**
- The `OneboxDatabase` class maintains the same public API - The `OneboxDatabase` class maintains the same public API
- All methods delegate to the appropriate repository - All methods delegate to the appropriate repository
- No breaking changes for existing code - No breaking changes for existing code
@@ -49,6 +53,7 @@ Migration 8 converted certificate storage from file paths to PEM content.
The reverse proxy uses **Caddy** running as a Docker Swarm service for production-grade reverse proxying with native SNI support, HTTP/2, HTTP/3, and WebSocket handling. The reverse proxy uses **Caddy** running as a Docker Swarm service for production-grade reverse proxying with native SNI support, HTTP/2, HTTP/3, and WebSocket handling.
**Architecture:** **Architecture:**
- Caddy runs as Docker Swarm service (`onebox-caddy`) on the overlay network - Caddy runs as Docker Swarm service (`onebox-caddy`) on the overlay network
- No binary download required - uses `caddy:2-alpine` Docker image - No binary download required - uses `caddy:2-alpine` Docker image
- Configuration pushed dynamically via Caddy Admin API (port 2019) - Configuration pushed dynamically via Caddy Admin API (port 2019)
@@ -57,10 +62,12 @@ The reverse proxy uses **Caddy** running as a Docker Swarm service for productio
- Services reached by Docker service name (e.g., `onebox-hello-world:80`) - Services reached by Docker service name (e.g., `onebox-hello-world:80`)
**Key files:** **Key files:**
- `ts/classes/caddy.ts` - CaddyManager class for Docker service and Admin API - `ts/classes/caddy.ts` - CaddyManager class for Docker service and Admin API
- `ts/classes/reverseproxy.ts` - Delegates to CaddyManager - `ts/classes/reverseproxy.ts` - Delegates to CaddyManager
**Certificate workflow:** **Certificate workflow:**
1. `CertRequirementManager` creates requirements for domains 1. `CertRequirementManager` creates requirements for domains
2. Daemon processes requirements via `certmanager.ts` 2. Daemon processes requirements via `certmanager.ts`
3. Certificates stored in database (PEM content) 3. Certificates stored in database (PEM content)
@@ -68,16 +75,19 @@ The reverse proxy uses **Caddy** running as a Docker Swarm service for productio
5. Caddy serves TLS with the loaded certificates (no volume mounts needed) 5. Caddy serves TLS with the loaded certificates (no volume mounts needed)
**Docker Service Configuration:** **Docker Service Configuration:**
- Service name: `onebox-caddy` - Service name: `onebox-caddy`
- Image: `caddy:2-alpine` - Image: `caddy:2-alpine`
- Network: `onebox-network` (overlay, attachable) - Network: `onebox-network` (overlay, attachable)
- Startup: Writes initial config with `admin.listen: 0.0.0.0:2019` for host access - Startup: Writes initial config with `admin.listen: 0.0.0.0:2019` for host access
**Port Mapping:** **Port Mapping:**
- Dev mode: HTTP on 8080, HTTPS on 8443, Admin on 2019 - Dev mode: HTTP on 8080, HTTPS on 8443, Admin on 2019
- Production: HTTP on 80, HTTPS on 443, Admin on 2019 - Production: HTTP on 80, HTTPS on 443, Admin on 2019
- All ports use `PublishMode: 'host'` for direct binding - All ports use `PublishMode: 'host'` for direct binding
**Log Receiver:** **Log Receiver:**
- Caddy sends access logs to `tcp/172.17.0.1:9999` (Docker bridge gateway) - Caddy sends access logs to `tcp/172.17.0.1:9999` (Docker bridge gateway)
- `CaddyLogReceiver` on host receives and processes logs - `CaddyLogReceiver` on host receives and processes logs

121
readme.md
View File

@@ -22,6 +22,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
## Features ✨ ## Features ✨
### Core Platform ### Core Platform
- 🐳 **Docker Swarm Management** - Deploy, scale, and orchestrate services with Swarm mode - 🐳 **Docker Swarm Management** - Deploy, scale, and orchestrate services with Swarm mode
- 🌐 **Caddy Reverse Proxy** - Production-grade proxy running as Docker service with SNI, HTTP/2, HTTP/3 - 🌐 **Caddy Reverse Proxy** - Production-grade proxy running as Docker service with SNI, HTTP/2, HTTP/3
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring - 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring
@@ -30,6 +31,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events - 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events
### Monitoring & Management ### Monitoring & Management
- 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s) - 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s)
- 📝 **Centralized Logging** - Container logs with streaming and retention policies - 📝 **Centralized Logging** - Container logs with streaming and retention policies
- 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates - 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates
@@ -37,6 +39,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- 💾 **SQLite Database** - Embedded, zero-configuration storage - 💾 **SQLite Database** - Embedded, zero-configuration storage
### Developer Experience ### Developer Experience
- 🚀 **Auto-update on Push** - Push to registry and services update automatically - 🚀 **Auto-update on Push** - Push to registry and services update automatically
- 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries - 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries
- 🔄 **Systemd Integration** - Run as a daemon with auto-restart - 🔄 **Systemd Integration** - Run as a daemon with auto-restart
@@ -75,6 +78,7 @@ onebox service add myapp \
Open `http://localhost:3000` in your browser. Open `http://localhost:3000` in your browser.
**Default credentials:** **Default credentials:**
- Username: `admin` - Username: `admin`
- Password: `admin` - Password: `admin`
@@ -130,15 +134,15 @@ Onebox is built with modern technologies for performance and developer experienc
### Core Components ### Core Components
| Component | Description | | Component | Description |
|-----------|-------------| | ----------------------- | -------------------------------------------------------------------- |
| **Deno Runtime** | Modern TypeScript with built-in security | | **Deno Runtime** | Modern TypeScript with built-in security |
| **Caddy Reverse Proxy** | Docker Swarm service with HTTP/2, HTTP/3, SNI, and WebSocket support | | **Caddy Reverse Proxy** | Docker Swarm service with HTTP/2, HTTP/3, SNI, and WebSocket support |
| **Docker Swarm** | Container orchestration (all workloads run as services) | | **Docker Swarm** | Container orchestration (all workloads run as services) |
| **SQLite Database** | Configuration, metrics, and user data | | **SQLite Database** | Configuration, metrics, and user data |
| **WebSocket Server** | Real-time bidirectional communication | | **WebSocket Server** | Real-time bidirectional communication |
| **Let's Encrypt** | Automatic SSL certificate management | | **Let's Encrypt** | Automatic SSL certificate management |
| **Cloudflare API** | DNS record automation | | **Cloudflare API** | DNS record automation |
## CLI Reference 📖 ## CLI Reference 📖
@@ -262,11 +266,11 @@ sudo onebox upgrade
### Data Locations ### Data Locations
| Data | Location | | Data | Location |
|------|----------| | -------------------- | ------------------------------ |
| **Database** | `./onebox.db` (or custom path) | | **Database** | `./onebox.db` (or custom path) |
| **SSL Certificates** | Managed by CertManager | | **SSL Certificates** | Managed by CertManager |
| **Registry Data** | `./.nogit/registry-data` | | **Registry Data** | `./.nogit/registry-data` |
### Environment Variables ### Environment Variables
@@ -355,62 +359,69 @@ onebox/
The HTTP server exposes a comprehensive REST API: The HTTP server exposes a comprehensive REST API:
#### Authentication #### Authentication
| Method | Endpoint | Description |
|--------|----------|-------------| | Method | Endpoint | Description |
| ------ | ----------------- | ----------------------------------- |
| `POST` | `/api/auth/login` | User authentication (returns token) | | `POST` | `/api/auth/login` | User authentication (returns token) |
#### Services #### Services
| Method | Endpoint | Description |
|--------|----------|-------------| | Method | Endpoint | Description |
| `GET` | `/api/services` | List all services | | -------- | --------------------------------- | ------------------------- |
| `POST` | `/api/services` | Create/deploy service | | `GET` | `/api/services` | List all services |
| `GET` | `/api/services/:name` | Get service details | | `POST` | `/api/services` | Create/deploy service |
| `PUT` | `/api/services/:name` | Update service | | `GET` | `/api/services/:name` | Get service details |
| `DELETE` | `/api/services/:name` | Delete service | | `PUT` | `/api/services/:name` | Update service |
| `POST` | `/api/services/:name/start` | Start service | | `DELETE` | `/api/services/:name` | Delete service |
| `POST` | `/api/services/:name/stop` | Stop service | | `POST` | `/api/services/:name/start` | Start service |
| `POST` | `/api/services/:name/restart` | Restart service | | `POST` | `/api/services/:name/stop` | Stop service |
| `GET` | `/api/services/:name/logs` | Get service logs | | `POST` | `/api/services/:name/restart` | Restart service |
| `WS` | `/api/services/:name/logs/stream` | Stream logs via WebSocket | | `GET` | `/api/services/:name/logs` | Get service logs |
| `WS` | `/api/services/:name/logs/stream` | Stream logs via WebSocket |
#### SSL Certificates #### SSL Certificates
| Method | Endpoint | Description |
|--------|----------|-------------| | Method | Endpoint | Description |
| `GET` | `/api/ssl/list` | List all certificates | | ------ | ------------------------ | ----------------------- |
| `GET` | `/api/ssl/:domain` | Get certificate details | | `GET` | `/api/ssl/list` | List all certificates |
| `POST` | `/api/ssl/obtain` | Request new certificate | | `GET` | `/api/ssl/:domain` | Get certificate details |
| `POST` | `/api/ssl/obtain` | Request new certificate |
| `POST` | `/api/ssl/:domain/renew` | Force renew certificate | | `POST` | `/api/ssl/:domain/renew` | Force renew certificate |
#### Domains #### Domains
| Method | Endpoint | Description |
|--------|----------|-------------| | Method | Endpoint | Description |
| `GET` | `/api/domains` | List all domains | | ------ | ---------------------- | ---------------------------- |
| `GET` | `/api/domains/:domain` | Get domain details | | `GET` | `/api/domains` | List all domains |
| `POST` | `/api/domains/sync` | Sync domains from Cloudflare | | `GET` | `/api/domains/:domain` | Get domain details |
| `POST` | `/api/domains/sync` | Sync domains from Cloudflare |
#### DNS Records #### DNS Records
| Method | Endpoint | Description |
|--------|----------|-------------| | Method | Endpoint | Description |
| `GET` | `/api/dns` | List DNS records | | -------- | ------------------ | ------------------------ |
| `POST` | `/api/dns` | Create DNS record | | `GET` | `/api/dns` | List DNS records |
| `DELETE` | `/api/dns/:domain` | Delete DNS record | | `POST` | `/api/dns` | Create DNS record |
| `POST` | `/api/dns/sync` | Sync DNS from Cloudflare | | `DELETE` | `/api/dns/:domain` | Delete DNS record |
| `POST` | `/api/dns/sync` | Sync DNS from Cloudflare |
#### Registry #### Registry
| Method | Endpoint | Description |
|--------|----------|-------------| | Method | Endpoint | Description |
| `GET` | `/api/registry/tags/:service` | Get registry tags for service | | -------- | ----------------------------- | ----------------------------- |
| `GET` | `/api/registry/tokens` | List registry tokens | | `GET` | `/api/registry/tags/:service` | Get registry tags for service |
| `POST` | `/api/registry/tokens` | Create registry token | | `GET` | `/api/registry/tokens` | List registry tokens |
| `DELETE` | `/api/registry/tokens/:id` | Delete registry token | | `POST` | `/api/registry/tokens` | Create registry token |
| `DELETE` | `/api/registry/tokens/:id` | Delete registry token |
#### System #### System
| Method | Endpoint | Description |
|--------|----------|-------------| | Method | Endpoint | Description |
| `GET` | `/api/status` | System status | | ------ | --------------- | ------------------------------- |
| `GET` | `/api/settings` | Get settings | | `GET` | `/api/status` | System status |
| `PUT` | `/api/settings` | Update settings | | `GET` | `/api/settings` | Get settings |
| `WS` | `/api/ws` | WebSocket for real-time updates | | `PUT` | `/api/settings` | Update settings |
| `WS` | `/api/ws` | WebSocket for real-time updates |
### WebSocket Messages ### WebSocket Messages

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.13.9', version: '1.24.2',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }

View File

@@ -0,0 +1,73 @@
/**
* App Store type definitions
*/
export interface ICatalog {
schemaVersion: number;
updatedAt: string;
apps: ICatalogApp[];
}
export interface ICatalogApp {
id: string;
name: string;
description: string;
category: string;
iconName?: string;
iconUrl?: string;
latestVersion: string;
tags?: string[];
}
export interface IAppMeta {
id: string;
name: string;
description: string;
category: string;
iconName?: string;
latestVersion: string;
versions: string[];
maintainer?: string;
links?: Record<string, string>;
}
export interface IAppVersionConfig {
image: string;
port: number;
envVars?: Array<{ key: string; value: string; description: string; required?: boolean }>;
volumes?: string[];
platformRequirements?: {
mongodb?: boolean;
s3?: boolean;
clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
};
minOneboxVersion?: string;
}
export interface IMigrationContext {
service: {
name: string;
image: string;
envVars: Record<string, string>;
port: number;
};
fromVersion: string;
toVersion: string;
}
export interface IMigrationResult {
success: boolean;
envVars?: Record<string, string>;
image?: string;
warnings: string[];
}
export interface IUpgradeableService {
serviceName: string;
appTemplateId: string;
currentVersion: string;
latestVersion: string;
hasMigration: boolean;
}

335
ts/classes/appstore.ts Normal file
View File

@@ -0,0 +1,335 @@
/**
* App Store Manager
* Fetches, caches, and serves app templates from the remote appstore-apptemplates repo.
* The remote repo is the single source of truth — no fallback catalog.
*/
import type {
ICatalog,
ICatalogApp,
IAppMeta,
IAppVersionConfig,
IMigrationContext,
IMigrationResult,
IUpgradeableService,
} from './appstore-types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
import type { IService } from '../types.ts';
export class AppStoreManager {
private oneboxRef: Onebox;
private catalogCache: ICatalog | null = null;
private lastFetchTime = 0;
private readonly repoBaseUrl = 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
async init(): Promise<void> {
try {
await this.getCatalog();
logger.info(`App Store initialized with ${this.catalogCache?.apps.length || 0} templates`);
} catch (error) {
logger.warn(`App Store initialization failed: ${getErrorMessage(error)}`);
logger.warn('App Store will retry on next request');
}
}
/**
* Get the catalog (cached, refreshes after TTL)
*/
async getCatalog(): Promise<ICatalog> {
const now = Date.now();
if (this.catalogCache && (now - this.lastFetchTime) < this.cacheTtlMs) {
return this.catalogCache;
}
try {
const catalog = await this.fetchJson('catalog.json') as ICatalog;
if (catalog && catalog.apps && Array.isArray(catalog.apps)) {
this.catalogCache = catalog;
this.lastFetchTime = now;
return catalog;
}
throw new Error('Invalid catalog format');
} catch (error) {
logger.warn(`Failed to fetch remote catalog: ${getErrorMessage(error)}`);
// Return cached if available, otherwise return empty catalog
if (this.catalogCache) {
return this.catalogCache;
}
return { schemaVersion: 1, updatedAt: '', apps: [] };
}
}
/**
* Get the catalog apps list (convenience method for the API)
*/
async getApps(): Promise<ICatalogApp[]> {
const catalog = await this.getCatalog();
return catalog.apps;
}
/**
* Fetch app metadata (versions list, etc.)
*/
async getAppMeta(appId: string): Promise<IAppMeta> {
try {
return await this.fetchJson(`apps/${appId}/app.json`) as IAppMeta;
} catch (error) {
throw new Error(`Failed to fetch metadata for app '${appId}': ${getErrorMessage(error)}`);
}
}
/**
* Fetch full config for an app version
*/
async getAppVersionConfig(appId: string, version: string): Promise<IAppVersionConfig> {
try {
return await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig;
} catch (error) {
throw new Error(`Failed to fetch config for ${appId}@${version}: ${getErrorMessage(error)}`);
}
}
/**
* Compare deployed services against catalog to find those with available upgrades
*/
async getUpgradeableServices(): Promise<IUpgradeableService[]> {
const catalog = await this.getCatalog();
const services = this.oneboxRef.database.getAllServices();
const upgradeable: IUpgradeableService[] = [];
for (const service of services) {
if (!service.appTemplateId || !service.appTemplateVersion) continue;
const catalogApp = catalog.apps.find(a => a.id === service.appTemplateId);
if (!catalogApp) continue;
if (catalogApp.latestVersion !== service.appTemplateVersion) {
// Check if a migration script exists
const hasMigration = await this.hasMigrationScript(
service.appTemplateId,
service.appTemplateVersion,
catalogApp.latestVersion,
);
upgradeable.push({
serviceName: service.name,
appTemplateId: service.appTemplateId,
currentVersion: service.appTemplateVersion,
latestVersion: catalogApp.latestVersion,
hasMigration,
});
}
}
return upgradeable;
}
/**
* Check if a migration script exists for a specific version transition
*/
async hasMigrationScript(appId: string, fromVersion: string, toVersion: string): Promise<boolean> {
try {
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`;
await this.fetchText(scriptPath);
return true;
} catch {
return false;
}
}
/**
* Execute a migration in a sandboxed Deno child process
*/
async executeMigration(service: IService, fromVersion: string, toVersion: string): Promise<IMigrationResult> {
const appId = service.appTemplateId;
if (!appId) {
throw new Error('Service has no appTemplateId');
}
// Fetch the migration script
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`;
let scriptContent: string;
try {
scriptContent = await this.fetchText(scriptPath);
} catch {
// No migration script — do a simple config-based upgrade
logger.info(`No migration script for ${appId} ${fromVersion} -> ${toVersion}, using config-only upgrade`);
const config = await this.getAppVersionConfig(appId, toVersion);
return {
success: true,
image: config.image,
envVars: undefined, // Keep existing env vars
warnings: [],
};
}
// Write to temp file
const tempFile = `/tmp/onebox-migration-${crypto.randomUUID()}.ts`;
await Deno.writeTextFile(tempFile, scriptContent);
try {
// Prepare context
const context: IMigrationContext = {
service: {
name: service.name,
image: service.image,
envVars: service.envVars,
port: service.port,
},
fromVersion,
toVersion,
};
// Execute in sandboxed Deno child process
const cmd = new Deno.Command('deno', {
args: ['run', '--allow-env', '--allow-net=none', '--allow-read=none', '--allow-write=none', tempFile],
stdin: 'piped',
stdout: 'piped',
stderr: 'piped',
});
const child = cmd.spawn();
// Write context to stdin
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(JSON.stringify(context)));
await writer.close();
// Read result
const output = await child.output();
const exitCode = output.code;
const stdout = new TextDecoder().decode(output.stdout);
const stderr = new TextDecoder().decode(output.stderr);
if (exitCode !== 0) {
logger.error(`Migration script failed (exit ${exitCode}): ${stderr.substring(0, 500)}`);
return {
success: false,
warnings: [`Migration script failed: ${stderr.substring(0, 200)}`],
};
}
// Parse result from stdout
try {
const result = JSON.parse(stdout) as IMigrationResult;
result.success = true;
return result;
} catch {
logger.error(`Failed to parse migration output: ${stdout.substring(0, 200)}`);
return {
success: false,
warnings: ['Migration script produced invalid output'],
};
}
} finally {
// Cleanup temp file
try {
await Deno.remove(tempFile);
} catch {
// Ignore cleanup errors
}
}
}
/**
* Apply an upgrade: update image, env vars, recreate container
*/
async applyUpgrade(
serviceName: string,
migrationResult: IMigrationResult,
newVersion: string,
): Promise<IService> {
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
throw new Error(`Service not found: ${serviceName}`);
}
// Stop the existing container
if (service.containerID && service.status === 'running') {
await this.oneboxRef.services.stopService(serviceName);
}
// Update service record
const updates: Partial<IService> = {
appTemplateVersion: newVersion,
};
if (migrationResult.image) {
updates.image = migrationResult.image;
}
if (migrationResult.envVars) {
// Merge: migration result provides base, user overrides preserved
const mergedEnvVars = { ...migrationResult.envVars };
// Keep any user-set env vars that aren't in the migration result
for (const [key, value] of Object.entries(service.envVars)) {
if (!(key in mergedEnvVars)) {
mergedEnvVars[key] = value;
}
}
updates.envVars = mergedEnvVars;
}
this.oneboxRef.database.updateService(service.id!, updates);
// Pull new image if changed
const newImage = migrationResult.image || service.image;
if (migrationResult.image && migrationResult.image !== service.image) {
await this.oneboxRef.docker.pullImage(newImage);
}
// Recreate and start container
const updatedService = this.oneboxRef.database.getServiceByName(serviceName)!;
// Remove old container
if (service.containerID) {
try {
await this.oneboxRef.docker.removeContainer(service.containerID, true);
} catch {
// Container might already be gone
}
}
// Create new container
const containerID = await this.oneboxRef.docker.createContainer(updatedService);
this.oneboxRef.database.updateService(service.id!, { containerID, status: 'starting' });
// Start container
await this.oneboxRef.docker.startContainer(containerID);
this.oneboxRef.database.updateService(service.id!, { status: 'running' });
logger.success(`Service '${serviceName}' upgraded to template version ${newVersion}`);
return this.oneboxRef.database.getServiceByName(serviceName)!;
}
/**
* Fetch JSON from the remote repo
*/
private async fetchJson(path: string): Promise<unknown> {
const url = `${this.repoBaseUrl}/${path}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
/**
* Fetch text from the remote repo
*/
private async fetchText(path: string): Promise<string> {
const url = `${this.repoBaseUrl}/${path}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.text();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,15 @@ export class BackupScheduler {
await this.registerTask(schedule); await this.registerTask(schedule);
} }
// Add periodic archive prune task (runs daily at 3 AM)
const pruneTask = new plugins.taskbuffer.Task({
name: 'backup-archive-prune',
taskFunction: async () => {
await this.pruneArchive();
},
});
this.taskManager.addAndScheduleTask(pruneTask, '0 3 * * *');
// Start the task manager (activates cron scheduling) // Start the task manager (activates cron scheduling)
await this.taskManager.start(); await this.taskManager.start();
@@ -436,9 +445,11 @@ export class BackupScheduler {
if (!toKeep.has(backup.id!)) { if (!toKeep.has(backup.id!)) {
try { try {
await this.oneboxRef.backupManager.deleteBackup(backup.id!); await this.oneboxRef.backupManager.deleteBackup(backup.id!);
logger.info(`Deleted backup ${backup.filename} (retention policy)`); const backupRef = backup.snapshotId || backup.filename;
logger.info(`Deleted backup ${backupRef} (retention policy)`);
} catch (error) { } catch (error) {
logger.warn(`Failed to delete old backup ${backup.filename}: ${getErrorMessage(error)}`); const backupRef = backup.snapshotId || backup.filename;
logger.warn(`Failed to delete old backup ${backupRef}: ${getErrorMessage(error)}`);
} }
} }
} }
@@ -647,4 +658,48 @@ export class BackupScheduler {
private getRetentionDescription(retention: IRetentionPolicy): string { private getRetentionDescription(retention: IRetentionPolicy): string {
return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`; return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`;
} }
/**
* Prune the containerarchive repository to reclaim storage.
* Uses the most generous retention policy across all schedules.
*/
private async pruneArchive(): Promise<void> {
const archive = this.oneboxRef.backupManager.archive;
if (!archive) return;
try {
// Compute the most generous retention across all schedules
const schedules = this.oneboxRef.database.getAllBackupSchedules();
// Default minimums if no schedules exist
let maxDays = 7;
let maxWeeks = 4;
let maxMonths = 12;
for (const schedule of schedules) {
if (schedule.retention.daily > maxDays) maxDays = schedule.retention.daily;
if (schedule.retention.weekly > maxWeeks) maxWeeks = schedule.retention.weekly;
if (schedule.retention.monthly > maxMonths) maxMonths = schedule.retention.monthly;
}
const result = await archive.prune(
{
keepDays: maxDays,
keepWeeks: maxWeeks,
keepMonths: maxMonths,
},
false, // not dry run
);
if (result.removedSnapshots > 0 || result.freedBytes > 0) {
const freedMB = Math.round(result.freedBytes / (1024 * 1024) * 10) / 10;
logger.info(
`Archive prune: removed ${result.removedSnapshots} snapshot(s), ` +
`${result.removedPacks} pack(s), freed ${freedMB} MB`
);
}
} catch (error) {
logger.warn(`Archive prune failed: ${getErrorMessage(error)}`);
}
}
} }

View File

@@ -4,9 +4,7 @@
* Handles background monitoring, metrics collection, and automatic tasks * Handles background monitoring, metrics collection, and automatic tasks
*/ */
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts'; import type { Onebox } from './onebox.ts';
@@ -18,7 +16,6 @@ const FALLBACK_PID_FILE = `${FALLBACK_PID_DIR}/onebox.pid`;
export class OneboxDaemon { export class OneboxDaemon {
private oneboxRef: Onebox; private oneboxRef: Onebox;
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
private running = false; private running = false;
private monitoringInterval: number | null = null; private monitoringInterval: number | null = null;
private statsInterval: number | null = null; private statsInterval: number | null = null;
@@ -46,68 +43,6 @@ export class OneboxDaemon {
} }
} }
/**
* Install systemd service
*/
async installService(): Promise<void> {
try {
logger.info('Installing Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
// Get installation directory
const execPath = Deno.execPath();
const service = await this.smartdaemon.addService({
name: 'onebox',
version: projectInfo.version,
command: `${execPath} run --allow-all ${Deno.cwd()}/mod.ts daemon start`,
description: 'Onebox - Self-hosted container platform',
workingDir: Deno.cwd(),
});
await service.save();
await service.enable();
logger.success('Onebox daemon service installed');
logger.info('Start with: sudo systemctl start smartdaemon_onebox');
} catch (error) {
logger.error(`Failed to install daemon service: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Uninstall systemd service
*/
async uninstallService(): Promise<void> {
try {
logger.info('Uninstalling Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
const services = await this.smartdaemon.systemdManager.getServices();
const service = services.find(s => s.name === 'onebox');
if (service) {
await service.stop();
await service.disable();
await service.delete();
}
logger.success('Onebox daemon service uninstalled');
} catch (error) {
logger.error(`Failed to uninstall daemon service: ${getErrorMessage(error)}`);
throw error;
}
}
/** /**
* Start daemon mode (background monitoring) * Start daemon mode (background monitoring)
*/ */
@@ -482,36 +417,7 @@ export class OneboxDaemon {
static async ensureNoDaemon(): Promise<void> { static async ensureNoDaemon(): Promise<void> {
const running = await OneboxDaemon.isDaemonRunning(); const running = await OneboxDaemon.isDaemonRunning();
if (running) { if (running) {
throw new Error('Daemon is already running. Please stop it first with: onebox daemon stop'); throw new Error('Daemon is already running. Please stop it first with: onebox systemd stop');
}
}
/**
* Get service status from systemd
*/
async getServiceStatus(): Promise<string> {
try {
// Don't need smartdaemon to check status, just use systemctl directly
const command = new Deno.Command('systemctl', {
args: ['status', 'smartdaemon_onebox'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stdout } = await command.output();
const output = new TextDecoder().decode(stdout);
if (code === 0 || output.includes('active (running)')) {
return 'running';
} else if (output.includes('inactive') || output.includes('dead')) {
return 'stopped';
} else if (output.includes('failed')) {
return 'failed';
} else {
return 'unknown';
}
} catch (error) {
return 'not-installed';
} }
} }
} }

View File

@@ -596,18 +596,26 @@ export class OneboxDockerManager {
async getContainerStats(containerID: string): Promise<IContainerStats | null> { async getContainerStats(containerID: string): Promise<IContainerStats | null> {
try { try {
// Try to get container directly first // Try to get container directly first
let container = await this.dockerClient!.getContainerById(containerID); let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Container not found by ID — might be a Swarm service ID
}
// If not found, it might be a service ID - try to get the actual container ID // If not found, it might be a service ID - try to get the actual container ID
if (!container) { if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID); const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) { if (serviceContainerId) {
container = await this.dockerClient!.getContainerById(serviceContainerId); try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
} }
} }
if (!container) { if (!container) {
// Container/service not found
return null; return null;
} }
@@ -849,7 +857,23 @@ export class OneboxDockerManager {
cmd: string[] cmd: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> { ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try { try {
const container = await this.dockerClient!.getContainerById(containerID); let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Not a direct container ID — try Swarm service lookup
}
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
}
}
if (!container) { if (!container) {
throw new Error(`Container not found: ${containerID}`); throw new Error(`Container not found: ${containerID}`);
@@ -881,12 +905,12 @@ export class OneboxDockerManager {
]); ]);
const execInfo = await inspect(); const execInfo = await inspect();
const exitCode = execInfo.ExitCode || 0; const exitCode = execInfo.ExitCode ?? -1;
return { stdout, stderr, exitCode }; return { stdout, stderr, exitCode };
} catch (error) { } catch (error) {
logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`); logger.error(`Failed to exec in container ${containerID}: ${getErrorMessage(error)}`);
throw error; return { stdout: '', stderr: getErrorMessage(error), exitCode: -1 };
} }
} }
@@ -1011,7 +1035,23 @@ export class OneboxDockerManager {
callback: (line: string, isError: boolean) => void callback: (line: string, isError: boolean) => void
): Promise<void> { ): Promise<void> {
try { try {
const container = await this.dockerClient!.getContainerById(containerID); let container: any = null;
try {
container = await this.dockerClient!.getContainerById(containerID);
} catch {
// Not a direct container ID — try Swarm service lookup
}
if (!container) {
const serviceContainerId = await this.getContainerIdForService(containerID);
if (serviceContainerId) {
try {
container = await this.dockerClient!.getContainerById(serviceContainerId);
} catch {
// Service container also not found
}
}
}
if (!container) { if (!container) {
throw new Error(`Container not found: ${containerID}`); throw new Error(`Container not found: ${containerID}`);

View File

@@ -2161,27 +2161,47 @@ export class OneboxHttpServer {
*/ */
private async handleDownloadBackupRequest(backupId: number): Promise<Response> { private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
try { try {
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId); const backup = this.oneboxRef.database.getBackupById(backupId);
if (!filePath) { if (!backup) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404); return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
} }
let downloadPath: string | null = null;
let tempExport = false;
if (backup.snapshotId) {
// ContainerArchive backup: export as encrypted tar
downloadPath = await this.oneboxRef.backupManager.getBackupExportPath(backupId);
tempExport = true;
} else {
// Legacy file-based backup
downloadPath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
}
if (!downloadPath) {
return this.jsonResponse({ success: false, error: 'Backup file not available' }, 404);
}
// Check if file exists // Check if file exists
try { try {
await Deno.stat(filePath); await Deno.stat(downloadPath);
} catch { } catch {
return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404); return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404);
} }
// Read file and return as download const file = await Deno.readFile(downloadPath);
const backup = this.oneboxRef.database.getBackupById(backupId); const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`;
const file = await Deno.readFile(filePath);
// Clean up temp export file
if (tempExport) {
try { await Deno.remove(downloadPath); } catch { /* ignore */ }
}
return new Response(file, { return new Response(file, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`, 'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(file.length), 'Content-Length': String(file.length),
}, },
}); });
@@ -2241,12 +2261,6 @@ export class OneboxHttpServer {
}, 400); }, 400);
} }
// Get backup file path
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
if (!filePath) {
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
}
// Validate mode-specific requirements // Validate mode-specific requirements
if ((mode === 'import' || mode === 'clone') && !newServiceName) { if ((mode === 'import' || mode === 'clone') && !newServiceName) {
return this.jsonResponse({ return this.jsonResponse({
@@ -2255,7 +2269,7 @@ export class OneboxHttpServer {
}, 400); }, 400);
} }
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, { const result = await this.oneboxRef.backupManager.restoreBackup(backupId, {
mode, mode,
newServiceName, newServiceName,
overwriteExisting: overwriteExisting === true, overwriteExisting: overwriteExisting === true,

View File

@@ -14,11 +14,13 @@ import { OneboxReverseProxy } from './reverseproxy.ts';
import { OneboxDnsManager } from './dns.ts'; import { OneboxDnsManager } from './dns.ts';
import { OneboxSslManager } from './ssl.ts'; import { OneboxSslManager } from './ssl.ts';
import { OneboxDaemon } from './daemon.ts'; import { OneboxDaemon } from './daemon.ts';
import { OneboxSystemd } from './systemd.ts';
import { OneboxHttpServer } from './httpserver.ts'; import { OneboxHttpServer } from './httpserver.ts';
import { CloudflareDomainSync } from './cloudflare-sync.ts'; import { CloudflareDomainSync } from './cloudflare-sync.ts';
import { CertRequirementManager } from './cert-requirement-manager.ts'; import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts'; import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts'; import { PlatformServicesManager } from './platform-services/index.ts';
import { AppStoreManager } from './appstore.ts';
import { CaddyLogReceiver } from './caddy-log-receiver.ts'; import { CaddyLogReceiver } from './caddy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts'; import { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts'; import { BackupScheduler } from './backup-scheduler.ts';
@@ -33,11 +35,13 @@ export class Onebox {
public dns: OneboxDnsManager; public dns: OneboxDnsManager;
public ssl: OneboxSslManager; public ssl: OneboxSslManager;
public daemon: OneboxDaemon; public daemon: OneboxDaemon;
public systemd: OneboxSystemd;
public httpServer: OneboxHttpServer; public httpServer: OneboxHttpServer;
public cloudflareDomainSync: CloudflareDomainSync; public cloudflareDomainSync: CloudflareDomainSync;
public certRequirementManager: CertRequirementManager; public certRequirementManager: CertRequirementManager;
public registry: RegistryManager; public registry: RegistryManager;
public platformServices: PlatformServicesManager; public platformServices: PlatformServicesManager;
public appStore: AppStoreManager;
public caddyLogReceiver: CaddyLogReceiver; public caddyLogReceiver: CaddyLogReceiver;
public backupManager: BackupManager; public backupManager: BackupManager;
public backupScheduler: BackupScheduler; public backupScheduler: BackupScheduler;
@@ -57,6 +61,7 @@ export class Onebox {
this.dns = new OneboxDnsManager(this); this.dns = new OneboxDnsManager(this);
this.ssl = new OneboxSslManager(this); this.ssl = new OneboxSslManager(this);
this.daemon = new OneboxDaemon(this); this.daemon = new OneboxDaemon(this);
this.systemd = new OneboxSystemd();
this.httpServer = new OneboxHttpServer(this); this.httpServer = new OneboxHttpServer(this);
this.registry = new RegistryManager({ this.registry = new RegistryManager({
dataDir: './.nogit/registry-data', dataDir: './.nogit/registry-data',
@@ -71,6 +76,9 @@ export class Onebox {
// Initialize platform services manager // Initialize platform services manager
this.platformServices = new PlatformServicesManager(this); this.platformServices = new PlatformServicesManager(this);
// Initialize App Store manager
this.appStore = new AppStoreManager(this);
// Initialize Caddy log receiver // Initialize Caddy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999); this.caddyLogReceiver = new CaddyLogReceiver(9999);
@@ -170,12 +178,28 @@ export class Onebox {
logger.warn(`Error: ${getErrorMessage(error)}`); logger.warn(`Error: ${getErrorMessage(error)}`);
} }
// Initialize App Store (non-critical)
try {
await this.appStore.init();
} catch (error) {
logger.warn('App Store initialization failed - app templates will be unavailable until reconnected');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
// Login to all registries // Login to all registries
await this.registries.loginToAllRegistries(); await this.registries.loginToAllRegistries();
// Start auto-update monitoring for registry services // Start auto-update monitoring for registry services
this.services.startAutoUpdateMonitoring(); this.services.startAutoUpdateMonitoring();
// Initialize BackupManager (containerarchive repository, non-critical)
try {
await this.backupManager.init();
} catch (error) {
logger.warn('BackupManager initialization failed - backups will be limited');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
// Initialize Backup Scheduler (non-critical) // Initialize Backup Scheduler (non-critical)
try { try {
await this.backupScheduler.init(); await this.backupScheduler.init();
@@ -288,10 +312,65 @@ export class Onebox {
// Sort expiring domains by days remaining (ascending) // Sort expiring domains by days remaining (ascending)
expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining); expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining);
// Aggregate resource usage across all running service containers
let totalCpu = 0;
let totalMemoryUsed = 0;
let totalMemoryLimit = 0;
let totalNetworkIn = 0;
let totalNetworkOut = 0;
if (dockerRunning) {
const allServices = this.services.listServices();
const runningUserServices = allServices.filter((s) => s.status === 'running' && s.containerID);
logger.debug(`Resource stats: ${runningUserServices.length} running user services`);
const statsPromises = runningUserServices
.map((s) => {
logger.debug(`Fetching stats for user service: ${s.name} (${s.containerID})`);
return this.docker.getContainerStats(s.containerID!).catch((err) => {
logger.debug(`Stats failed for ${s.name}: ${(err as Error).message}`);
return null;
});
});
// Also get stats for platform service containers
const allPlatformServices = this.platformServices.getAllPlatformServices();
const runningPlatformServices = allPlatformServices.filter((s) => s.status === 'running' && s.containerId);
logger.debug(`Resource stats: ${runningPlatformServices.length} running platform services`);
const platformStatsPromises = runningPlatformServices
.map((s) => {
logger.debug(`Fetching stats for platform service: ${s.type} (${s.containerId})`);
return this.docker.getContainerStats(s.containerId!).catch((err) => {
logger.debug(`Stats failed for ${s.type}: ${(err as Error).message}`);
return null;
});
});
const allStats = await Promise.all([...statsPromises, ...platformStatsPromises]);
let successCount = 0;
for (const stats of allStats) {
if (stats) {
successCount++;
totalCpu += stats.cpuPercent;
totalMemoryUsed += stats.memoryUsed;
totalMemoryLimit = Math.max(totalMemoryLimit, stats.memoryLimit);
totalNetworkIn += stats.networkRx;
totalNetworkOut += stats.networkTx;
}
}
logger.debug(`Resource stats: ${successCount}/${allStats.length} containers returned stats. CPU: ${totalCpu}, Mem: ${totalMemoryUsed}`);
}
return { return {
docker: { docker: {
running: dockerRunning, running: dockerRunning,
version: dockerRunning ? await this.docker.getDockerVersion() : null, version: dockerRunning ? await this.docker.getDockerVersion() : null,
cpuUsage: Math.round(totalCpu * 10) / 10,
memoryUsage: totalMemoryUsed,
memoryTotal: totalMemoryLimit,
networkIn: totalNetworkIn,
networkOut: totalNetworkOut,
}, },
reverseProxy: proxyStatus, reverseProxy: proxyStatus,
dns: { dns: {
@@ -320,20 +399,6 @@ export class Onebox {
} }
} }
/**
* Start daemon mode
*/
async startDaemon(): Promise<void> {
await this.daemon.start();
}
/**
* Stop daemon mode
*/
async stopDaemon(): Promise<void> {
await this.daemon.stop();
}
/** /**
* Start OpsServer (TypedRequest-based, serves new UI) * Start OpsServer (TypedRequest-based, serves new UI)
*/ */
@@ -355,6 +420,9 @@ export class Onebox {
try { try {
logger.info('Shutting down Onebox...'); logger.info('Shutting down Onebox...');
// Stop auto-update monitoring
this.services.stopAutoUpdateMonitoring();
// Stop backup scheduler // Stop backup scheduler
await this.backupScheduler.stop(); await this.backupScheduler.stop();
@@ -370,6 +438,9 @@ export class Onebox {
// Stop Caddy log receiver // Stop Caddy log receiver
await this.caddyLogReceiver.stop(); await this.caddyLogReceiver.stop();
// Close backup archive
await this.backupManager.close();
// Close database // Close database
this.database.close(); this.database.close();

View File

@@ -8,3 +8,6 @@ export type { IPlatformServiceProvider } from './providers/base.ts';
export { BasePlatformServiceProvider } from './providers/base.ts'; export { BasePlatformServiceProvider } from './providers/base.ts';
export { MongoDBProvider } from './providers/mongodb.ts'; export { MongoDBProvider } from './providers/mongodb.ts';
export { MinioProvider } from './providers/minio.ts'; export { MinioProvider } from './providers/minio.ts';
export { ClickHouseProvider } from './providers/clickhouse.ts';
export { MariaDBProvider } from './providers/mariadb.ts';
export { RedisProvider } from './providers/redis.ts';

View File

@@ -16,6 +16,8 @@ import { MongoDBProvider } from './providers/mongodb.ts';
import { MinioProvider } from './providers/minio.ts'; import { MinioProvider } from './providers/minio.ts';
import { CaddyProvider } from './providers/caddy.ts'; import { CaddyProvider } from './providers/caddy.ts';
import { ClickHouseProvider } from './providers/clickhouse.ts'; import { ClickHouseProvider } from './providers/clickhouse.ts';
import { MariaDBProvider } from './providers/mariadb.ts';
import { RedisProvider } from './providers/redis.ts';
import { logger } from '../../logging.ts'; import { logger } from '../../logging.ts';
import { getErrorMessage } from '../../utils/error.ts'; import { getErrorMessage } from '../../utils/error.ts';
import { credentialEncryption } from '../encryption.ts'; import { credentialEncryption } from '../encryption.ts';
@@ -41,6 +43,8 @@ export class PlatformServicesManager {
this.registerProvider(new MinioProvider(this.oneboxRef)); this.registerProvider(new MinioProvider(this.oneboxRef));
this.registerProvider(new CaddyProvider(this.oneboxRef)); this.registerProvider(new CaddyProvider(this.oneboxRef));
this.registerProvider(new ClickHouseProvider(this.oneboxRef)); this.registerProvider(new ClickHouseProvider(this.oneboxRef));
this.registerProvider(new MariaDBProvider(this.oneboxRef));
this.registerProvider(new RedisProvider(this.oneboxRef));
logger.info(`Platform services manager initialized with ${this.providers.size} providers`); logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
} }
@@ -304,6 +308,60 @@ export class PlatformServicesManager {
logger.success(`ClickHouse provisioned for service '${service.name}'`); logger.success(`ClickHouse provisioned for service '${service.name}'`);
} }
// Provision Redis if requested
if (requirements.redis) {
logger.info(`Provisioning Redis for service '${service.name}'...`);
// Ensure Redis is running
const redisService = await this.ensureRunning('redis');
const provider = this.providers.get('redis')!;
// Provision cache resource
const result = await provider.provisionResource(service);
// Store resource record
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
this.oneboxRef.database.createPlatformResource({
platformServiceId: redisService.id!,
serviceId: service.id!,
resourceType: result.type,
resourceName: result.name,
credentialsEncrypted: encryptedCreds,
createdAt: Date.now(),
});
// Merge env vars
Object.assign(allEnvVars, result.envVars);
logger.success(`Redis provisioned for service '${service.name}'`);
}
// Provision MariaDB if requested
if (requirements.mariadb) {
logger.info(`Provisioning MariaDB for service '${service.name}'...`);
// Ensure MariaDB is running
const mariadbService = await this.ensureRunning('mariadb');
const provider = this.providers.get('mariadb')!;
// Provision database
const result = await provider.provisionResource(service);
// Store resource record
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
this.oneboxRef.database.createPlatformResource({
platformServiceId: mariadbService.id!,
serviceId: service.id!,
resourceType: result.type,
resourceName: result.name,
credentialsEncrypted: encryptedCreds,
createdAt: Date.now(),
});
// Merge env vars
Object.assign(allEnvVars, result.envVars);
logger.success(`MariaDB provisioned for service '${service.name}'`);
}
return allEnvVars; return allEnvVars;
} }

View File

@@ -194,12 +194,6 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); const containerName = this.getContainerName();
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
if (!hostPort) {
throw new Error('Could not get ClickHouse container host port');
}
// Generate resource names and credentials // Generate resource names and credentials
const dbName = this.generateResourceName(userService.name); const dbName = this.generateResourceName(userService.name);
const username = this.generateResourceName(userService.name); const username = this.generateResourceName(userService.name);
@@ -207,35 +201,16 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`); logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
// Connect to ClickHouse via localhost and the mapped host port // Use docker exec to provision inside the container (avoids host port mapping issues)
const baseUrl = `http://127.0.0.1:${hostPort}`; const queries = [
`CREATE DATABASE IF NOT EXISTS ${dbName}`,
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`,
`GRANT ALL ON ${dbName}.* TO ${username}`,
];
// Create database for (const query of queries) {
await this.executeQuery( await this.execClickHouseQuery(platformService.containerId, adminCreds, query);
baseUrl, }
adminCreds.username,
adminCreds.password,
`CREATE DATABASE IF NOT EXISTS ${dbName}`
);
logger.info(`Created ClickHouse database '${dbName}'`);
// Create user with access to this database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`
);
logger.info(`Created ClickHouse user '${username}'`);
// Grant permissions on the database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`GRANT ALL ON ${dbName}.* TO ${username}`
);
logger.info(`Granted permissions to user '${username}' on database '${dbName}'`);
logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`); logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`);
@@ -274,37 +249,11 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
if (!hostPort) {
throw new Error('Could not get ClickHouse container host port');
}
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`); logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
const baseUrl = `http://127.0.0.1:${hostPort}`;
try { try {
// Drop the user await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP USER IF EXISTS ${credentials.username}`);
try { await this.execClickHouseQuery(platformService.containerId, adminCreds, `DROP DATABASE IF EXISTS ${resource.resourceName}`);
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`DROP USER IF EXISTS ${credentials.username}`
);
logger.info(`Dropped ClickHouse user '${credentials.username}'`);
} catch (e) {
logger.warn(`Could not drop ClickHouse user: ${getErrorMessage(e)}`);
}
// Drop the database
await this.executeQuery(
baseUrl,
adminCreds.username,
adminCreds.password,
`DROP DATABASE IF EXISTS ${resource.resourceName}`
);
logger.success(`ClickHouse database '${resource.resourceName}' dropped`); logger.success(`ClickHouse database '${resource.resourceName}' dropped`);
} catch (e) { } catch (e) {
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`); logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
@@ -313,26 +262,27 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
} }
/** /**
* Execute a ClickHouse SQL query via HTTP interface * Execute a ClickHouse SQL query via docker exec inside the container
*/ */
private async executeQuery( private async execClickHouseQuery(
baseUrl: string, containerId: string,
username: string, adminCreds: { username: string; password: string },
password: string,
query: string query: string
): Promise<string> { ): Promise<string> {
const url = `${baseUrl}/?user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`; const result = await this.oneboxRef.docker.execInContainer(
containerId,
[
'clickhouse-client',
'--user', adminCreds.username,
'--password', adminCreds.password,
'--query', query,
]
);
const response = await fetch(url, { if (result.exitCode !== 0) {
method: 'POST', throw new Error(`ClickHouse query failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
body: query,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`ClickHouse query failed: ${errorText}`);
} }
return await response.text(); return result.stdout;
} }
} }

View File

@@ -0,0 +1,279 @@
/**
* MariaDB Platform Service Provider
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import { getErrorMessage } from '../../../utils/error.ts';
import { credentialEncryption } from '../../encryption.ts';
import type { Onebox } from '../../onebox.ts';
export class MariaDBProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'mariadb';
readonly displayName = 'MariaDB';
readonly resourceTypes: TPlatformResourceType[] = ['database'];
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'mariadb:11',
port: 3306,
volumes: ['/var/lib/onebox/mariadb:/var/lib/mysql'],
environment: {
MARIADB_ROOT_PASSWORD: '',
// Password will be generated and stored encrypted
},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [
{ envVar: 'MARIADB_HOST', credentialPath: 'host' },
{ envVar: 'MARIADB_PORT', credentialPath: 'port' },
{ envVar: 'MARIADB_DATABASE', credentialPath: 'database' },
{ envVar: 'MARIADB_USER', credentialPath: 'username' },
{ envVar: 'MARIADB_PASSWORD', credentialPath: 'password' },
{ envVar: 'MARIADB_URI', credentialPath: 'connectionString' },
];
}
async deployContainer(): Promise<string> {
const config = this.getDefaultConfig();
const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/mariadb';
logger.info(`Deploying MariaDB platform service as ${containerName}...`);
// Check if we have existing data and stored credentials
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
let adminCredentials: { username: string; password: string };
let dataExists = false;
// Check if data directory has existing MariaDB data
try {
const stat = await Deno.stat(`${dataDir}/ibdata1`);
dataExists = stat.isFile;
logger.info(`MariaDB data directory exists with ibdata1 file`);
} catch {
// ibdata1 file doesn't exist, this is a fresh install
dataExists = false;
}
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing MariaDB credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new MariaDB admin credentials');
adminCredentials = {
username: 'root',
password: credentialEncryption.generatePassword(32),
};
// If data exists but we don't have credentials, we need to wipe the data
if (dataExists) {
logger.warn('MariaDB data exists but no credentials in database - wiping data directory');
try {
await Deno.remove(dataDir, { recursive: true });
} catch (e) {
logger.error(`Failed to wipe MariaDB data directory: ${getErrorMessage(e)}`);
throw new Error('Cannot deploy MariaDB: data directory exists without credentials');
}
}
}
// Ensure data directory exists
try {
await Deno.mkdir(dataDir, { recursive: true });
} catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create MariaDB data directory: ${getErrorMessage(e)}`);
}
}
// Create container using Docker API
const envVars = [
`MARIADB_ROOT_PASSWORD=${adminCredentials.password}`,
];
// Use Docker to create the container
const containerId = await this.oneboxRef.docker.createPlatformContainer({
name: containerName,
image: config.image,
port: config.port,
env: envVars,
volumes: config.volumes,
network: this.getNetworkName(),
});
// Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId,
adminCredentialsEncrypted: encryptedCreds,
status: 'starting',
});
}
logger.success(`MariaDB container created: ${containerId}`);
return containerId;
}
async stopContainer(containerId: string): Promise<void> {
logger.info(`Stopping MariaDB container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('MariaDB container stopped');
}
async healthCheck(): Promise<boolean> {
try {
logger.info('MariaDB health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService) {
logger.info('MariaDB health check: platform service not found in database');
return false;
}
if (!platformService.adminCredentialsEncrypted) {
logger.info('MariaDB health check: no admin credentials stored');
return false;
}
if (!platformService.containerId) {
logger.info('MariaDB health check: no container ID in database record');
return false;
}
logger.info(`MariaDB health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Use docker exec to run health check inside the container
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['mariadb-admin', 'ping', '-u', 'root', `-p${adminCreds.password}`]
);
if (result.exitCode === 0) {
logger.info('MariaDB health check: success');
return true;
} else {
logger.info(`MariaDB health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
return false;
}
} catch (error) {
logger.info(`MariaDB health check exception: ${getErrorMessage(error)}`);
return false;
}
}
async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MariaDB platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Generate resource names and credentials
const dbName = this.generateResourceName(userService.name);
const username = this.generateResourceName(userService.name);
const password = credentialEncryption.generatePassword(32);
logger.info(`Provisioning MariaDB database '${dbName}' for service '${userService.name}'...`);
// Create database and user via mariadb inside the container
const sql = [
`CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`,
`CREATE USER IF NOT EXISTS '${username}'@'%' IDENTIFIED BY '${password.replace(/'/g, "\\'")}';`,
`GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${username}'@'%';`,
`FLUSH PRIVILEGES;`,
].join(' ');
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
[
'mariadb',
'-u', 'root',
`-p${adminCreds.password}`,
'-e', sql,
]
);
if (result.exitCode !== 0) {
throw new Error(`Failed to provision MariaDB database: exit code ${result.exitCode}, output: ${result.stdout.substring(0, 200)} ${result.stderr.substring(0, 200)}`);
}
logger.success(`MariaDB database '${dbName}' provisioned with user '${username}'`);
// Build the credentials and env vars
const credentials: Record<string, string> = {
host: containerName,
port: '3306',
database: dbName,
username,
password,
connectionString: `mysql://${username}:${password}@${containerName}:3306/${dbName}`,
};
// Map credentials to env vars
const envVars: Record<string, string> = {};
for (const mapping of this.getEnvVarMappings()) {
if (credentials[mapping.credentialPath]) {
envVars[mapping.envVar] = credentials[mapping.credentialPath];
}
}
return {
type: 'database',
name: dbName,
credentials,
envVars,
};
}
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('MariaDB platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
logger.info(`Deprovisioning MariaDB database '${resource.resourceName}'...`);
const sql = [
`DROP USER IF EXISTS '${credentials.username}'@'%';`,
`DROP DATABASE IF EXISTS \`${resource.resourceName}\`;`,
].join(' ');
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
[
'mariadb',
'-u', 'root',
`-p${adminCreds.password}`,
'-e', sql,
]
);
if (result.exitCode !== 0) {
logger.warn(`MariaDB deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
}
logger.success(`MariaDB database '${resource.resourceName}' dropped`);
}
}

View File

@@ -196,84 +196,28 @@ export class MinioProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); const containerName = this.getContainerName();
// Get container host port for connection from host (overlay network IPs not accessible from host) // Generate bucket name
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
// Generate bucket name and credentials
const bucketName = this.generateBucketName(userService.name); const bucketName = this.generateBucketName(userService.name);
const accessKey = credentialEncryption.generateAccessKey(20);
const secretKey = credentialEncryption.generateSecretKey(40);
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`); logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
// Connect to MinIO via localhost and the mapped host port (for provisioning from host) // Use docker exec with mc (MinIO Client) inside the container
const provisioningEndpoint = `http://127.0.0.1:${hostPort}`; // First configure mc alias for local server
await this.execMc(platformService.containerId, [
// Import AWS S3 client 'alias', 'set', 'local', 'http://localhost:9000',
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3'); adminCreds.username, adminCreds.password,
]);
// Create S3 client with admin credentials - connect via host port
const s3Client = new S3Client({
endpoint: provisioningEndpoint,
region: 'us-east-1',
credentials: {
accessKeyId: adminCreds.username,
secretAccessKey: adminCreds.password,
},
forcePathStyle: true,
});
// Create the bucket // Create the bucket
try { const mbResult = await this.execMc(platformService.containerId, [
await s3Client.send(new CreateBucketCommand({ 'mb', '--ignore-existing', `local/${bucketName}`,
Bucket: bucketName, ]);
})); logger.info(`Created MinIO bucket '${bucketName}'`);
logger.info(`Created MinIO bucket '${bucketName}'`);
} catch (e: any) {
if (e.name !== 'BucketAlreadyOwnedByYou' && e.name !== 'BucketAlreadyExists') {
throw e;
}
logger.warn(`Bucket '${bucketName}' already exists`);
}
// Create service account/access key using MinIO Admin API // Set bucket policy to allow public read/write (services on the same network use root creds)
// MinIO Admin API requires mc client or direct API calls await this.execMc(platformService.containerId, [
// For simplicity, we'll use root credentials and bucket policy isolation 'anonymous', 'set', 'none', `local/${bucketName}`,
// In production, you'd use MinIO's Admin API to create service accounts ]);
// Set bucket policy to allow access only with this bucket's credentials
const bucketPolicy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
Resource: [
`arn:aws:s3:::${bucketName}`,
`arn:aws:s3:::${bucketName}/*`,
],
},
],
};
try {
await s3Client.send(new PutBucketPolicyCommand({
Bucket: bucketName,
Policy: JSON.stringify(bucketPolicy),
}));
logger.info(`Set bucket policy for '${bucketName}'`);
} catch (e) {
logger.warn(`Could not set bucket policy: ${getErrorMessage(e)}`);
}
// Note: For proper per-service credentials, MinIO Admin API should be used
// For now, we're providing the bucket with root access
// TODO: Implement MinIO service account creation
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
// Use container name for the endpoint in credentials (user services run in same network) // Use container name for the endpoint in credentials (user services run in same network)
const serviceEndpoint = `http://${containerName}:9000`; const serviceEndpoint = `http://${containerName}:9000`;
@@ -281,7 +225,7 @@ export class MinioProvider extends BasePlatformServiceProvider {
const credentials: Record<string, string> = { const credentials: Record<string, string> = {
endpoint: serviceEndpoint, endpoint: serviceEndpoint,
bucket: bucketName, bucket: bucketName,
accessKey: adminCreds.username, // Using root for now accessKey: adminCreds.username,
secretKey: adminCreds.password, secretKey: adminCreds.password,
region: 'us-east-1', region: 'us-east-1',
}; };
@@ -312,57 +256,37 @@ export class MinioProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 9000);
if (!hostPort) {
throw new Error('Could not get MinIO container host port');
}
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`); logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3'); // Configure mc alias
await this.execMc(platformService.containerId, [
const s3Client = new S3Client({ 'alias', 'set', 'local', 'http://localhost:9000',
endpoint: `http://127.0.0.1:${hostPort}`, adminCreds.username, adminCreds.password,
region: 'us-east-1', ]);
credentials: {
accessKeyId: adminCreds.username,
secretAccessKey: adminCreds.password,
},
forcePathStyle: true,
});
try { try {
// First, delete all objects in the bucket // Remove all objects and the bucket
let continuationToken: string | undefined; await this.execMc(platformService.containerId, [
do { 'rb', '--force', `local/${resource.resourceName}`,
const listResponse = await s3Client.send(new ListObjectsV2Command({ ]);
Bucket: resource.resourceName,
ContinuationToken: continuationToken,
}));
if (listResponse.Contents && listResponse.Contents.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: resource.resourceName,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key! })),
},
}));
logger.info(`Deleted ${listResponse.Contents.length} objects from bucket`);
}
continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined;
} while (continuationToken);
// Now delete the bucket
await s3Client.send(new DeleteBucketCommand({
Bucket: resource.resourceName,
}));
logger.success(`MinIO bucket '${resource.resourceName}' deleted`); logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
} catch (e) { } catch (e) {
logger.error(`Failed to delete MinIO bucket: ${getErrorMessage(e)}`); logger.error(`Failed to delete MinIO bucket: ${getErrorMessage(e)}`);
throw e; throw e;
} }
} }
/**
* Execute mc (MinIO Client) command inside the container
*/
private async execMc(
containerId: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> {
const result = await this.oneboxRef.docker.execInContainer(containerId, ['mc', ...args]);
if (result.exitCode !== 0) {
throw new Error(`mc command failed (exit ${result.exitCode}): ${result.stderr.substring(0, 200)}`);
}
return result;
}
} }

View File

@@ -28,7 +28,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
getDefaultConfig(): IPlatformServiceConfig { getDefaultConfig(): IPlatformServiceConfig {
return { return {
image: 'mongo:7', image: 'mongo:4.4',
port: 27017, port: 27017,
volumes: ['/var/lib/onebox/mongodb:/data/db'], volumes: ['/var/lib/onebox/mongodb:/data/db'],
environment: { environment: {
@@ -165,7 +165,7 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
// This avoids network issues with overlay networks // This avoids network issues with overlay networks
const result = await this.oneboxRef.docker.execInContainer( const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId, platformService.containerId,
['mongosh', '--eval', 'db.adminCommand("ping")', '--username', adminCreds.username, '--password', adminCreds.password, '--authenticationDatabase', 'admin', '--quiet'] ['mongo', '--eval', 'db.adminCommand("ping")', '--username', adminCreds.username, '--password', adminCreds.password, '--authenticationDatabase', 'admin', '--quiet']
); );
if (result.exitCode === 0) { if (result.exitCode === 0) {
@@ -190,12 +190,6 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName(); const containerName = this.getContainerName();
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017);
if (!hostPort) {
throw new Error('Could not get MongoDB container host port');
}
// Generate resource names and credentials // Generate resource names and credentials
const dbName = this.generateResourceName(userService.name); const dbName = this.generateResourceName(userService.name);
const username = this.generateResourceName(userService.name); const username = this.generateResourceName(userService.name);
@@ -203,32 +197,40 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`); logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`);
// Connect to MongoDB via localhost and the mapped host port // Use docker exec to provision inside the container (avoids host port mapping issues)
const { MongoClient } = await import('npm:mongodb@6'); const escapedPassword = password.replace(/'/g, "'\\''");
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`; const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
const client = new MongoClient(adminUri); // Create database and user via mongo inside the container
await client.connect(); const mongoScript = `
db = db.getSiblingDB('${dbName}');
try { db.createCollection('_onebox_init');
// Create the database by switching to it (MongoDB creates on first write) db.createUser({
const db = client.db(dbName); user: '${username}',
pwd: '${escapedPassword}',
// Create a collection to ensure the database exists roles: [{ role: 'readWrite', db: '${dbName}' }]
await db.createCollection('_onebox_init');
// Create user with readWrite access to this database
await db.command({
createUser: username,
pwd: password,
roles: [{ role: 'readWrite', db: dbName }],
}); });
print('PROVISION_SUCCESS');
`;
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`); const result = await this.oneboxRef.docker.execInContainer(
} finally { platformService.containerId,
await client.close(); [
'mongo',
'--username', adminCreds.username,
'--password', escapedAdminPassword,
'--authenticationDatabase', 'admin',
'--quiet',
'--eval', mongoScript,
]
);
if (result.exitCode !== 0 || !result.stdout.includes('PROVISION_SUCCESS')) {
throw new Error(`Failed to provision MongoDB database: exit code ${result.exitCode}, output: ${result.stdout.substring(0, 200)} ${result.stderr.substring(0, 200)}`);
} }
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
// Build the credentials and env vars // Build the credentials and env vars
const credentials: Record<string, string> = { const credentials: Record<string, string> = {
host: containerName, host: containerName,
@@ -262,37 +264,33 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
} }
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const escapedAdminPassword = adminCreds.password.replace(/'/g, "'\\''");
// Get container host port for connection from host (overlay network IPs not accessible from host)
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 27017);
if (!hostPort) {
throw new Error('Could not get MongoDB container host port');
}
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`); logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
const { MongoClient } = await import('npm:mongodb@6'); const mongoScript = `
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@127.0.0.1:${hostPort}/?authSource=admin`; db = db.getSiblingDB('${resource.resourceName}');
try { db.dropUser('${credentials.username}'); } catch(e) { print('User drop failed: ' + e); }
db.dropDatabase();
print('DEPROVISION_SUCCESS');
`;
const client = new MongoClient(adminUri); const result = await this.oneboxRef.docker.execInContainer(
await client.connect(); platformService.containerId,
[
'mongo',
'--username', adminCreds.username,
'--password', escapedAdminPassword,
'--authenticationDatabase', 'admin',
'--quiet',
'--eval', mongoScript,
]
);
try { if (result.exitCode !== 0) {
const db = client.db(resource.resourceName); logger.warn(`MongoDB deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
// Drop the user
try {
await db.command({ dropUser: credentials.username });
logger.info(`Dropped MongoDB user '${credentials.username}'`);
} catch (e) {
logger.warn(`Could not drop MongoDB user: ${getErrorMessage(e)}`);
}
// Drop the database
await db.dropDatabase();
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
} finally {
await client.close();
} }
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
} }
} }

View File

@@ -0,0 +1,283 @@
/**
* Redis Platform Service Provider
*/
import { BasePlatformServiceProvider } from './base.ts';
import type {
IService,
IPlatformResource,
IPlatformServiceConfig,
IProvisionedResource,
IEnvVarMapping,
TPlatformServiceType,
TPlatformResourceType,
} from '../../../types.ts';
import { logger } from '../../../logging.ts';
import { getErrorMessage } from '../../../utils/error.ts';
import { credentialEncryption } from '../../encryption.ts';
import type { Onebox } from '../../onebox.ts';
export class RedisProvider extends BasePlatformServiceProvider {
readonly type: TPlatformServiceType = 'redis';
readonly displayName = 'Redis';
readonly resourceTypes: TPlatformResourceType[] = ['cache'];
constructor(oneboxRef: Onebox) {
super(oneboxRef);
}
getDefaultConfig(): IPlatformServiceConfig {
return {
image: 'redis:7-alpine',
port: 6379,
volumes: ['/var/lib/onebox/redis:/data'],
environment: {},
};
}
getEnvVarMappings(): IEnvVarMapping[] {
return [
{ envVar: 'REDIS_HOST', credentialPath: 'host' },
{ envVar: 'REDIS_PORT', credentialPath: 'port' },
{ envVar: 'REDIS_PASSWORD', credentialPath: 'password' },
{ envVar: 'REDIS_DB', credentialPath: 'db' },
{ envVar: 'REDIS_URL', credentialPath: 'connectionString' },
];
}
async deployContainer(): Promise<string> {
const config = this.getDefaultConfig();
const containerName = this.getContainerName();
const dataDir = '/var/lib/onebox/redis';
logger.info(`Deploying Redis platform service as ${containerName}...`);
// Check if we have existing data and stored credentials
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
let adminCredentials: { username: string; password: string };
let dataExists = false;
// Check if data directory has existing Redis data
try {
const stat = await Deno.stat(`${dataDir}/dump.rdb`);
dataExists = stat.isFile;
logger.info(`Redis data directory exists with dump.rdb file`);
} catch {
// Also check for appendonly file
try {
const stat = await Deno.stat(`${dataDir}/appendonly.aof`);
dataExists = stat.isFile;
logger.info(`Redis data directory exists with appendonly.aof file`);
} catch {
dataExists = false;
}
}
if (dataExists && platformService?.adminCredentialsEncrypted) {
// Reuse existing credentials from database
logger.info('Reusing existing Redis credentials (data directory already initialized)');
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
} else {
// Generate new credentials for fresh deployment
logger.info('Generating new Redis admin credentials');
adminCredentials = {
username: 'default',
password: credentialEncryption.generatePassword(32),
};
// If data exists but we don't have credentials, we need to wipe the data
if (dataExists) {
logger.warn('Redis data exists but no credentials in database - wiping data directory');
try {
await Deno.remove(dataDir, { recursive: true });
} catch (e) {
logger.error(`Failed to wipe Redis data directory: ${getErrorMessage(e)}`);
throw new Error('Cannot deploy Redis: data directory exists without credentials');
}
}
}
// Ensure data directory exists
try {
await Deno.mkdir(dataDir, { recursive: true });
} catch (e) {
// Directory might already exist
if (!(e instanceof Deno.errors.AlreadyExists)) {
logger.warn(`Could not create Redis data directory: ${getErrorMessage(e)}`);
}
}
// Redis uses command args for password, not env vars
const containerId = await this.oneboxRef.docker.createPlatformContainer({
name: containerName,
image: config.image,
port: config.port,
env: [],
volumes: config.volumes,
network: this.getNetworkName(),
command: ['redis-server', '--requirepass', adminCredentials.password, '--appendonly', 'yes'],
});
// Store encrypted admin credentials (only update if new or changed)
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
if (platformService) {
this.oneboxRef.database.updatePlatformService(platformService.id!, {
containerId,
adminCredentialsEncrypted: encryptedCreds,
status: 'starting',
});
}
logger.success(`Redis container created: ${containerId}`);
return containerId;
}
async stopContainer(containerId: string): Promise<void> {
logger.info(`Stopping Redis container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('Redis container stopped');
}
async healthCheck(): Promise<boolean> {
try {
logger.info('Redis health check: starting...');
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService) {
logger.info('Redis health check: platform service not found in database');
return false;
}
if (!platformService.adminCredentialsEncrypted) {
logger.info('Redis health check: no admin credentials stored');
return false;
}
if (!platformService.containerId) {
logger.info('Redis health check: no container ID in database record');
return false;
}
logger.info(`Redis health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
// Use docker exec to run health check inside the container
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['redis-cli', '-a', adminCreds.password, 'ping']
);
if (result.exitCode === 0 && result.stdout.includes('PONG')) {
logger.info('Redis health check: success');
return true;
} else {
logger.info(`Redis health check failed: exit code ${result.exitCode}, stdout: ${result.stdout.substring(0, 200)}`);
return false;
}
} catch (error) {
logger.info(`Redis health check exception: ${getErrorMessage(error)}`);
return false;
}
}
async provisionResource(userService: IService): Promise<IProvisionedResource> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted) {
throw new Error('Redis platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const containerName = this.getContainerName();
// Determine the next available DB index (1-15, reserving 0 for admin)
const existingResources = this.oneboxRef.database.getPlatformResourcesByPlatformService(platformService.id!);
const usedIndexes = new Set<number>();
for (const resource of existingResources) {
try {
const creds = await credentialEncryption.decrypt(resource.credentialsEncrypted);
if (creds.db) {
usedIndexes.add(parseInt(creds.db, 10));
}
} catch {
// Skip resources with corrupt credentials
}
}
let dbIndex = -1;
for (let i = 1; i <= 15; i++) {
if (!usedIndexes.has(i)) {
dbIndex = i;
break;
}
}
if (dbIndex === -1) {
throw new Error('No available Redis database indexes (max 15 services per Redis instance)');
}
const resourceName = this.generateResourceName(userService.name);
logger.info(`Provisioning Redis database index ${dbIndex} for service '${userService.name}'...`);
// No server-side creation needed - Redis DB indexes exist implicitly
// Just verify connectivity
if (platformService.containerId) {
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['redis-cli', '-a', adminCreds.password, '-n', String(dbIndex), 'ping']
);
if (result.exitCode !== 0 || !result.stdout.includes('PONG')) {
throw new Error(`Failed to verify Redis database ${dbIndex}: exit code ${result.exitCode}`);
}
}
logger.success(`Redis database index ${dbIndex} provisioned for service '${userService.name}'`);
// Build the credentials and env vars
const credentials: Record<string, string> = {
host: containerName,
port: '6379',
password: adminCreds.password,
db: String(dbIndex),
connectionString: `redis://:${adminCreds.password}@${containerName}:6379/${dbIndex}`,
};
// Map credentials to env vars
const envVars: Record<string, string> = {};
for (const mapping of this.getEnvVarMappings()) {
if (credentials[mapping.credentialPath]) {
envVars[mapping.envVar] = credentials[mapping.credentialPath];
}
}
return {
type: 'cache',
name: resourceName,
credentials,
envVars,
};
}
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
throw new Error('Redis platform service not found or not configured');
}
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
const dbIndex = credentials.db || '0';
logger.info(`Deprovisioning Redis database index ${dbIndex} for resource '${resource.resourceName}'...`);
// Flush the specific database
const result = await this.oneboxRef.docker.execInContainer(
platformService.containerId,
['redis-cli', '-a', adminCreds.password, '-n', dbIndex, 'FLUSHDB']
);
if (result.exitCode !== 0) {
logger.warn(`Redis deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
}
logger.success(`Redis database index ${dbIndex} flushed for resource '${resource.resourceName}'`);
}
}

View File

@@ -2,7 +2,7 @@
* Onebox Registry Manager * Onebox Registry Manager
* *
* Manages the local Docker registry using: * Manages the local Docker registry using:
* - @push.rocks/smarts3 (S3-compatible server with filesystem storage) * - @push.rocks/smartstorage (S3-compatible server with filesystem storage)
* - @push.rocks/smartregistry (OCI-compliant Docker registry) * - @push.rocks/smartregistry (OCI-compliant Docker registry)
*/ */
@@ -27,7 +27,7 @@ export class RegistryManager {
} }
/** /**
* Initialize the registry (start smarts3 and smartregistry) * Initialize the registry (start smartstorage and smartregistry)
*/ */
async init(): Promise<void> { async init(): Promise<void> {
if (this.isInitialized) { if (this.isInitialized) {
@@ -39,10 +39,10 @@ export class RegistryManager {
const dataDir = this.options.dataDir || './.nogit/registry-data'; const dataDir = this.options.dataDir || './.nogit/registry-data';
const port = this.options.port || 4000; const port = this.options.port || 4000;
logger.info(`Starting smarts3 server on port ${port}...`); logger.info(`Starting smartstorage server on port ${port}...`);
// 1. Start smarts3 server (S3-compatible storage with filesystem backend) // 1. Start smartstorage server (S3-compatible storage with filesystem backend)
this.s3Server = await plugins.smarts3.Smarts3.createAndStart({ this.s3Server = await plugins.smartstorage.SmartStorage.createAndStart({
server: { server: {
port: port, port: port,
address: '0.0.0.0', address: '0.0.0.0',
@@ -53,16 +53,16 @@ export class RegistryManager {
}, },
}); });
logger.success(`smarts3 server started on port ${port}`); logger.success(`smartstorage server started on port ${port}`);
// 2. Configure smartregistry to use smarts3 // 2. Configure smartregistry to use smartstorage
logger.info('Initializing smartregistry...'); logger.info('Initializing smartregistry...');
this.registry = new plugins.smartregistry.SmartRegistry({ this.registry = new plugins.smartregistry.SmartRegistry({
storage: { storage: {
endpoint: 'localhost', endpoint: 'localhost',
port: port, port: port,
accessKey: 'onebox', // smarts3 doesn't validate credentials accessKey: 'onebox', // smartstorage doesn't validate credentials
accessSecret: 'onebox', accessSecret: 'onebox',
useSsl: false, useSsl: false,
region: 'us-east-1', region: 'us-east-1',
@@ -314,15 +314,15 @@ export class RegistryManager {
} }
/** /**
* Stop the registry and smarts3 server * Stop the registry and smartstorage server
*/ */
async stop(): Promise<void> { async stop(): Promise<void> {
if (this.s3Server) { if (this.s3Server) {
try { try {
await this.s3Server.stop(); await this.s3Server.stop();
logger.info('smarts3 server stopped'); logger.info('smartstorage server stopped');
} catch (error) { } catch (error) {
logger.error(`Error stopping smarts3: ${getErrorMessage(error)}`); logger.error(`Error stopping smartstorage: ${getErrorMessage(error)}`);
} }
} }

View File

@@ -15,6 +15,7 @@ export class OneboxServicesManager {
private oneboxRef: any; // Will be Onebox instance private oneboxRef: any; // Will be Onebox instance
private database: OneboxDatabase; private database: OneboxDatabase;
private docker: OneboxDockerManager; private docker: OneboxDockerManager;
private autoUpdateIntervalId: number | null = null;
constructor(oneboxRef: any) { constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef; this.oneboxRef = oneboxRef;
@@ -49,11 +50,13 @@ export class OneboxServicesManager {
// Build platform requirements // Build platform requirements
const platformRequirements: IPlatformRequirements | undefined = const platformRequirements: IPlatformRequirements | undefined =
(options.enableMongoDB || options.enableS3 || options.enableClickHouse) (options.enableMongoDB || options.enableS3 || options.enableClickHouse || options.enableRedis || options.enableMariaDB)
? { ? {
mongodb: options.enableMongoDB, mongodb: options.enableMongoDB,
s3: options.enableS3, s3: options.enableS3,
clickhouse: options.enableClickHouse, clickhouse: options.enableClickHouse,
redis: options.enableRedis,
mariadb: options.enableMariaDB,
} }
: undefined; : undefined;
@@ -75,6 +78,9 @@ export class OneboxServicesManager {
autoUpdateOnPush: options.autoUpdateOnPush, autoUpdateOnPush: options.autoUpdateOnPush,
// Platform requirements // Platform requirements
platformRequirements, platformRequirements,
// App Store template tracking
appTemplateId: options.appTemplateId,
appTemplateVersion: options.appTemplateVersion,
}); });
// Provision platform resources if needed // Provision platform resources if needed
@@ -681,7 +687,7 @@ export class OneboxServicesManager {
*/ */
startAutoUpdateMonitoring(): void { startAutoUpdateMonitoring(): void {
// Check every 30 seconds // Check every 30 seconds
setInterval(async () => { this.autoUpdateIntervalId = setInterval(async () => {
try { try {
await this.checkForRegistryUpdates(); await this.checkForRegistryUpdates();
} catch (error) { } catch (error) {
@@ -692,6 +698,17 @@ export class OneboxServicesManager {
logger.info('Auto-update monitoring started (30s interval)'); logger.info('Auto-update monitoring started (30s interval)');
} }
/**
* Stop auto-update monitoring
*/
stopAutoUpdateMonitoring(): void {
if (this.autoUpdateIntervalId !== null) {
clearInterval(this.autoUpdateIntervalId);
this.autoUpdateIntervalId = null;
logger.debug('Auto-update monitoring stopped');
}
}
/** /**
* Check all services using onebox registry for updates * Check all services using onebox registry for updates
*/ */

243
ts/classes/systemd.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* Systemd Service Manager for Onebox
*
* Handles systemd unit file installation, enabling, starting, stopping,
* and status checking. Modeled on nupst's direct systemctl approach —
* no external library dependencies.
*/
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
const SERVICE_NAME = 'onebox';
const SERVICE_FILE_PATH = '/etc/systemd/system/onebox.service';
const SERVICE_UNIT_TEMPLATE = `[Unit]
Description=Onebox - Self-hosted container platform
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/local/bin/onebox systemd start-daemon
Restart=always
RestartSec=10
WorkingDirectory=/var/lib/onebox
Environment=PATH=/usr/bin:/usr/local/bin
Environment=HOME=/root
Environment=DENO_DIR=/root/.cache/deno
[Install]
WantedBy=multi-user.target
`;
export class OneboxSystemd {
/**
* Install and enable the systemd service
*/
async enable(): Promise<void> {
try {
// Ensure Docker is installed before writing unit file (it requires docker.service)
await this.ensureDocker();
// Write the unit file
logger.info('Writing systemd unit file...');
await Deno.writeTextFile(SERVICE_FILE_PATH, SERVICE_UNIT_TEMPLATE);
logger.info(`Unit file written to ${SERVICE_FILE_PATH}`);
// Reload systemd daemon
await this.runSystemctl(['daemon-reload']);
// Enable the service
const result = await this.runSystemctl(['enable', `${SERVICE_NAME}.service`]);
if (!result.success) {
throw new Error(`Failed to enable service: ${result.stderr}`);
}
logger.success('Onebox systemd service enabled');
logger.info('Start with: onebox systemd start');
} catch (error) {
logger.error(`Failed to enable service: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Stop, disable, and remove the systemd service
*/
async disable(): Promise<void> {
try {
// Stop the service (ignore errors if not running)
await this.runSystemctl(['stop', `${SERVICE_NAME}.service`]);
// Disable the service
await this.runSystemctl(['disable', `${SERVICE_NAME}.service`]);
// Remove the unit file
try {
await Deno.remove(SERVICE_FILE_PATH);
logger.info(`Removed ${SERVICE_FILE_PATH}`);
} catch {
// File might not exist
}
// Reload systemd daemon
await this.runSystemctl(['daemon-reload']);
logger.success('Onebox systemd service disabled and removed');
} catch (error) {
logger.error(`Failed to disable service: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Start the service via systemctl
*/
async start(): Promise<void> {
const result = await this.runSystemctl(['start', `${SERVICE_NAME}.service`]);
if (!result.success) {
logger.error(`Failed to start service: ${result.stderr}`);
throw new Error(`Failed to start onebox service`);
}
logger.success('Onebox service started');
}
/**
* Stop the service via systemctl
*/
async stop(): Promise<void> {
const result = await this.runSystemctl(['stop', `${SERVICE_NAME}.service`]);
if (!result.success) {
logger.error(`Failed to stop service: ${result.stderr}`);
throw new Error(`Failed to stop onebox service`);
}
logger.success('Onebox service stopped');
}
/**
* Get and display service status
*/
async getStatus(): Promise<string> {
const result = await this.runSystemctl(['status', `${SERVICE_NAME}.service`]);
const output = result.stdout;
let status: string;
if (output.includes('active (running)')) {
status = 'running';
} else if (output.includes('inactive') || output.includes('dead')) {
status = 'stopped';
} else if (output.includes('failed')) {
status = 'failed';
} else if (!result.success && result.stderr.includes('could not be found')) {
status = 'not-installed';
} else {
status = 'unknown';
}
// Print the raw systemctl output for full details
if (output.trim()) {
console.log(output);
}
return status;
}
/**
* Show service logs via journalctl
*/
async showLogs(): Promise<void> {
const cmd = new Deno.Command('journalctl', {
args: ['-u', `${SERVICE_NAME}.service`, '-f'],
stdout: 'inherit',
stderr: 'inherit',
});
await cmd.output();
}
/**
* Check if the service unit file is installed
*/
async isInstalled(): Promise<boolean> {
try {
await Deno.stat(SERVICE_FILE_PATH);
return true;
} catch {
return false;
}
}
/**
* Ensure Docker is installed, installing it if necessary
*/
private async ensureDocker(): Promise<void> {
try {
const cmd = new Deno.Command('docker', {
args: ['--version'],
stdout: 'piped',
stderr: 'piped',
});
const result = await cmd.output();
if (result.success) {
const version = new TextDecoder().decode(result.stdout).trim();
logger.info(`Docker found: ${version}`);
return;
}
} catch {
// docker command not found
}
logger.info('Docker not found. Installing Docker...');
const installCmd = new Deno.Command('bash', {
args: ['-c', 'curl -fsSL https://get.docker.com | sh'],
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
});
const installResult = await installCmd.output();
if (!installResult.success) {
throw new Error('Failed to install Docker. Please install it manually: curl -fsSL https://get.docker.com | sh');
}
logger.success('Docker installed successfully');
// Initialize Docker Swarm
logger.info('Initializing Docker Swarm...');
const swarmCmd = new Deno.Command('docker', {
args: ['swarm', 'init'],
stdout: 'piped',
stderr: 'piped',
});
const swarmResult = await swarmCmd.output();
if (swarmResult.success) {
logger.success('Docker Swarm initialized');
} else {
const stderr = new TextDecoder().decode(swarmResult.stderr);
if (stderr.includes('already part of a swarm')) {
logger.info('Docker Swarm already initialized');
} else {
logger.warn(`Docker Swarm init warning: ${stderr.trim()}`);
}
}
}
/**
* Run a systemctl command and return results
*/
private async runSystemctl(
args: string[]
): Promise<{ success: boolean; stdout: string; stderr: string }> {
const cmd = new Deno.Command('systemctl', {
args,
stdout: 'piped',
stderr: 'piped',
});
const result = await cmd.output();
return {
success: result.success,
stdout: new TextDecoder().decode(result.stdout),
stderr: new TextDecoder().decode(result.stderr),
};
}
}

View File

@@ -7,6 +7,7 @@ import { projectInfo } from './info.ts';
import { getErrorMessage } from './utils/error.ts'; import { getErrorMessage } from './utils/error.ts';
import { Onebox } from './classes/onebox.ts'; import { Onebox } from './classes/onebox.ts';
import { OneboxDaemon } from './classes/daemon.ts'; import { OneboxDaemon } from './classes/daemon.ts';
import { OneboxSystemd } from './classes/systemd.ts';
export async function runCli(): Promise<void> { export async function runCli(): Promise<void> {
const args = Deno.args; const args = Deno.args;
@@ -25,6 +26,19 @@ export async function runCli(): Promise<void> {
const subcommand = args[1]; const subcommand = args[1];
try { try {
// === LIGHTWEIGHT COMMANDS (no init()) ===
if (command === 'systemd') {
await handleSystemdCommand(subcommand, args.slice(2));
return;
}
if (command === 'upgrade') {
await handleUpgradeCommand();
return;
}
// === HEAVY COMMANDS (require full init()) ===
// Server command has special handling (doesn't shut down) // Server command has special handling (doesn't shut down)
if (command === 'server') { if (command === 'server') {
const onebox = new Onebox(); const onebox = new Onebox();
@@ -60,10 +74,6 @@ export async function runCli(): Promise<void> {
await handleNginxCommand(onebox, subcommand, args.slice(2)); await handleNginxCommand(onebox, subcommand, args.slice(2));
break; break;
case 'daemon':
await handleDaemonCommand(onebox, subcommand, args.slice(2));
break;
case 'config': case 'config':
await handleConfigCommand(onebox, subcommand, args.slice(2)); await handleConfigCommand(onebox, subcommand, args.slice(2));
break; break;
@@ -72,10 +82,6 @@ export async function runCli(): Promise<void> {
await handleStatusCommand(onebox); await handleStatusCommand(onebox);
break; break;
case 'upgrade':
await handleUpgradeCommand();
break;
default: default:
logger.error(`Unknown command: ${command}`); logger.error(`Unknown command: ${command}`);
printHelp(); printHelp();
@@ -282,7 +288,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
await OneboxDaemon.ensureNoDaemon(); await OneboxDaemon.ensureNoDaemon();
} catch (error) { } catch (error) {
logger.error('Cannot start in ephemeral mode: Daemon is already running'); logger.error('Cannot start in ephemeral mode: Daemon is already running');
logger.info('Stop the daemon first: onebox daemon stop'); logger.info('Stop the daemon first: onebox systemd stop');
logger.info('Or run without --ephemeral to use the existing daemon'); logger.info('Or run without --ephemeral to use the existing daemon');
Deno.exit(1); Deno.exit(1);
} }
@@ -326,39 +332,49 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
} }
} }
// Daemon commands // Systemd service commands (lightweight — no Onebox init)
async function handleDaemonCommand(onebox: Onebox, subcommand: string, _args: string[]) { async function handleSystemdCommand(subcommand: string, _args: string[]) {
const systemd = new OneboxSystemd();
switch (subcommand) { switch (subcommand) {
case 'install': case 'enable':
await onebox.daemon.installService(); await systemd.enable();
break;
case 'disable':
await systemd.disable();
break; break;
case 'start': case 'start':
await onebox.startDaemon(); await systemd.start();
break; break;
case 'stop': case 'stop':
await onebox.stopDaemon(); await systemd.stop();
break; break;
case 'logs': { case 'status': {
const command = new Deno.Command('journalctl', { const status = await systemd.getStatus();
args: ['-u', 'smartdaemon_onebox', '-f'], logger.info(`Service status: ${status}`);
stdout: 'inherit',
stderr: 'inherit',
});
await command.output();
break; break;
} }
case 'status': { case 'logs':
const status = await onebox.daemon.getServiceStatus(); await systemd.showLogs();
logger.info(`Daemon status: ${status}`); break;
case 'start-daemon': {
// This is what systemd's ExecStart calls — full init + daemon loop
const onebox = new Onebox();
await onebox.init();
await onebox.daemon.start();
// start() blocks (keepAlive loop) until SIGTERM/SIGINT
break; break;
} }
default: default:
logger.error(`Unknown daemon subcommand: ${subcommand}`); logger.error(`Unknown systemd subcommand: ${subcommand}`);
logger.info('Available: enable, disable, start, stop, status, logs');
} }
} }
@@ -506,11 +522,12 @@ Commands:
nginx test nginx test
nginx status nginx status
daemon install systemd enable Install and enable systemd service
daemon start systemd disable Stop, disable, and remove systemd service
daemon stop systemd start Start onebox via systemctl
daemon logs systemd stop Stop onebox via systemctl
daemon status systemd status Show systemd service status
systemd logs Follow service logs (journalctl)
config show config show
config set <key> <value> config set <key> <value>
@@ -530,15 +547,15 @@ Development Workflow:
onebox service add ... # In another terminal onebox service add ... # In another terminal
Production Workflow: Production Workflow:
onebox daemon install # Install systemd service onebox systemd enable # Install and enable systemd service
onebox daemon start # Start daemon onebox systemd start # Start via systemctl
onebox service add ... # CLI uses daemon onebox service add ... # CLI manages services
Examples: Examples:
onebox server --ephemeral # Start dev server onebox server --ephemeral # Start dev server
onebox service add myapp --image nginx:latest --domain app.example.com --port 80 onebox service add myapp --image nginx:latest --domain app.example.com --port 80
onebox registry add --url registry.example.com --username user --password pass onebox registry add --url registry.example.com --username user --password pass
onebox daemon install onebox systemd enable
onebox daemon start onebox systemd start
`); `);
} }

View File

@@ -607,6 +607,10 @@ export class OneboxDatabase {
return this.backupRepo.getBySchedule(scheduleId); return this.backupRepo.getBySchedule(scheduleId);
} }
getBackupBySnapshotId(snapshotId: string): IBackup | null {
return this.backupRepo.getBySnapshotId(snapshotId);
}
// ============ Backup Schedules (delegated to repository) ============ // ============ Backup Schedules (delegated to repository) ============
createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule { createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {

View File

@@ -0,0 +1,12 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration013AppTemplateVersion extends BaseMigration {
readonly version = 13;
readonly description = 'Add app template tracking columns to services';
up(query: TQueryFunction): void {
query('ALTER TABLE services ADD COLUMN app_template_id TEXT');
query('ALTER TABLE services ADD COLUMN app_template_version TEXT');
}
}

View File

@@ -0,0 +1,13 @@
import { BaseMigration } from './base-migration.ts';
import type { TQueryFunction } from '../types.ts';
export class Migration014ContainerArchive extends BaseMigration {
readonly version = 14;
readonly description = 'Add containerarchive snapshot tracking to backups';
up(query: TQueryFunction): void {
query('ALTER TABLE backups ADD COLUMN snapshot_id TEXT');
query('ALTER TABLE backups ADD COLUMN stored_size_bytes INTEGER DEFAULT 0');
query('CREATE INDEX IF NOT EXISTS idx_backups_snapshot ON backups(snapshot_id)');
}
}

View File

@@ -19,6 +19,8 @@ import { Migration009BackupSystem } from './migration-009-backup-system.ts';
import { Migration010BackupSchedules } from './migration-010-backup-schedules.ts'; import { Migration010BackupSchedules } from './migration-010-backup-schedules.ts';
import { Migration011ScopeColumns } from './migration-011-scope-columns.ts'; import { Migration011ScopeColumns } from './migration-011-scope-columns.ts';
import { Migration012GfsRetention } from './migration-012-gfs-retention.ts'; import { Migration012GfsRetention } from './migration-012-gfs-retention.ts';
import { Migration013AppTemplateVersion } from './migration-013-app-template-version.ts';
import { Migration014ContainerArchive } from './migration-014-containerarchive.ts';
import type { BaseMigration } from './base-migration.ts'; import type { BaseMigration } from './base-migration.ts';
export class MigrationRunner { export class MigrationRunner {
@@ -42,6 +44,8 @@ export class MigrationRunner {
new Migration010BackupSchedules(), new Migration010BackupSchedules(),
new Migration011ScopeColumns(), new Migration011ScopeColumns(),
new Migration012GfsRetention(), new Migration012GfsRetention(),
new Migration013AppTemplateVersion(),
new Migration014ContainerArchive(),
].sort((a, b) => a.version - b.version); ].sort((a, b) => a.version - b.version);
} }

View File

@@ -20,8 +20,9 @@ export class BackupRepository extends BaseRepository {
this.query( this.query(
`INSERT INTO backups ( `INSERT INTO backups (
service_id, service_name, filename, size_bytes, created_at, service_id, service_name, filename, size_bytes, created_at,
includes_image, platform_resources, checksum, schedule_id includes_image, platform_resources, checksum, schedule_id,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, snapshot_id, stored_size_bytes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
backup.serviceId, backup.serviceId,
backup.serviceName, backup.serviceName,
@@ -32,6 +33,8 @@ export class BackupRepository extends BaseRepository {
JSON.stringify(backup.platformResources), JSON.stringify(backup.platformResources),
backup.checksum, backup.checksum,
backup.scheduleId ?? null, backup.scheduleId ?? null,
backup.snapshotId ?? null,
backup.storedSizeBytes ?? 0,
] ]
); );
@@ -78,6 +81,14 @@ export class BackupRepository extends BaseRepository {
return rows.map((row) => this.rowToBackup(row)); return rows.map((row) => this.rowToBackup(row));
} }
getBySnapshotId(snapshotId: string): IBackup | null {
const rows = this.query(
'SELECT * FROM backups WHERE snapshot_id = ?',
[snapshotId]
);
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
}
private rowToBackup(row: any): IBackup { private rowToBackup(row: any): IBackup {
let platformResources: TPlatformServiceType[] = []; let platformResources: TPlatformServiceType[] = [];
const platformResourcesRaw = row.platform_resources; const platformResourcesRaw = row.platform_resources;
@@ -94,7 +105,9 @@ export class BackupRepository extends BaseRepository {
serviceId: Number(row.service_id), serviceId: Number(row.service_id),
serviceName: String(row.service_name), serviceName: String(row.service_name),
filename: String(row.filename), filename: String(row.filename),
snapshotId: row.snapshot_id ? String(row.snapshot_id) : undefined,
sizeBytes: Number(row.size_bytes), sizeBytes: Number(row.size_bytes),
storedSizeBytes: row.stored_size_bytes ? Number(row.stored_size_bytes) : undefined,
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
includesImage: Boolean(row.includes_image), includesImage: Boolean(row.includes_image),
platformResources, platformResources,

View File

@@ -17,8 +17,9 @@ export class ServiceRepository extends BaseRepository {
name, image, registry, env_vars, port, domain, container_id, status, name, image, registry, env_vars, port, domain, container_id, status,
created_at, updated_at, created_at, updated_at,
use_onebox_registry, registry_repository, registry_image_tag, use_onebox_registry, registry_repository, registry_image_tag,
auto_update_on_push, image_digest, platform_requirements auto_update_on_push, image_digest, platform_requirements,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, app_template_id, app_template_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
service.name, service.name,
service.image, service.image,
@@ -36,6 +37,8 @@ export class ServiceRepository extends BaseRepository {
service.autoUpdateOnPush ? 1 : 0, service.autoUpdateOnPush ? 1 : 0,
service.imageDigest || null, service.imageDigest || null,
JSON.stringify(service.platformRequirements || {}), JSON.stringify(service.platformRequirements || {}),
service.appTemplateId || null,
service.appTemplateVersion || null,
] ]
); );
@@ -123,6 +126,14 @@ export class ServiceRepository extends BaseRepository {
fields.push('include_image_in_backup = ?'); fields.push('include_image_in_backup = ?');
values.push(updates.includeImageInBackup ? 1 : 0); values.push(updates.includeImageInBackup ? 1 : 0);
} }
if (updates.appTemplateId !== undefined) {
fields.push('app_template_id = ?');
values.push(updates.appTemplateId);
}
if (updates.appTemplateVersion !== undefined) {
fields.push('app_template_version = ?');
values.push(updates.appTemplateVersion);
}
fields.push('updated_at = ?'); fields.push('updated_at = ?');
values.push(Date.now()); values.push(Date.now());
@@ -179,6 +190,8 @@ export class ServiceRepository extends BaseRepository {
includeImageInBackup: row.include_image_in_backup !== undefined includeImageInBackup: row.include_image_in_backup !== undefined
? Boolean(row.include_image_in_backup) ? Boolean(row.include_image_in_backup)
: true, // Default to true : true, // Default to true
appTemplateId: row.app_template_id ? String(row.app_template_id) : undefined,
appTemplateVersion: row.app_template_version ? String(row.app_template_version) : undefined,
}; };
} }
} }

View File

@@ -12,6 +12,7 @@ export { OneboxReverseProxy } from './classes/reverseproxy.ts';
export { OneboxDnsManager } from './classes/dns.ts'; export { OneboxDnsManager } from './classes/dns.ts';
export { OneboxSslManager } from './classes/ssl.ts'; export { OneboxSslManager } from './classes/ssl.ts';
export { OneboxDaemon } from './classes/daemon.ts'; export { OneboxDaemon } from './classes/daemon.ts';
export { OneboxSystemd } from './classes/systemd.ts';
export { OneboxHttpServer } from './classes/httpserver.ts'; export { OneboxHttpServer } from './classes/httpserver.ts';
export { OneboxApiClient } from './classes/apiclient.ts'; export { OneboxApiClient } from './classes/apiclient.ts';

View File

@@ -23,6 +23,8 @@ export class OpsServer {
public schedulesHandler!: handlers.SchedulesHandler; public schedulesHandler!: handlers.SchedulesHandler;
public settingsHandler!: handlers.SettingsHandler; public settingsHandler!: handlers.SettingsHandler;
public logsHandler!: handlers.LogsHandler; public logsHandler!: handlers.LogsHandler;
public workspaceHandler!: handlers.WorkspaceHandler;
public appStoreHandler!: handlers.AppStoreHandler;
constructor(oneboxRef: Onebox) { constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef; this.oneboxRef = oneboxRef;
@@ -63,6 +65,8 @@ export class OpsServer {
this.schedulesHandler = new handlers.SchedulesHandler(this); this.schedulesHandler = new handlers.SchedulesHandler(this);
this.settingsHandler = new handlers.SettingsHandler(this); this.settingsHandler = new handlers.SettingsHandler(this);
this.logsHandler = new handlers.LogsHandler(this); this.logsHandler = new handlers.LogsHandler(this);
this.workspaceHandler = new handlers.WorkspaceHandler(this);
this.appStoreHandler = new handlers.AppStoreHandler(this);
logger.success('OpsServer TypedRequest handlers initialized'); logger.success('OpsServer TypedRequest handlers initialized');
} }

View File

@@ -0,0 +1,104 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class AppStoreHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get app templates (catalog)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppTemplates>(
'getAppTemplates',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const apps = await this.opsServerRef.oneboxRef.appStore.getApps();
return { apps };
},
),
);
// Get app config for a specific version
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAppConfig>(
'getAppConfig',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const config = await this.opsServerRef.oneboxRef.appStore.getAppVersionConfig(
dataArg.appId,
dataArg.version,
);
const appMeta = await this.opsServerRef.oneboxRef.appStore.getAppMeta(dataArg.appId);
return { config, appMeta };
},
),
);
// Get services with available upgrades
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetUpgradeableServices>(
'getUpgradeableServices',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const services = await this.opsServerRef.oneboxRef.appStore.getUpgradeableServices();
return { services };
},
),
);
// Upgrade a service to a new template version
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpgradeService>(
'upgradeService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!existingService) {
throw new plugins.typedrequest.TypedResponseError(`Service not found: ${dataArg.serviceName}`);
}
if (!existingService.appTemplateId) {
throw new plugins.typedrequest.TypedResponseError('Service was not deployed from an app template');
}
if (!existingService.appTemplateVersion) {
throw new plugins.typedrequest.TypedResponseError('Service has no tracked template version');
}
logger.info(`Upgrading service '${dataArg.serviceName}' from v${existingService.appTemplateVersion} to v${dataArg.targetVersion}`);
// Execute migration
const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration(
existingService,
existingService.appTemplateVersion,
dataArg.targetVersion,
);
if (!migrationResult.success) {
throw new plugins.typedrequest.TypedResponseError(
`Migration failed: ${migrationResult.warnings.join('; ')}`,
);
}
// Apply the upgrade
const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade(
dataArg.serviceName,
migrationResult,
dataArg.targetVersion,
);
return {
service: updatedService,
warnings: migrationResult.warnings,
};
},
),
);
}
}

View File

@@ -53,12 +53,8 @@ export class BackupsHandler {
'restoreBackup', 'restoreBackup',
async (dataArg) => { async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg); await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backupPath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
if (!backupPath) {
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
}
const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup( const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup(
backupPath, dataArg.backupId,
dataArg.options, dataArg.options,
); );
return { return {
@@ -84,14 +80,11 @@ export class BackupsHandler {
if (!backup) { if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found'); throw new plugins.typedrequest.TypedResponseError('Backup not found');
} }
const filePath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
if (!filePath) {
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
}
// Return a download URL that the client can fetch directly // Return a download URL that the client can fetch directly
const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`;
return { return {
downloadUrl: `/api/backups/${dataArg.backupId}/download`, downloadUrl: `/api/backups/${dataArg.backupId}/download`,
filename: backup.filename, filename,
}; };
}, },
), ),

View File

@@ -11,3 +11,5 @@ export * from './backups.handler.ts';
export * from './schedules.handler.ts'; export * from './schedules.handler.ts';
export * from './settings.handler.ts'; export * from './settings.handler.ts';
export * from './logs.handler.ts'; export * from './logs.handler.ts';
export * from './workspace.handler.ts';
export * from './appstore.handler.ts';

View File

@@ -21,6 +21,7 @@ export class NetworkHandler {
rabbitmq: 5672, rabbitmq: 5672,
caddy: 80, caddy: 80,
clickhouse: 8123, clickhouse: 8123,
mariadb: 3306,
}; };
return ports[type] || 0; return ports[type] || 0;
} }

View File

@@ -6,10 +6,128 @@ import { requireValidIdentity } from '../helpers/guards.ts';
export class PlatformHandler { export class PlatformHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
private activeLogStreams = new Map<string, boolean>();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers(); this.registerHandlers();
this.startLogStreaming();
}
/**
* Start streaming logs from all running containers (platform + user services)
* and push new entries to connected dashboard clients via TypedSocket
*/
private async startLogStreaming(): Promise<void> {
const checkAndStream = async () => {
// Stream platform service containers
const platformServices = this.opsServerRef.oneboxRef.database.getAllPlatformServices();
for (const service of platformServices) {
if (service.status !== 'running' || !service.containerId) continue;
const key = `platform:${service.type}`;
if (this.activeLogStreams.has(key)) continue;
this.activeLogStreams.set(key, true);
logger.info(`Starting log stream for platform service: ${service.type}`);
try {
await this.opsServerRef.oneboxRef.docker.streamContainerLogs(
service.containerId,
(line: string, isError: boolean) => {
this.pushPlatformLogToClients(service.type as interfaces.data.TPlatformServiceType, line, isError);
}
);
} catch (err) {
logger.warn(`Log stream failed for ${service.type}: ${(err as Error).message}`);
this.activeLogStreams.delete(key);
}
}
// Stream user service containers
const userServices = this.opsServerRef.oneboxRef.services.listServices();
for (const service of userServices) {
if (service.status !== 'running' || !service.containerID) continue;
const key = `service:${service.name}`;
if (this.activeLogStreams.has(key)) continue;
this.activeLogStreams.set(key, true);
logger.info(`Starting log stream for user service: ${service.name}`);
try {
await this.opsServerRef.oneboxRef.docker.streamContainerLogs(
service.containerID,
(line: string, isError: boolean) => {
this.pushServiceLogToClients(service.name, line, isError);
}
);
} catch (err) {
logger.warn(`Log stream failed for ${service.name}: ${(err as Error).message}`);
this.activeLogStreams.delete(key);
}
}
};
// Initial check after a short delay (let services start first)
setTimeout(() => checkAndStream(), 5000);
// Re-check periodically for newly started services
setInterval(() => checkAndStream(), 15000);
}
private parseLogLine(line: string, isError: boolean): { timestamp: string; level: string; message: string } {
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
const timestamp = tsMatch ? tsMatch[1] : new Date().toISOString();
const message = tsMatch ? tsMatch[2] : line;
const msgLower = message.toLowerCase();
const level = isError || msgLower.includes('error') || msgLower.includes('fatal')
? 'error'
: msgLower.includes('warn')
? 'warn'
: 'info';
return { timestamp, level, message };
}
private pushPlatformLogToClients(
serviceType: interfaces.data.TPlatformServiceType,
line: string,
isError: boolean,
): void {
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
if (!typedsocket) return;
const entry = this.parseLogLine(line, isError);
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
.then((connections: any[]) => {
for (const conn of connections) {
typedsocket.createTypedRequest<interfaces.requests.IReq_PushPlatformServiceLog>(
'pushPlatformServiceLog',
conn,
).fire({ serviceType, entry }).catch(() => {});
}
})
.catch(() => {});
}
private pushServiceLogToClients(
serviceName: string,
line: string,
isError: boolean,
): void {
const typedsocket = (this.opsServerRef.server as any)?.typedserver?.typedsocket;
if (!typedsocket) return;
const entry = this.parseLogLine(line, isError);
typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard')
.then((connections: any[]) => {
for (const conn of connections) {
typedsocket.createTypedRequest<interfaces.requests.IReq_PushServiceLog>(
'pushServiceLog',
conn,
).fire({ serviceName, entry }).catch(() => {});
}
})
.catch(() => {});
} }
private registerHandlers(): void { private registerHandlers(): void {
@@ -165,5 +283,47 @@ export class PlatformHandler {
}, },
), ),
); );
// Get platform service logs
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogs>(
'getPlatformServiceLogs',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
if (!service || !service.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
}
const tail = dataArg.tail || 100;
const rawLogs = await this.opsServerRef.oneboxRef.docker.getContainerLogs(service.containerId, tail);
// Parse raw log output into structured entries
const logLines = (rawLogs.stdout + rawLogs.stderr)
.split('\n')
.filter((line: string) => line.trim());
const logs = logLines.map((line: string, index: number) => {
// Try to parse Docker timestamp from beginning of line
const tsMatch = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
const timestamp = tsMatch ? new Date(tsMatch[1]).getTime() : Date.now();
const message = tsMatch ? tsMatch[2] : line;
const msgLower = message.toLowerCase();
const isError = msgLower.includes('error') || msgLower.includes('fatal');
const isWarn = msgLower.includes('warn');
return {
id: index,
serviceId: 0,
timestamp,
message,
level: (isError ? 'error' : isWarn ? 'warn' : 'info') as 'info' | 'warn' | 'error' | 'debug',
source: 'stdout' as const,
};
});
return { logs };
},
),
);
} }
} }

View File

@@ -0,0 +1,181 @@
import * as plugins from '../../plugins.ts';
import { logger } from '../../logging.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import { getErrorMessage } from '../../utils/error.ts';
export class WorkspaceHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Resolve a service name to a container ID (handling Swarm service IDs)
*/
private async resolveContainerId(serviceName: string): Promise<string> {
const service = this.opsServerRef.oneboxRef.services.getService(serviceName);
if (!service || !service.containerID) {
throw new plugins.typedrequest.TypedResponseError(`Service not found or has no container: ${serviceName}`);
}
return service.containerID;
}
private registerHandlers(): void {
// Read file from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadFile>(
'workspaceReadFile',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['cat', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to read file: ${result.stderr || 'File not found'}`);
}
return { content: result.stdout };
},
),
);
// Write file to container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceWriteFile>(
'workspaceWriteFile',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use sh -c with printf to write content (handles special characters)
const escaped = dataArg.content.replace(/'/g, "'\\''");
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['sh', '-c', `printf '%s' '${escaped}' > ${dataArg.path}`],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to write file: ${result.stderr}`);
}
return {};
},
),
);
// Read directory from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceReadDir>(
'workspaceReadDir',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
// Use ls with -1 -F to get entries with type indicators (/ for dirs)
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['ls', '-1', '-F', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to read directory: ${result.stderr}`);
}
const entries = result.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const isDir = line.endsWith('/');
const name = isDir ? line.slice(0, -1) : line.replace(/[*@=|]$/, '');
const basePath = dataArg.path.endsWith('/') ? dataArg.path : dataArg.path + '/';
return {
type: (isDir ? 'directory' : 'file') as 'file' | 'directory',
name,
path: basePath + name,
};
});
return { entries };
},
),
);
// Create directory in container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceMkdir>(
'workspaceMkdir',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['mkdir', '-p', dataArg.path],
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to create directory: ${result.stderr}`);
}
return {};
},
),
);
// Remove file/directory from container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceRm>(
'workspaceRm',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const args = dataArg.recursive ? ['rm', '-rf', dataArg.path] : ['rm', '-f', dataArg.path];
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
args,
);
if (result.exitCode !== 0) {
throw new plugins.typedrequest.TypedResponseError(`Failed to remove: ${result.stderr}`);
}
return {};
},
),
);
// Check if path exists in container
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExists>(
'workspaceExists',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
['test', '-e', dataArg.path],
);
return { exists: result.exitCode === 0 };
},
),
);
// Execute a command in the container (non-interactive)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_WorkspaceExec>(
'workspaceExec',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const containerId = await this.resolveContainerId(dataArg.serviceName);
const cmd = dataArg.args
? [dataArg.command, ...dataArg.args]
: [dataArg.command];
const result = await this.opsServerRef.oneboxRef.docker.execInContainer(
containerId,
cmd,
);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
};
},
),
);
logger.info('Workspace handler registered');
}
}

View File

@@ -17,10 +17,6 @@ export { path, fs, http, encoding };
import { Database } from '@db/sqlite'; import { Database } from '@db/sqlite';
export const sqlite = { DB: Database }; export const sqlite = { DB: Database };
// Systemd Daemon Integration
import * as smartdaemon from '@push.rocks/smartdaemon';
export { smartdaemon };
// Docker API Client // Docker API Client
import { DockerHost } from '@apiclient.xyz/docker'; import { DockerHost } from '@apiclient.xyz/docker';
export const docker = { Docker: DockerHost }; export const docker = { Docker: DockerHost };
@@ -38,8 +34,8 @@ import * as smartregistry from '@push.rocks/smartregistry';
export { smartregistry }; export { smartregistry };
// S3-compatible storage server // S3-compatible storage server
import * as smarts3 from '@push.rocks/smarts3'; import * as smartstorage from '@push.rocks/smartstorage';
export { smarts3 }; export { smartstorage };
// Task scheduling and cron jobs // Task scheduling and cron jobs
import * as taskbuffer from '@push.rocks/taskbuffer'; import * as taskbuffer from '@push.rocks/taskbuffer';
@@ -71,3 +67,12 @@ export { typedrequest, typedserver };
import * as smartguard from '@push.rocks/smartguard'; import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt'; import * as smartjwt from '@push.rocks/smartjwt';
export { smartguard, smartjwt }; export { smartguard, smartjwt };
// Backup archive (content-addressed dedup storage)
import { ContainerArchive } from '@serve.zone/containerarchive';
export { ContainerArchive };
// Node.js compat for streaming
import * as nodeFs from 'node:fs';
import * as nodeStream from 'node:stream';
export { nodeFs, nodeStream };

View File

@@ -25,6 +25,9 @@ export interface IService {
platformRequirements?: IPlatformRequirements; platformRequirements?: IPlatformRequirements;
// Backup settings // Backup settings
includeImageInBackup?: boolean; includeImageInBackup?: boolean;
// App Store template tracking
appTemplateId?: string;
appTemplateVersion?: string;
} }
// Registry types // Registry types
@@ -75,7 +78,7 @@ export interface ITokenCreatedResponse {
} }
// Platform service types // Platform service types
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse'; export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse' | 'mariadb';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
@@ -113,6 +116,8 @@ export interface IPlatformRequirements {
mongodb?: boolean; mongodb?: boolean;
s3?: boolean; s3?: boolean;
clickhouse?: boolean; clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
} }
export interface IProvisionedResource { export interface IProvisionedResource {
@@ -291,6 +296,11 @@ export interface IServiceDeployOptions {
enableMongoDB?: boolean; enableMongoDB?: boolean;
enableS3?: boolean; enableS3?: boolean;
enableClickHouse?: boolean; enableClickHouse?: boolean;
enableRedis?: boolean;
enableMariaDB?: boolean;
// App Store template tracking
appTemplateId?: string;
appTemplateVersion?: string;
} }
// HTTP API request/response types // HTTP API request/response types
@@ -346,7 +356,9 @@ export interface IBackup {
serviceId: number; serviceId: number;
serviceName: string; // Denormalized for display serviceName: string; // Denormalized for display
filename: string; filename: string;
snapshotId?: string; // ContainerArchive snapshot ID (new backups)
sizeBytes: number; sizeBytes: number;
storedSizeBytes?: number; // Actual stored size after dedup+compression
createdAt: number; createdAt: number;
includesImage: boolean; includesImage: boolean;
platformResources: TPlatformServiceType[]; // Which platform types were backed up platformResources: TPlatformServiceType[]; // Which platform types were backed up
@@ -389,7 +401,8 @@ export interface IBackupPlatformResource {
export interface IBackupResult { export interface IBackupResult {
backup: IBackup; backup: IBackup;
filePath: string; filePath?: string; // Legacy file-based backups only
snapshotId?: string; // ContainerArchive snapshot ID
} }
export interface IRestoreOptions { export interface IRestoreOptions {

File diff suppressed because one or more lines are too long

View File

@@ -28,7 +28,9 @@ export interface IBackup {
serviceId: number; serviceId: number;
serviceName: string; serviceName: string;
filename: string; filename: string;
snapshotId?: string;
sizeBytes: number; sizeBytes: number;
storedSizeBytes?: number;
createdAt: number; createdAt: number;
includesImage: boolean; includesImage: boolean;
platformResources: TPlatformServiceType[]; platformResources: TPlatformServiceType[];

View File

@@ -2,7 +2,7 @@
* Platform service data shapes for Onebox * Platform service data shapes for Onebox
*/ */
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse'; export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse' | 'mariadb';
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
@@ -10,6 +10,8 @@ export interface IPlatformRequirements {
mongodb?: boolean; mongodb?: boolean;
s3?: boolean; s3?: boolean;
clickhouse?: boolean; clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
} }
export interface IPlatformService { export interface IPlatformService {

View File

@@ -28,6 +28,9 @@ export interface IService {
platformRequirements?: IPlatformRequirements; platformRequirements?: IPlatformRequirements;
// Backup settings // Backup settings
includeImageInBackup?: boolean; includeImageInBackup?: boolean;
// App Store template tracking
appTemplateId?: string;
appTemplateVersion?: string;
} }
export interface IServiceCreate { export interface IServiceCreate {
@@ -42,6 +45,10 @@ export interface IServiceCreate {
enableMongoDB?: boolean; enableMongoDB?: boolean;
enableS3?: boolean; enableS3?: boolean;
enableClickHouse?: boolean; enableClickHouse?: boolean;
enableRedis?: boolean;
enableMariaDB?: boolean;
appTemplateId?: string;
appTemplateVersion?: string;
} }
export interface IServiceUpdate { export interface IServiceUpdate {

View File

@@ -8,6 +8,11 @@ export interface ISystemStatus {
docker: { docker: {
running: boolean; running: boolean;
version: unknown; version: unknown;
cpuUsage: number;
memoryUsage: number;
memoryTotal: number;
networkIn: number;
networkOut: number;
}; };
reverseProxy: { reverseProxy: {
http: { running: boolean; port: number }; http: { running: boolean; port: number };

View File

@@ -0,0 +1,106 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface ICatalogApp {
id: string;
name: string;
description: string;
category: string;
iconName?: string;
iconUrl?: string;
latestVersion: string;
tags?: string[];
}
export interface IAppVersionConfig {
image: string;
port: number;
envVars?: Array<{ key: string; value: string; description: string; required?: boolean }>;
volumes?: string[];
platformRequirements?: {
mongodb?: boolean;
s3?: boolean;
clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
};
minOneboxVersion?: string;
}
export interface IAppMeta {
id: string;
name: string;
description: string;
category: string;
iconName?: string;
latestVersion: string;
versions: string[];
maintainer?: string;
links?: Record<string, string>;
}
export interface IUpgradeableService {
serviceName: string;
appTemplateId: string;
currentVersion: string;
latestVersion: string;
hasMigration: boolean;
}
export interface IReq_GetAppTemplates extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAppTemplates
> {
method: 'getAppTemplates';
request: {
identity: data.IIdentity;
};
response: {
apps: ICatalogApp[];
};
}
export interface IReq_GetAppConfig extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetAppConfig
> {
method: 'getAppConfig';
request: {
identity: data.IIdentity;
appId: string;
version: string;
};
response: {
config: IAppVersionConfig;
appMeta: IAppMeta;
};
}
export interface IReq_GetUpgradeableServices extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetUpgradeableServices
> {
method: 'getUpgradeableServices';
request: {
identity: data.IIdentity;
};
response: {
services: IUpgradeableService[];
};
}
export interface IReq_UpgradeService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpgradeService
> {
method: 'upgradeService';
request: {
identity: data.IIdentity;
serviceName: string;
targetVersion: string;
};
response: {
service: data.IService;
warnings: string[];
};
}

View File

@@ -11,3 +11,5 @@ export * from './backups.ts';
export * from './backup-schedules.ts'; export * from './backup-schedules.ts';
export * from './settings.ts'; export * from './settings.ts';
export * from './logs.ts'; export * from './logs.ts';
export * from './workspace.ts';
export * from './appstore.ts';

View File

@@ -69,3 +69,34 @@ export interface IReq_GetPlatformServiceStats extends plugins.typedrequestInterf
stats: data.IContainerStats; stats: data.IContainerStats;
}; };
} }
export interface IReq_GetPlatformServiceLogs extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetPlatformServiceLogs
> {
method: 'getPlatformServiceLogs';
request: {
identity: data.IIdentity;
serviceType: data.TPlatformServiceType;
tail?: number;
};
response: {
logs: data.ILogEntry[];
};
}
export interface IReq_PushPlatformServiceLog extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushPlatformServiceLog
> {
method: 'pushPlatformServiceLog';
request: {
serviceType: data.TPlatformServiceType;
entry: {
timestamp: string;
level: string;
message: string;
};
};
response: {};
}

View File

@@ -212,3 +212,19 @@ export interface IReq_GetServiceBackupSchedules extends plugins.typedrequestInte
schedules: data.IBackupSchedule[]; schedules: data.IBackupSchedule[];
}; };
} }
export interface IReq_PushServiceLog extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_PushServiceLog
> {
method: 'pushServiceLog';
request: {
serviceName: string;
entry: {
timestamp: string;
level: string;
message: string;
};
};
response: {};
}

View File

@@ -0,0 +1,106 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_WorkspaceReadFile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WorkspaceReadFile
> {
method: 'workspaceReadFile';
request: {
identity: data.IIdentity;
serviceName: string;
path: string;
};
response: {
content: string;
};
}
export interface IReq_WorkspaceWriteFile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WorkspaceWriteFile
> {
method: 'workspaceWriteFile';
request: {
identity: data.IIdentity;
serviceName: string;
path: string;
content: string;
};
response: {};
}
export interface IReq_WorkspaceReadDir extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WorkspaceReadDir
> {
method: 'workspaceReadDir';
request: {
identity: data.IIdentity;
serviceName: string;
path: string;
};
response: {
entries: Array<{ type: 'file' | 'directory'; name: string; path: string }>;
};
}
export interface IReq_WorkspaceMkdir extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WorkspaceMkdir
> {
method: 'workspaceMkdir';
request: {
identity: data.IIdentity;
serviceName: string;
path: string;
};
response: {};
}
export interface IReq_WorkspaceRm extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WorkspaceRm
> {
method: 'workspaceRm';
request: {
identity: data.IIdentity;
serviceName: string;
path: string;
recursive?: boolean;
};
response: {};
}
export interface IReq_WorkspaceExists extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WorkspaceExists
> {
method: 'workspaceExists';
request: {
identity: data.IIdentity;
serviceName: string;
path: string;
};
response: {
exists: boolean;
};
}
export interface IReq_WorkspaceExec extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_WorkspaceExec
> {
method: 'workspaceExec';
request: {
identity: data.IIdentity;
serviceName: string;
command: string;
args?: string[];
};
response: {
stdout: string;
stderr: string;
exitCode: number;
};
}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.13.9', version: '1.24.2',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
} }

View File

@@ -26,6 +26,8 @@ export interface IServicesState {
currentServiceStats: interfaces.data.IContainerStats | null; currentServiceStats: interfaces.data.IContainerStats | null;
platformServices: interfaces.data.IPlatformService[]; platformServices: interfaces.data.IPlatformService[];
currentPlatformService: interfaces.data.IPlatformService | null; currentPlatformService: interfaces.data.IPlatformService | null;
currentPlatformServiceStats: interfaces.data.IContainerStats | null;
currentPlatformServiceLogs: interfaces.data.ILogEntry[];
} }
export interface INetworkState { export interface INetworkState {
@@ -52,10 +54,16 @@ export interface ISettingsState {
backupPasswordConfigured: boolean; backupPasswordConfigured: boolean;
} }
export interface IAppStoreState {
apps: interfaces.requests.ICatalogApp[];
upgradeableServices: interfaces.requests.IUpgradeableService[];
}
export interface IUiState { export interface IUiState {
activeView: string; activeView: string;
autoRefresh: boolean; autoRefresh: boolean;
refreshInterval: number; refreshInterval: number;
pendingAppTemplate?: any;
} }
// ============================================================================ // ============================================================================
@@ -88,6 +96,8 @@ export const servicesStatePart = await appState.getStatePart<IServicesState>(
currentServiceStats: null, currentServiceStats: null,
platformServices: [], platformServices: [],
currentPlatformService: null, currentPlatformService: null,
currentPlatformServiceStats: null,
currentPlatformServiceLogs: [],
}, },
'soft', 'soft',
); );
@@ -132,6 +142,15 @@ export const settingsStatePart = await appState.getStatePart<ISettingsState>(
'soft', 'soft',
); );
export const appStoreStatePart = await appState.getStatePart<IAppStoreState>(
'appStore',
{
apps: [],
upgradeableServices: [],
},
'soft',
);
export const uiStatePart = await appState.getStatePart<IUiState>( export const uiStatePart = await appState.getStatePart<IUiState>(
'ui', 'ui',
{ {
@@ -476,6 +495,46 @@ export const stopPlatformServiceAction = servicesStatePart.createAction<{
} }
}); });
export const fetchPlatformServiceStatsAction = servicesStatePart.createAction<{
serviceType: interfaces.data.TPlatformServiceType;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPlatformServiceStats
>('/typedrequest', 'getPlatformServiceStats');
const response = await typedRequest.fire({
identity: context.identity!,
serviceType: dataArg.serviceType,
});
return { ...statePartArg.getState(), currentPlatformServiceStats: response.stats };
} catch (err) {
console.error('Failed to fetch platform service stats:', err);
return { ...statePartArg.getState(), currentPlatformServiceStats: null };
}
});
export const fetchPlatformServiceLogsAction = servicesStatePart.createAction<{
serviceType: interfaces.data.TPlatformServiceType;
tail?: number;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPlatformServiceLogs
>('/typedrequest', 'getPlatformServiceLogs');
const response = await typedRequest.fire({
identity: context.identity!,
serviceType: dataArg.serviceType,
tail: dataArg.tail || 100,
});
return { ...statePartArg.getState(), currentPlatformServiceLogs: response.logs };
} catch (err) {
console.error('Failed to fetch platform service logs:', err);
return { ...statePartArg.getState(), currentPlatformServiceLogs: [] };
}
});
// ============================================================================ // ============================================================================
// Network Actions // Network Actions
// ============================================================================ // ============================================================================
@@ -869,7 +928,8 @@ export const setBackupPasswordAction = settingsStatePart.createAction<{ password
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>( export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
async (statePartArg, dataArg) => { async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), activeView: dataArg.view }; const normalizedView = dataArg.view.toLowerCase().replace(/\s+/g, '-');
return { ...statePartArg.getState(), activeView: normalizedView };
}, },
); );
@@ -917,3 +977,166 @@ const startAutoRefresh = () => {
uiStatePart.select((s) => s).subscribe(() => startAutoRefresh()); uiStatePart.select((s) => s).subscribe(() => startAutoRefresh());
loginStatePart.select((s) => s).subscribe(() => startAutoRefresh()); loginStatePart.select((s) => s).subscribe(() => startAutoRefresh());
startAutoRefresh(); startAutoRefresh();
// ============================================================================
// TypedSocket — real-time server push (logs, events)
// ============================================================================
let socketClient: InstanceType<typeof plugins.typedsocket.TypedSocket> | null = null;
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
// Handle server-pushed platform service log entries
socketRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushPlatformServiceLog>(
'pushPlatformServiceLog',
async (dataArg) => {
const state = servicesStatePart.getState();
const entry: interfaces.data.ILogEntry = {
id: state.currentPlatformServiceLogs.length,
serviceId: 0,
timestamp: new Date(dataArg.entry.timestamp).getTime(),
message: dataArg.entry.message,
level: dataArg.entry.level as 'info' | 'warn' | 'error' | 'debug',
source: 'stdout',
};
const updated = [...state.currentPlatformServiceLogs, entry];
// Cap at 2000 entries
if (updated.length > 2000) {
updated.splice(0, updated.length - 2000);
}
servicesStatePart.setState({
...state,
currentPlatformServiceLogs: updated,
});
return {};
},
),
);
// Handle server-pushed user service log entries
socketRouter.addTypedHandler(
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushServiceLog>(
'pushServiceLog',
async (dataArg) => {
const state = servicesStatePart.getState();
// Only append if we're currently viewing this service
if (!state.currentService || state.currentService.name !== dataArg.serviceName) {
return {};
}
const entry: interfaces.data.ILogEntry = {
id: state.currentServiceLogs.length,
serviceId: 0,
timestamp: new Date(dataArg.entry.timestamp).getTime(),
message: dataArg.entry.message,
level: dataArg.entry.level as 'info' | 'warn' | 'error' | 'debug',
source: 'stdout',
};
const updated = [...state.currentServiceLogs, entry];
if (updated.length > 2000) {
updated.splice(0, updated.length - 2000);
}
servicesStatePart.setState({
...state,
currentServiceLogs: updated,
});
return {};
},
),
);
async function connectSocket() {
if (socketClient) return;
try {
socketClient = await plugins.typedsocket.TypedSocket.createClient(
socketRouter,
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
);
await socketClient.setTag('role', 'ops_dashboard');
console.log('TypedSocket dashboard connection established');
} catch (err) {
console.error('TypedSocket connection failed:', err);
socketClient = null;
}
}
async function disconnectSocket() {
if (socketClient) {
try {
await socketClient.disconnect();
} catch {
// ignore disconnect errors
}
socketClient = null;
}
}
// ============================================================================
// App Store Actions
// ============================================================================
export const fetchAppTemplatesAction = appStoreStatePart.createAction(
async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAppTemplates
>('/typedrequest', 'getAppTemplates');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), apps: response.apps };
} catch (err) {
console.error('Failed to fetch app templates:', err);
return statePartArg.getState();
}
},
);
export const fetchUpgradeableServicesAction = appStoreStatePart.createAction(
async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetUpgradeableServices
>('/typedrequest', 'getUpgradeableServices');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), upgradeableServices: response.services };
} catch (err) {
console.error('Failed to fetch upgradeable services:', err);
return statePartArg.getState();
}
},
);
export const upgradeServiceAction = appStoreStatePart.createAction<{
serviceName: string;
targetVersion: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpgradeService
>('/typedrequest', 'upgradeService');
await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.serviceName,
targetVersion: dataArg.targetVersion,
});
// Re-fetch upgradeable services and services list
const upgradeReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetUpgradeableServices
>('/typedrequest', 'getUpgradeableServices');
const upgradeResp = await upgradeReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), upgradeableServices: upgradeResp.services };
} catch (err) {
console.error('Failed to upgrade service:', err);
return statePartArg.getState();
}
});
// Connect socket when logged in, disconnect when logged out
loginStatePart.select((s) => s).subscribe((loginState) => {
if (loginState.isLoggedIn) {
connectSocket();
} else {
disconnectSocket();
}
});

View File

@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as interfaces from '../../ts_interfaces/index.js'; import * as interfaces from '../../ts_interfaces/index.js';
import { appRouter } from '../router.js';
import { import {
DeesElement, DeesElement,
customElement, customElement,
@@ -37,15 +38,16 @@ export class ObAppShell extends DeesElement {
accessor loginError: string = ''; accessor loginError: string = '';
private viewTabs = [ private viewTabs = [
{ name: 'Dashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() }, { name: 'Dashboard', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() },
{ name: 'Services', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() }, { name: 'App Store', iconName: 'lucide:store', element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)() },
{ name: 'Network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() }, { name: 'Services', iconName: 'lucide:boxes', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() },
{ name: 'Registries', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() }, { name: 'Network', iconName: 'lucide:network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() },
{ name: 'Tokens', element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)() }, { name: 'Registries', iconName: 'lucide:package', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() },
{ name: 'Settings', element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)() }, { name: 'Tokens', iconName: 'lucide:key', element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)() },
{ name: 'Settings', iconName: 'lucide:settings', element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)() },
]; ];
private resolvedViewTabs: Array<{ name: string; element: any }> = []; private resolvedViewTabs: Array<{ name: string; iconName?: string; element: any }> = [];
constructor() { constructor() {
super(); super();
@@ -92,6 +94,9 @@ export class ObAppShell extends DeesElement {
<dees-simple-appdash <dees-simple-appdash
name="Onebox" name="Onebox"
.viewTabs=${this.resolvedViewTabs} .viewTabs=${this.resolvedViewTabs}
.selectedView=${this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView
) || this.resolvedViewTabs[0]}
> >
</dees-simple-appdash> </dees-simple-appdash>
</dees-simple-login> </dees-simple-login>
@@ -104,6 +109,7 @@ export class ObAppShell extends DeesElement {
this.resolvedViewTabs = await Promise.all( this.resolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({ this.viewTabs.map(async (tab) => ({
name: tab.name, name: tab.name,
iconName: tab.iconName,
element: await tab.element, element: await tab.element,
})), })),
); );
@@ -120,8 +126,8 @@ export class ObAppShell extends DeesElement {
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any; const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
if (appDash) { if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => { appDash.addEventListener('view-select', (e: CustomEvent) => {
const viewName = e.detail.view.name.toLowerCase(); const viewName = e.detail.view.name.toLowerCase().replace(/\s+/g, '-');
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName }); appRouter.navigateToView(viewName);
}); });
appDash.addEventListener('logout', async () => { appDash.addEventListener('logout', async () => {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
@@ -129,10 +135,11 @@ export class ObAppShell extends DeesElement {
} }
// Load the initial view on the appdash now that tabs are resolved // Load the initial view on the appdash now that tabs are resolved
// (appdash's own firstUpdated already fired when viewTabs was still empty) // Read activeView directly from state (not this.uiState which may be stale)
if (appDash && this.resolvedViewTabs.length > 0) { if (appDash && this.resolvedViewTabs.length > 0) {
const currentActiveView = appstate.uiStatePart.getState().activeView;
const initialView = this.resolvedViewTabs.find( const initialView = this.resolvedViewTabs.find(
(t) => t.name.toLowerCase() === this.uiState.activeView, (t) => t.name.toLowerCase().replace(/\s+/g, '-') === currentActiveView,
) || this.resolvedViewTabs[0]; ) || this.resolvedViewTabs[0];
await appDash.loadView(initialView); await appDash.loadView(initialView);
} }
@@ -141,23 +148,26 @@ export class ObAppShell extends DeesElement {
const loginState = appstate.loginStatePart.getState(); const loginState = appstate.loginStatePart.getState();
if (loginState.identity?.jwt) { if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) { if (loginState.identity.expiresAt > Date.now()) {
// Validate token with server before switching to dashboard // Switch to dashboard immediately (no flash of login form)
// (server may have restarted with a new JWT secret) this.loginState = loginState;
if (simpleLogin) {
await simpleLogin.switchToSlottedContent();
}
// Validate token with server in the background
try { try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSystemStatus interfaces.requests.IReq_GetSystemStatus
>('/typedrequest', 'getSystemStatus'); >('/typedrequest', 'getSystemStatus');
const response = await typedRequest.fire({ identity: loginState.identity }); const response = await typedRequest.fire({ identity: loginState.identity });
// Token is valid - switch to dashboard
appstate.systemStatePart.setState({ status: response.status }); appstate.systemStatePart.setState({ status: response.status });
this.loginState = loginState;
if (simpleLogin) {
await simpleLogin.switchToSlottedContent();
}
} catch (err) { } catch (err) {
// Token rejected by server - clear session // Token rejected by server - switch back to login
console.warn('Stored session invalid, returning to login:', err); console.warn('Stored session invalid, returning to login:', err);
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
if (simpleLogin) {
// Force page reload to show login properly
window.location.reload();
}
} }
} else { } else {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null); await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
@@ -199,9 +209,11 @@ export class ObAppShell extends DeesElement {
private syncAppdashView(viewName: string): void { private syncAppdashView(viewName: string): void {
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any; const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash || this.resolvedViewTabs.length === 0) return; if (!appDash || this.resolvedViewTabs.length === 0) return;
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName); // Match kebab-case view name (e.g., 'app-store') to tab name (e.g., 'App Store')
const targetTab = this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === viewName
);
if (!targetTab) return; if (!targetTab) return;
// Use appdash's own loadView method for proper view management
appDash.loadView(targetTab); appDash.loadView(targetTab);
} }
} }

View File

@@ -0,0 +1,612 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../ts_interfaces/index.js';
import { appRouter } from '../router.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ob-view-appstore')
export class ObViewAppStore extends DeesElement {
@state()
accessor appStoreState: appstate.IAppStoreState = {
apps: [],
upgradeableServices: [],
};
@state()
accessor currentView: 'grid' | 'detail' = 'grid';
@state()
accessor selectedApp: interfaces.requests.ICatalogApp | null = null;
@state()
accessor selectedAppMeta: interfaces.requests.IAppMeta | null = null;
@state()
accessor selectedAppConfig: interfaces.requests.IAppVersionConfig | null = null;
@state()
accessor selectedVersion: string = '';
@state()
accessor editableEnvVars: Array<{ key: string; value: string; description: string; required?: boolean; platformInjected?: boolean }> = [];
@state()
accessor serviceName: string = '';
@state()
accessor loading: boolean = false;
@state()
accessor deployMode: boolean = false;
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css`
.detail-card {
background: var(--ci-shade-1, #09090b);
border: 1px solid var(--ci-shade-2, #27272a);
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.detail-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.detail-icon {
width: 64px;
height: 64px;
border-radius: 12px;
background: var(--ci-shade-2, #27272a);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
color: var(--ci-shade-5, #a1a1aa);
flex-shrink: 0;
}
.detail-title {
font-size: 24px;
font-weight: 700;
color: var(--ci-shade-7, #e4e4e7);
margin: 0 0 4px 0;
}
.detail-category {
display: inline-block;
padding: 2px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
background: var(--ci-shade-2, #27272a);
color: var(--ci-shade-5, #a1a1aa);
margin-bottom: 8px;
}
.detail-description {
font-size: 14px;
color: var(--ci-shade-5, #a1a1aa);
line-height: 1.6;
margin: 0;
}
.detail-meta {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 13px;
color: var(--ci-shade-4, #71717a);
}
.detail-meta a {
color: var(--ci-shade-5, #a1a1aa);
text-decoration: none;
}
.detail-meta a:hover {
text-decoration: underline;
}
.section-label {
font-size: 13px;
font-weight: 600;
color: var(--ci-shade-5, #a1a1aa);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
margin-right: 6px;
margin-bottom: 6px;
}
.version-row {
display: flex;
align-items: center;
gap: 16px;
}
.version-select {
background: var(--ci-shade-2, #27272a);
border: 1px solid var(--ci-shade-3, #3f3f46);
border-radius: 6px;
padding: 8px 12px;
color: var(--ci-shade-7, #e4e4e7);
font-size: 14px;
cursor: pointer;
}
.image-tag {
font-family: monospace;
font-size: 13px;
color: var(--ci-shade-5, #a1a1aa);
background: var(--ci-shade-2, #27272a);
padding: 4px 8px;
border-radius: 4px;
}
.env-table {
width: 100%;
border-collapse: collapse;
}
.env-table th {
text-align: left;
font-size: 12px;
font-weight: 500;
color: var(--ci-shade-4, #71717a);
padding: 8px 8px 8px 0;
border-bottom: 1px solid var(--ci-shade-2, #27272a);
}
.env-table td {
padding: 6px 8px 6px 0;
vertical-align: middle;
}
.env-input {
width: 100%;
background: var(--ci-shade-2, #27272a);
border: 1px solid var(--ci-shade-3, #3f3f46);
border-radius: 4px;
padding: 6px 8px;
color: var(--ci-shade-7, #e4e4e7);
font-size: 13px;
font-family: monospace;
box-sizing: border-box;
}
.env-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.env-key {
font-family: monospace;
font-size: 13px;
color: var(--ci-shade-6, #d4d4d8);
white-space: nowrap;
}
.env-desc {
font-size: 12px;
color: var(--ci-shade-4, #71717a);
}
.env-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
.env-badge.required {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.env-badge.auto {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.name-input {
background: var(--ci-shade-2, #27272a);
border: 1px solid var(--ci-shade-3, #3f3f46);
border-radius: 6px;
padding: 10px 14px;
color: var(--ci-shade-7, #e4e4e7);
font-size: 14px;
width: 300px;
box-sizing: border-box;
}
.actions-row {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 200ms ease;
}
.btn:hover { opacity: 0.9; }
.btn-primary {
background: var(--ci-shade-7, #e4e4e7);
color: var(--ci-shade-0, #09090b);
}
.btn-secondary {
background: transparent;
border: 1px solid var(--ci-shade-2, #27272a);
color: var(--ci-shade-6, #d4d4d8);
}
.loading-spinner {
padding: 32px;
text-align: center;
color: var(--ci-shade-4, #71717a);
}
`,
];
constructor() {
super();
const sub = appstate.appStoreStatePart
.select((s) => s)
.subscribe((newState) => {
this.appStoreState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
super.connectedCallback();
await appstate.appStoreStatePart.dispatchAction(appstate.fetchAppTemplatesAction, null);
}
public render(): TemplateResult {
switch (this.currentView) {
case 'detail':
return this.renderDetailView();
default:
return this.renderGridView();
}
}
private renderGridView(): TemplateResult {
const appTemplates = this.appStoreState.apps.map((app) => ({
id: app.id,
name: app.name,
description: app.description,
category: app.category,
iconName: app.iconName,
iconUrl: app.iconUrl,
image: '',
port: 0,
}));
return html`
<ob-sectionheading>App Store</ob-sectionheading>
${appTemplates.length === 0
? html`<div class="loading-spinner">Loading app templates...</div>`
: html`
<sz-app-store-view
.apps=${appTemplates}
@view-app=${(e: CustomEvent) => this.handleViewDetails(e)}
@deploy-app=${(e: CustomEvent) => this.handleAppClick(e)}
></sz-app-store-view>
`}
`;
}
private renderDetailView(): TemplateResult {
if (this.loading) {
return html`
<ob-sectionheading>App Store</ob-sectionheading>
<div class="loading-spinner">Loading app details...</div>
`;
}
const app = this.selectedApp;
const meta = this.selectedAppMeta;
const config = this.selectedAppConfig;
if (!app || !config) {
return html`
<ob-sectionheading>App Store</ob-sectionheading>
<div class="loading-spinner">App not found.</div>
`;
}
const platformReqs = config.platformRequirements || {};
const hasPlatformReqs = Object.values(platformReqs).some(Boolean);
const platformLabels: Record<string, string> = {
mongodb: 'MongoDB',
s3: 'S3 (MinIO)',
clickhouse: 'ClickHouse',
redis: 'Redis',
mariadb: 'MariaDB',
};
return html`
<ob-sectionheading>App Store</ob-sectionheading>
<button class="btn btn-secondary" style="margin-bottom: 16px;" @click=${() => { this.currentView = 'grid'; }}>
&larr; Back to App Store
</button>
<!-- Header -->
<div class="detail-card">
<div class="detail-header">
<div class="detail-icon">${(app.name || '?')[0].toUpperCase()}</div>
<div style="flex: 1;">
<h2 class="detail-title">${app.name}</h2>
<span class="detail-category">${app.category}</span>
<p class="detail-description">${app.description}</p>
<div class="detail-meta">
${meta?.maintainer ? html`<span>Maintainer: <strong>${meta.maintainer}</strong></span>` : ''}
${meta?.links ? Object.entries(meta.links).map(([label, url]) =>
html`<a href="${url}" target="_blank" rel="noopener">${label}</a>`
) : ''}
${app.tags?.length ? html`<span>Tags: ${app.tags.join(', ')}</span>` : ''}
</div>
</div>
</div>
</div>
<!-- Platform Services -->
${hasPlatformReqs ? html`
<div class="detail-card">
<div class="section-label">Platform Services</div>
<div>
${Object.entries(platformReqs)
.filter(([_, enabled]) => enabled)
.map(([key]) => html`<span class="badge">${platformLabels[key] || key}</span>`)}
</div>
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 8px;">
These platform services will be automatically provisioned when you deploy.
</div>
</div>
` : ''}
<!-- Version & Image -->
<div class="detail-card">
<div class="section-label">Version</div>
<div class="version-row">
<select class="version-select" @change=${(e: Event) => this.handleVersionChange((e.target as HTMLSelectElement).value)}>
${(meta?.versions || [this.selectedVersion]).map((v) =>
html`<option value="${v}" ?selected=${v === this.selectedVersion}>${v}${v === app.latestVersion ? ' (latest)' : ''}</option>`
)}
</select>
<span class="image-tag">${config.image}</span>
${config.minOneboxVersion ? html`<span style="font-size: 12px; color: var(--ci-shade-4, #71717a);">Requires onebox &ge; ${config.minOneboxVersion}</span>` : ''}
</div>
</div>
<!-- Environment Variables -->
${this.editableEnvVars.length > 0 ? html`
<div class="detail-card">
<div class="section-label">Environment Variables</div>
<table class="env-table">
<thead>
<tr>
<th style="width: 30%;">Variable</th>
<th style="width: 40%;">Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${this.editableEnvVars.map((ev, index) => html`
<tr>
<td>
<span class="env-key">${ev.key}</span>
${ev.required ? html`<span class="env-badge required">required</span>` : ''}
${ev.platformInjected ? html`<span class="env-badge auto">auto</span>` : ''}
</td>
<td>
<input
class="env-input"
type="text"
.value=${ev.value}
?disabled=${ev.platformInjected || !this.deployMode}
placeholder=${ev.platformInjected ? 'Auto-injected by platform' : 'Enter value...'}
@input=${(e: Event) => this.handleEnvVarChange(index, (e.target as HTMLInputElement).value)}
/>
</td>
<td><span class="env-desc">${ev.description || ''}</span></td>
</tr>
`)}
</tbody>
</table>
</div>
` : ''}
<!-- Deploy section (only in deploy mode) or action button (view mode) -->
${this.deployMode ? html`
<div class="detail-card">
<div class="section-label">Service Name</div>
<input
class="name-input"
type="text"
.value=${this.serviceName}
placeholder="e.g. my-ghost-blog"
@input=${(e: Event) => { this.serviceName = (e.target as HTMLInputElement).value; }}
/>
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 6px;">
Lowercase letters, numbers, and hyphens only.
</div>
<div class="actions-row">
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>Cancel</button>
<button class="btn btn-primary" @click=${() => this.handleDeploy()}>
Deploy v${this.selectedVersion}
</button>
</div>
</div>
` : html`
<div class="actions-row" style="margin-top: 8px;">
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>
&larr; Back
</button>
<button class="btn btn-primary" @click=${() => { this.deployMode = true; }}>
Deploy this App
</button>
</div>
`}
`;
}
private async handleViewDetails(e: CustomEvent) {
const app = e.detail?.app;
if (!app) return;
const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id);
if (!catalogApp) return;
this.deployMode = false;
this.selectedApp = catalogApp;
this.selectedVersion = catalogApp.latestVersion;
this.serviceName = catalogApp.id;
this.loading = true;
this.currentView = 'detail';
await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion);
this.loading = false;
}
private async handleAppClick(e: CustomEvent) {
const app = e.detail?.app;
if (!app) return;
const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id);
if (!catalogApp) return;
this.deployMode = true;
this.selectedApp = catalogApp;
this.selectedVersion = catalogApp.latestVersion;
this.serviceName = catalogApp.id;
this.loading = true;
this.currentView = 'detail';
await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion);
this.loading = false;
}
private async handleVersionChange(version: string) {
if (!this.selectedApp || version === this.selectedVersion) return;
this.selectedVersion = version;
this.loading = true;
await this.fetchVersionConfig(this.selectedApp.id, version);
this.loading = false;
}
private async fetchVersionConfig(appId: string, version: string) {
try {
const identity = appstate.loginStatePart.getState().identity;
if (!identity) return;
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAppConfig
>('/typedrequest', 'getAppConfig');
const response = await typedRequest.fire({ identity, appId, version });
this.selectedAppMeta = response.appMeta;
this.selectedAppConfig = response.config;
// Build editable env vars
this.editableEnvVars = (response.config.envVars || []).map((ev) => ({
key: ev.key,
value: ev.value || '',
description: ev.description || '',
required: ev.required,
platformInjected: ev.value?.includes('${') || false,
}));
} catch (err) {
console.error('Failed to fetch app config:', err);
}
}
private handleEnvVarChange(index: number, value: string) {
const updated = [...this.editableEnvVars];
updated[index] = { ...updated[index], value };
this.editableEnvVars = updated;
}
private async handleDeploy() {
const app = this.selectedApp;
const config = this.selectedAppConfig;
if (!app || !config) return;
const envVars: Record<string, string> = {};
for (const ev of this.editableEnvVars) {
if (ev.key && ev.value && !ev.platformInjected) {
envVars[ev.key] = ev.value;
}
}
const platformReqs = config.platformRequirements || {};
const serviceConfig: interfaces.data.IServiceCreate = {
name: this.serviceName || app.id,
image: config.image,
port: config.port || 80,
envVars,
enableMongoDB: platformReqs.mongodb || false,
enableS3: platformReqs.s3 || false,
enableClickHouse: platformReqs.clickhouse || false,
enableRedis: platformReqs.redis || false,
enableMariaDB: platformReqs.mariadb || false,
appTemplateId: app.id,
appTemplateVersion: this.selectedVersion,
};
try {
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
config: serviceConfig,
});
setTimeout(() => {
appRouter.navigateToView('services');
}, 0);
} catch (err) {
console.error('Failed to deploy from App Store:', err);
}
}
}

View File

@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import { import {
DeesElement, DeesElement,
customElement, customElement,
@@ -24,6 +25,8 @@ export class ObViewDashboard extends DeesElement {
currentServiceStats: null, currentServiceStats: null,
platformServices: [], platformServices: [],
currentPlatformService: null, currentPlatformService: null,
currentPlatformServiceStats: null,
currentPlatformServiceLogs: [],
}; };
@state() @state()
@@ -108,15 +111,17 @@ export class ObViewDashboard extends DeesElement {
cpu: status?.docker?.cpuUsage || 0, cpu: status?.docker?.cpuUsage || 0,
memoryUsed: status?.docker?.memoryUsage || 0, memoryUsed: status?.docker?.memoryUsage || 0,
memoryTotal: status?.docker?.memoryTotal || 0, memoryTotal: status?.docker?.memoryTotal || 0,
networkIn: 0, networkIn: status?.docker?.networkIn || 0,
networkOut: 0, networkOut: status?.docker?.networkOut || 0,
topConsumers: [], topConsumers: [],
}, },
platformServices: platformServices.map((ps) => ({ platformServices: platformServices
name: ps.displayName, .filter((ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore)
status: ps.status === 'running' ? 'running' : 'stopped', .map((ps) => ({
running: ps.status === 'running', name: ps.displayName,
})), status: ps.status === 'running' ? 'Running' : ps.status === 'starting' ? 'Starting...' : ps.status === 'stopping' ? 'Stopping...' : 'Stopped',
running: ps.status === 'running',
})),
traffic: { traffic: {
requests: 0, requests: 0,
errors: 0, errors: 0,
@@ -149,6 +154,7 @@ export class ObViewDashboard extends DeesElement {
], ],
}} }}
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)} @action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
@service-click=${(e: CustomEvent) => this.handlePlatformServiceClick(e)}
></sz-dashboard-view> ></sz-dashboard-view>
`; `;
} }
@@ -156,9 +162,26 @@ export class ObViewDashboard extends DeesElement {
private handleQuickAction(e: CustomEvent) { private handleQuickAction(e: CustomEvent) {
const action = e.detail?.action || e.detail?.label; const action = e.detail?.action || e.detail?.label;
if (action === 'Deploy Service') { if (action === 'Deploy Service') {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' }); appRouter.navigateToView('services');
} else if (action === 'Add Domain') { } else if (action === 'Add Domain') {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'network' }); appRouter.navigateToView('network');
}
}
private handlePlatformServiceClick(e: CustomEvent) {
// Find the platform service type from the click event
const name = e.detail?.name;
const ps = this.servicesState.platformServices.find(
(p) => p.displayName === name,
);
if (ps) {
// Navigate to services tab — the ObViewServices component will pick up the type
// Store the selected platform type so the services view can open it
appstate.servicesStatePart.setState({
...appstate.servicesStatePart.getState(),
currentPlatformService: ps,
});
appRouter.navigateToView('services');
} }
} }
} }

View File

@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import { import {
DeesElement, DeesElement,
customElement, customElement,
@@ -64,7 +65,7 @@ export class ObViewRegistries extends DeesElement {
.registryUrl=${'localhost:5000'} .registryUrl=${'localhost:5000'}
@manage-tokens=${() => { @manage-tokens=${() => {
// tokens are managed via the tokens view // tokens are managed via the tokens view
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'tokens' }); appRouter.navigateToView('tokens');
}} }}
></sz-registry-advertisement> ></sz-registry-advertisement>
`; `;

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as interfaces from '../../ts_interfaces/index.js'; import * as interfaces from '../../ts_interfaces/index.js';
import { BackendExecutionEnvironment } from '../environments/backend-environment.js';
import { import {
DeesElement, DeesElement,
customElement, customElement,
@@ -76,20 +77,29 @@ function toServiceStats(stats: interfaces.data.IContainerStats) {
}; };
} }
function parseLogs(logs: any): Array<{ timestamp: string; message: string }> { function parseLogs(logs: any): Array<{ timestamp: string; message: string; level?: string }> {
if (Array.isArray(logs)) { if (Array.isArray(logs)) {
return logs.map((entry: any) => ({ return logs.map((entry: any) => {
timestamp: entry.timestamp ? String(entry.timestamp) : '', const ts = entry.timestamp
message: entry.message || String(entry), ? (typeof entry.timestamp === 'number' ? new Date(entry.timestamp).toISOString() : String(entry.timestamp))
})); : new Date().toISOString();
const message = entry.message || String(entry);
const level = entry.level || 'info';
return { timestamp: ts, message, level };
});
} }
if (typeof logs === 'string' && logs.trim()) { if (typeof logs === 'string' && logs.trim()) {
return logs.split('\n').filter((line: string) => line.trim()).map((line: string) => { return logs.split('\n').filter((line: string) => line.trim()).map((line: string) => {
const match = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/); const match = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
if (match) { const timestamp = match ? match[1] : new Date().toISOString();
return { timestamp: match[1], message: match[2] }; const message = match ? match[2] : line;
} const msgLower = message.toLowerCase();
return { timestamp: '', message: line }; const level = msgLower.includes('error') || msgLower.includes('fatal')
? 'error'
: msgLower.includes('warn')
? 'warn'
: 'info';
return { timestamp, message, level };
}); });
} }
return []; return [];
@@ -107,6 +117,8 @@ export class ObViewServices extends DeesElement {
currentServiceStats: null, currentServiceStats: null,
platformServices: [], platformServices: [],
currentPlatformService: null, currentPlatformService: null,
currentPlatformServiceStats: null,
currentPlatformServiceLogs: [],
}; };
@state() @state()
@@ -124,6 +136,18 @@ export class ObViewServices extends DeesElement {
@state() @state()
accessor selectedPlatformType: string = ''; accessor selectedPlatformType: string = '';
@state()
accessor workspaceOpen: boolean = false;
@state()
accessor pendingTemplate: any = null;
@state()
accessor appStoreState: appstate.IAppStoreState = {
apps: [],
upgradeableServices: [],
};
constructor() { constructor() {
super(); super();
@@ -140,12 +164,61 @@ export class ObViewServices extends DeesElement {
this.backupsState = newState; this.backupsState = newState;
}); });
this.rxSubscriptions.push(backupsSub); this.rxSubscriptions.push(backupsSub);
const appStoreSub = appstate.appStoreStatePart
.select((s) => s)
.subscribe((newState) => {
this.appStoreState = newState;
});
this.rxSubscriptions.push(appStoreSub);
} }
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css``, css`
.page-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.deploy-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 200ms ease;
}
.deploy-button:hover {
opacity: 0.9;
}
.deploy-button svg {
width: 16px;
height: 16px;
}
:host(.workspace-mode) {
max-width: none;
padding: 0;
height: 100%;
display: flex;
flex-direction: column;
}
:host(.workspace-mode) ob-sectionheading {
display: none;
}
`,
]; ];
async connectedCallback() { async connectedCallback() {
@@ -153,7 +226,20 @@ export class ObViewServices extends DeesElement {
await Promise.all([ await Promise.all([
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null), appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null), appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableServicesAction, null),
]); ]);
// If a platform service was selected from the dashboard, navigate to its detail
const state = appstate.servicesStatePart.getState();
if (state.currentPlatformService) {
const type = state.currentPlatformService.type;
appstate.servicesStatePart.setState({
...appstate.servicesStatePart.getState(),
currentPlatformService: null,
});
this.navigateToPlatformDetail(type);
}
} }
public render(): TemplateResult { public render(): TemplateResult {
@@ -178,8 +264,41 @@ export class ObViewServices extends DeesElement {
domain: s.domain || null, domain: s.domain || null,
status: mapStatus(s.status), status: mapStatus(s.status),
})); }));
const displayStatus = (status: string) => {
switch (status) {
case 'running': return 'Running';
case 'stopped': return 'Stopped';
case 'starting': return 'Starting...';
case 'stopping': return 'Stopping...';
case 'failed': return 'Failed';
case 'not-deployed': return 'Not Deployed';
default: return status;
}
};
// Split platform services into active (running or core) and inactive (not in use)
const activePlatformServices = this.servicesState.platformServices.filter(
(ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore,
);
const inactivePlatformServices = this.servicesState.platformServices.filter(
(ps) => !ps.isCore && (ps.status === 'not-deployed' || ps.status === 'stopped' || ps.status === 'failed'),
);
const mappedActivePlatformServices = activePlatformServices.map((ps) => ({
name: ps.displayName,
status: displayStatus(ps.status),
running: ps.status === 'running',
type: ps.type,
}));
return html` return html`
<ob-sectionheading>Services</ob-sectionheading> <ob-sectionheading>Services</ob-sectionheading>
<div class="page-actions">
<button class="deploy-button" @click=${() => { this.currentView = 'create'; }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Deploy Service
</button>
</div>
<sz-services-list-view <sz-services-list-view
.services=${mappedServices} .services=${mappedServices}
@service-click=${(e: CustomEvent) => { @service-click=${(e: CustomEvent) => {
@@ -197,17 +316,139 @@ export class ObViewServices extends DeesElement {
}} }}
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)} @service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
></sz-services-list-view> ></sz-services-list-view>
<ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading>
<div style="max-width: 500px;">
${mappedActivePlatformServices.length > 0 ? html`
<sz-platform-services-card
.services=${mappedActivePlatformServices}
@service-click=${(e: CustomEvent) => {
const type = e.detail.type || this.servicesState.platformServices.find(
(ps) => ps.displayName === e.detail.name,
)?.type;
if (type) {
this.navigateToPlatformDetail(type);
}
}}
></sz-platform-services-card>
` : ''}
${inactivePlatformServices.length > 0 ? html`
<div style="
background: var(--ci-shade-1, #09090b);
border: 1px solid var(--ci-shade-2, #27272a);
border-radius: 8px;
padding: 20px;
margin-top: ${mappedActivePlatformServices.length > 0 ? '12px' : '0'};
opacity: 0.5;
">
<div style="font-size: 13px; color: var(--ci-shade-4, #71717a); margin-bottom: 12px;">Available — not in use</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
${inactivePlatformServices.map((ps) => html`
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; cursor: pointer; transition: opacity 200ms ease;"
@click=${() => this.navigateToPlatformDetail(ps.type)}
>
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 8px; height: 8px; border-radius: 50%; background: var(--ci-shade-3, #3f3f46); flex-shrink: 0;"></div>
<span style="font-size: 14px; font-weight: 500; color: var(--ci-shade-4, #71717a);">${ps.displayName}</span>
</div>
<span style="font-size: 13px; color: var(--ci-shade-3, #3f3f46);">${displayStatus(ps.status)}</span>
</div>
`)}
</div>
</div>
` : ''}
</div>
`; `;
} }
private async deployFromTemplate(template: any): Promise<void> {
const name = template.id || template.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const envVars: Record<string, string> = {};
if (template.envVars) {
for (const ev of template.envVars) {
if (ev.key && ev.value) envVars[ev.key] = ev.value;
}
}
const serviceConfig: interfaces.data.IServiceCreate = {
name,
image: template.image,
port: template.port || 80,
envVars,
enableMongoDB: template.enableMongoDB || false,
enableS3: template.enableS3 || false,
enableClickHouse: template.enableClickHouse || false,
enableRedis: template.enableRedis || false,
enableMariaDB: template.enableMariaDB || false,
};
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
config: serviceConfig,
});
this.pendingTemplate = null;
this.currentView = 'list';
}
private renderCreateView(): TemplateResult { private renderCreateView(): TemplateResult {
// If we have a pending app template from the App Store, show a quick-deploy confirmation
if (this.pendingTemplate) {
const t = this.pendingTemplate;
return html`
<ob-sectionheading>Deploy ${t.name}</ob-sectionheading>
<div style="max-width: 600px; margin: 0 auto;">
<div style="background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 8px; padding: 24px; margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; font-size: 18px;">${t.name}</h3>
<p style="margin: 0 0 16px 0; color: var(--ci-shade-5, #a1a1aa); font-size: 14px;">${t.description}</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font-size: 13px;">
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Image:</span> <strong>${t.image}</strong></div>
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Port:</span> <strong>${t.port}</strong></div>
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Service Name:</span> <strong>${t.id}</strong></div>
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Category:</span> <strong>${t.category}</strong></div>
</div>
${t.enableMongoDB || t.enableS3 || t.enableClickHouse || t.enableRedis || t.enableMariaDB ? html`
<div style="margin-top: 12px; font-size: 13px; color: var(--ci-shade-5, #a1a1aa);">
Platform Services:
${t.enableMongoDB ? html`<span style="margin-right: 8px;">MongoDB</span>` : ''}
${t.enableS3 ? html`<span style="margin-right: 8px;">S3</span>` : ''}
${t.enableClickHouse ? html`<span style="margin-right: 8px;">ClickHouse</span>` : ''}
${t.enableRedis ? html`<span style="margin-right: 8px;">Redis</span>` : ''}
${t.enableMariaDB ? html`<span style="margin-right: 8px;">MariaDB</span>` : ''}
</div>
` : ''}
</div>
<div style="display: flex; gap: 12px; justify-content: flex-end;">
<button class="deploy-button" style="background: transparent; border: 1px solid var(--ci-shade-2, #27272a); color: inherit;" @click=${() => { this.pendingTemplate = null; this.currentView = 'list'; }}>Cancel</button>
<button class="deploy-button" @click=${() => this.deployFromTemplate(t)}>Deploy ${t.name}</button>
</div>
</div>
`;
}
return html` return html`
<ob-sectionheading>Create Service</ob-sectionheading> <ob-sectionheading>Create Service</ob-sectionheading>
<sz-service-create-view <sz-service-create-view
.registries=${[]} .registries=${[]}
@create-service=${async (e: CustomEvent) => { @create-service=${async (e: CustomEvent) => {
const formConfig = e.detail;
const serviceConfig: interfaces.data.IServiceCreate = {
name: formConfig.name,
image: formConfig.image,
port: formConfig.ports?.[0]?.containerPort
? parseInt(formConfig.ports[0].containerPort, 10)
: 80,
envVars: formConfig.envVars?.reduce(
(acc: Record<string, string>, ev: { key: string; value: string }) => {
if (ev.key) acc[ev.key] = ev.value;
return acc;
},
{} as Record<string, string>,
),
enableMongoDB: formConfig.enableMongoDB || false,
enableS3: formConfig.enableS3 || false,
enableClickHouse: formConfig.enableClickHouse || false,
enableRedis: formConfig.enableRedis || false,
enableMariaDB: formConfig.enableMariaDB || false,
};
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, { await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
config: e.detail, config: serviceConfig,
}); });
this.currentView = 'list'; this.currentView = 'list';
}} }}
@@ -226,8 +467,49 @@ export class ObViewServices extends DeesElement {
: defaultStats; : defaultStats;
const transformedLogs = parseLogs(this.servicesState.currentServiceLogs); const transformedLogs = parseLogs(this.servicesState.currentServiceLogs);
// Check if this service has an available upgrade
const upgradeInfo = service
? this.appStoreState.upgradeableServices.find((u) => u.serviceName === service.name)
: null;
return html` return html`
<ob-sectionheading>Service Details</ob-sectionheading> <ob-sectionheading>Service Details</ob-sectionheading>
${upgradeInfo ? html`
<div style="
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
">
<div>
<div style="font-size: 14px; font-weight: 600; color: var(--ci-shade-7, #e4e4e7);">
Update available: v${upgradeInfo.currentVersion} &rarr; v${upgradeInfo.latestVersion}
</div>
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 4px;">
${upgradeInfo.hasMigration ? 'Migration script available' : 'Config-only upgrade'}
</div>
</div>
<button
class="deploy-button"
style="padding: 8px 16px; font-size: 13px;"
@click=${async () => {
await appstate.appStoreStatePart.dispatchAction(appstate.upgradeServiceAction, {
serviceName: upgradeInfo.serviceName,
targetVersion: upgradeInfo.latestVersion,
});
// Refresh service data
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceAction, {
name: upgradeInfo.serviceName,
});
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null);
}}
>Upgrade</button>
</div>
` : ''}
<sz-service-detail-view <sz-service-detail-view
.service=${transformedService} .service=${transformedService}
.logs=${transformedLogs} .logs=${transformedLogs}
@@ -236,6 +518,28 @@ export class ObViewServices extends DeesElement {
this.currentView = 'list'; this.currentView = 'list';
}} }}
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)} @service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
@request-workspace=${async (e: CustomEvent) => {
const name = e.detail?.service?.name || this.selectedServiceName;
const identity = appstate.loginStatePart.getState().identity;
if (!name || !identity) return;
try {
const env = new BackendExecutionEnvironment(name, identity);
await env.init();
const detailView = this.shadowRoot?.querySelector('sz-service-detail-view') as any;
if (detailView) {
detailView.workspaceEnvironment = env;
}
this.workspaceOpen = true;
this.classList.add('workspace-mode');
} catch (err) {
console.error('Failed to open workspace:', err);
}
}}
@back=${() => {
this.workspaceOpen = false;
this.classList.remove('workspace-mode');
this.currentView = 'list';
}}
></sz-service-detail-view> ></sz-service-detail-view>
`; `;
} }
@@ -265,34 +569,119 @@ export class ObViewServices extends DeesElement {
`; `;
} }
private navigateToPlatformDetail(type: string): void {
// Reset to list first to force fresh DOM for dees-chart-log
this.currentView = 'list';
this.selectedPlatformType = type;
// Clear previous stats/logs before fetching new ones
appstate.servicesStatePart.setState({
...appstate.servicesStatePart.getState(),
currentPlatformServiceStats: null,
currentPlatformServiceLogs: [],
});
// Fetch stats and logs for this platform service
const serviceType = type as interfaces.data.TPlatformServiceType;
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, { serviceType });
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceLogsAction, { serviceType });
// Switch to detail view on next microtask (ensures fresh DOM)
requestAnimationFrame(() => {
this.currentView = 'platform-detail';
});
}
private renderPlatformDetailView(): TemplateResult { private renderPlatformDetailView(): TemplateResult {
const platformService = this.servicesState.platformServices.find( const platformService = this.servicesState.platformServices.find(
(ps) => ps.type === this.selectedPlatformType, (ps) => ps.type === this.selectedPlatformType,
); );
const stats = this.servicesState.currentPlatformServiceStats;
const metrics = {
cpu: stats ? Math.round(stats.cpuPercent) : 0,
memory: stats ? Math.round(stats.memoryPercent) : 0,
storage: 0,
connections: undefined as number | undefined,
};
// Real service info per platform type
const serviceInfo: Record<string, { host: string; port: number; version: string; config: Record<string, any> }> = {
mongodb: { host: 'onebox-mongodb', port: 27017, version: '4.4', config: { engine: 'WiredTiger', authEnabled: true } },
minio: { host: 'onebox-minio', port: 9000, version: 'latest', config: { consolePort: 9001, region: 'us-east-1' } },
clickhouse: { host: 'onebox-clickhouse', port: 8123, version: 'latest', config: { nativePort: 9000, httpPort: 8123 } },
caddy: { host: 'onebox-caddy', port: 80, version: '2-alpine', config: { httpsPort: 443, adminApi: 2019 } },
mariadb: { host: 'onebox-mariadb', port: 3306, version: '11', config: { engine: 'InnoDB', authEnabled: true } },
redis: { host: 'onebox-redis', port: 6379, version: '7-alpine', config: { appendonly: true, maxDatabases: 16 } },
};
const info = platformService
? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} }
: { host: '', port: 0, version: '', config: {} };
// Map backend status to catalog-compatible status
const mapPlatformStatus = (status: string): 'running' | 'stopped' | 'error' => {
switch (status) {
case 'running': return 'running';
case 'failed': return 'error';
case 'starting':
case 'stopping':
case 'stopped':
case 'not-deployed':
default: return 'stopped';
}
};
return html` return html`
<ob-sectionheading>Platform Service</ob-sectionheading> <ob-sectionheading>Platform Service</ob-sectionheading>
<div class="page-actions" style="justify-content: flex-start;">
<button class="deploy-button" style="background: transparent; border: 1px solid var(--ci-shade-2, #27272a); color: inherit;" @click=${() => { this.currentView = 'list'; }}>
&larr; Back to Services
</button>
</div>
<sz-platform-service-detail-view <sz-platform-service-detail-view
.service=${platformService .service=${platformService
? { ? {
id: platformService.type, id: platformService.type,
name: platformService.displayName, name: platformService.displayName,
type: platformService.type, type: platformService.type,
status: platformService.status, status: mapPlatformStatus(platformService.status),
version: '', version: info.version,
host: 'localhost', host: info.host,
port: 0, port: info.port,
config: {}, config: info.config,
metrics,
} }
: null} : null}
.logs=${[]} .logs=${this.servicesState.currentPlatformServiceLogs.map((log) => ({
@start=${() => { timestamp: new Date(log.timestamp).toISOString(),
appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, { level: log.level,
serviceType: this.selectedPlatformType as any, message: log.message,
}))}
@back=${() => {
this.currentView = 'list';
}}
@start=${async () => {
await appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, {
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
});
// Refresh stats after starting
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, {
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
}); });
}} }}
@stop=${() => { @stop=${async () => {
appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, { await appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, {
serviceType: this.selectedPlatformType as any, serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
});
}}
@restart=${async () => {
await appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, {
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
});
await appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, {
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
});
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, {
serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType,
}); });
}} }}
></sz-platform-service-detail-view> ></sz-platform-service-detail-view>

View File

@@ -0,0 +1,155 @@
/**
* BackendExecutionEnvironment — implements IExecutionEnvironment
* by routing all filesystem and process operations through the onebox API
* to Docker exec on the target container.
*/
import * as plugins from '../plugins.js';
import * as interfaces from '../../ts_interfaces/index.js';
// Import IExecutionEnvironment type from dees-catalog
type IExecutionEnvironment = import('@design.estate/dees-catalog').IExecutionEnvironment;
type IFileEntry = import('@design.estate/dees-catalog').IFileEntry;
type IFileWatcher = import('@design.estate/dees-catalog').IFileWatcher;
type IProcessHandle = import('@design.estate/dees-catalog').IProcessHandle;
const domtools = plugins.deesElement.domtools;
export class BackendExecutionEnvironment implements IExecutionEnvironment {
readonly type = 'backend' as const;
private _ready = false;
private identity: interfaces.data.IIdentity;
constructor(
private serviceName: string,
identity: interfaces.data.IIdentity,
) {
this.identity = identity;
}
get ready(): boolean {
return this._ready;
}
async init(): Promise<void> {
// Verify the container is accessible by checking if root exists
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceExists>(
'workspaceExists',
{ path: '/' },
);
if (!result.exists) {
throw new Error(`Cannot access container filesystem for service: ${this.serviceName}`);
}
this._ready = true;
}
async destroy(): Promise<void> {
this._ready = false;
}
async readFile(path: string): Promise<string> {
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceReadFile>(
'workspaceReadFile',
{ path },
);
return result.content;
}
async writeFile(path: string, contents: string): Promise<void> {
await this.fireRequest<interfaces.requests.IReq_WorkspaceWriteFile>(
'workspaceWriteFile',
{ path, content: contents },
);
}
async readDir(path: string): Promise<IFileEntry[]> {
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceReadDir>(
'workspaceReadDir',
{ path },
);
return result.entries;
}
async mkdir(path: string): Promise<void> {
await this.fireRequest<interfaces.requests.IReq_WorkspaceMkdir>(
'workspaceMkdir',
{ path },
);
}
async rm(path: string, options?: { recursive?: boolean }): Promise<void> {
await this.fireRequest<interfaces.requests.IReq_WorkspaceRm>(
'workspaceRm',
{ path, recursive: options?.recursive },
);
}
async exists(path: string): Promise<boolean> {
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceExists>(
'workspaceExists',
{ path },
);
return result.exists;
}
watch(
_path: string,
_callback: (event: 'rename' | 'change', filename: string | null) => void,
_options?: { recursive?: boolean },
): IFileWatcher {
// Polling-based file watching — check for changes periodically
// For now, return a no-op watcher. Full implementation would poll readDir.
return { stop: () => {} };
}
async spawn(command: string, args?: string[]): Promise<IProcessHandle> {
// For interactive shell: execute the command via the workspace exec API
// and return a process handle that bridges stdin/stdout
const cmd = args ? [command, ...args] : [command];
const fullCommand = cmd.join(' ');
// Use a non-interactive exec for now — full interactive shell would need
// TypedSocket bidirectional streaming (to be implemented)
const result = await this.fireRequest<interfaces.requests.IReq_WorkspaceExec>(
'workspaceExec',
{ command: cmd[0], args: cmd.slice(1) },
);
// Create a ReadableStream from the exec output
const output = new ReadableStream<string>({
start(controller) {
if (result.stdout) controller.enqueue(result.stdout);
if (result.stderr) controller.enqueue(result.stderr);
controller.close();
},
});
// Create a writable stream (no-op for non-interactive)
const inputStream = new WritableStream<string>();
return {
output,
input: inputStream,
exit: Promise.resolve(result.exitCode),
kill: () => {},
};
}
/**
* Helper to fire TypedRequests to the workspace API
*/
private async fireRequest<T extends { method: string; request: any; response: any }>(
method: string,
data: Omit<T['request'], 'identity' | 'serviceName'>,
): Promise<T['response']> {
const typedRequest = new domtools.plugins.typedrequest.TypedRequest<T>(
'/typedrequest',
method,
);
return await typedRequest.fire({
identity: this.identity,
serviceName: this.serviceName,
...data,
} as T['request']);
}
}

View File

@@ -1,6 +1,10 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { html } from '@design.estate/dees-element'; import { html } from '@design.estate/dees-element';
import './elements/index.js'; import './elements/index.js';
import { appRouter } from './router.js';
// Initialize router before rendering (handles initial URL → state)
appRouter.init();
plugins.deesElement.render(html` plugins.deesElement.render(html`
<ob-app-shell></ob-app-shell> <ob-app-shell></ob-app-shell>

View File

@@ -5,9 +5,13 @@ import * as deesCatalog from '@design.estate/dees-catalog';
// @serve.zone scope — side-effect import registers all sz-* custom elements // @serve.zone scope — side-effect import registers all sz-* custom elements
import '@serve.zone/catalog'; import '@serve.zone/catalog';
// TypedSocket for real-time server push (logs, events)
import * as typedsocket from '@api.global/typedsocket';
export { export {
deesElement, deesElement,
deesCatalog, deesCatalog,
typedsocket,
}; };
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities // domtools gives us TypedRequest, smartstate, smartrouter, and other utilities

110
ts_web/router.ts Normal file
View File

@@ -0,0 +1,110 @@
import * as plugins from './plugins.js';
import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = [
'dashboard', 'app-store', 'services', 'network',
'registries', 'tokens', 'settings',
] as const;
export type TValidView = typeof validViews[number];
class AppRouter {
private router: InstanceType<typeof SmartRouter>;
private initialized = false;
private suppressStateUpdate = false;
constructor() {
this.router = new SmartRouter({ debug: false });
}
public init(): void {
if (this.initialized) return;
this.setupRoutes();
this.setupStateSync();
this.handleInitialRoute();
this.initialized = true;
}
private setupRoutes(): void {
for (const view of validViews) {
this.router.on(`/${view}`, async () => {
this.updateViewState(view);
});
}
// Root redirect
this.router.on('/', async () => {
this.navigateTo('/dashboard');
});
}
private setupStateSync(): void {
appstate.uiStatePart.select((s) => s.activeView).subscribe((activeView) => {
if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname;
const expectedPath = `/${activeView}`;
if (currentPath !== expectedPath) {
this.suppressStateUpdate = true;
this.router.pushUrl(expectedPath);
this.suppressStateUpdate = false;
}
});
}
private handleInitialRoute(): void {
const path = window.location.pathname;
if (!path || path === '/') {
this.router.pushUrl('/dashboard');
} else {
const segments = path.split('/').filter(Boolean);
const view = segments[0];
if (validViews.includes(view as TValidView)) {
this.updateViewState(view as TValidView);
} else {
this.router.pushUrl('/dashboard');
}
}
}
private updateViewState(view: string): void {
this.suppressStateUpdate = true;
const currentState = appstate.uiStatePart.getState();
if (currentState.activeView !== view) {
appstate.uiStatePart.setState({
...currentState,
activeView: view,
});
}
this.suppressStateUpdate = false;
}
public navigateTo(path: string): void {
this.router.pushUrl(path);
}
public navigateToView(view: string): void {
const normalized = view.toLowerCase().replace(/\s+/g, '-');
if (validViews.includes(normalized as TValidView)) {
this.navigateTo(`/${normalized}`);
} else {
this.navigateTo('/dashboard');
}
}
public getCurrentView(): string {
return appstate.uiStatePart.getState().activeView;
}
public destroy(): void {
this.router.destroy();
this.initialized = false;
}
}
export const appRouter = new AppRouter();