Compare commits

...

10 Commits

Author SHA1 Message Date
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
42 changed files with 3232 additions and 407 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 --ignore-scripts
- 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 --ignore-scripts
- 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

@@ -1,5 +1,39 @@
# Changelog # Changelog
## 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
## 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) ## 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 stream user service logs to the ops dashboard and resolve service containers for Docker log streaming

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.20.0", "version": "1.23.0",
"exports": "./mod.ts", "exports": "./mod.ts",
"tasks": { "tasks": {
"test": "deno test --allow-all test/", "test": "deno test --allow-all test/",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.20.0", "version": "1.23.0",
"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",
@@ -58,7 +58,7 @@
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@design.estate/dees-catalog": "^3.43.3", "@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.1.6",
"@serve.zone/catalog": "^2.7.0" "@serve.zone/catalog": "^2.9.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.9.0", "@git.zone/tsbundle": "^2.9.0",

226
pnpm-lock.yaml generated
View File

@@ -21,8 +21,8 @@ importers:
specifier: ^2.1.6 specifier: ^2.1.6
version: 2.2.3 version: 2.2.3
'@serve.zone/catalog': '@serve.zone/catalog':
specifier: ^2.7.0 specifier: ^2.9.0
version: 2.7.0(@tiptap/pm@2.27.2) version: 2.9.0(@tiptap/pm@2.27.2)
devDependencies: devDependencies:
'@git.zone/tsbundle': '@git.zone/tsbundle':
specifier: ^2.9.0 specifier: ^2.9.0
@@ -63,8 +63,8 @@ packages:
'@cfworker/json-schema@4.1.1': '@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
'@cloudflare/workers-types@4.20260313.1': '@cloudflare/workers-types@4.20260317.1':
resolution: {integrity: sha512-jMEeX3RKfOSVqqXRKr/ulgglcTloeMzSH3FdzIfqJHtvc12/ELKd5Ldsg8ZHahKX/4eRxYdw3kbzb8jLXbq/jQ==} resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==}
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
@@ -376,74 +376,74 @@ packages:
'@module-federation/webpack-bundler-runtime@0.22.0': '@module-federation/webpack-bundler-runtime@0.22.0':
resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==} resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==}
'@napi-rs/canvas-android-arm64@0.1.96': '@napi-rs/canvas-android-arm64@0.1.97':
resolution: {integrity: sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==} resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.96': '@napi-rs/canvas-darwin-arm64@0.1.97':
resolution: {integrity: sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==} resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.96': '@napi-rs/canvas-darwin-x64@0.1.97':
resolution: {integrity: sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==} resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.96': '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
resolution: {integrity: sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==} resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.96': '@napi-rs/canvas-linux-arm64-gnu@0.1.97':
resolution: {integrity: sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==} resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@napi-rs/canvas-linux-arm64-musl@0.1.96': '@napi-rs/canvas-linux-arm64-musl@0.1.97':
resolution: {integrity: sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==} resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.96': '@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
resolution: {integrity: sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==} resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@napi-rs/canvas-linux-x64-gnu@0.1.96': '@napi-rs/canvas-linux-x64-gnu@0.1.97':
resolution: {integrity: sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==} resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@napi-rs/canvas-linux-x64-musl@0.1.96': '@napi-rs/canvas-linux-x64-musl@0.1.97':
resolution: {integrity: sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==} resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@napi-rs/canvas-win32-arm64-msvc@0.1.96': '@napi-rs/canvas-win32-arm64-msvc@0.1.97':
resolution: {integrity: sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==} resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.96': '@napi-rs/canvas-win32-x64-msvc@0.1.97':
resolution: {integrity: sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==} resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@napi-rs/canvas@0.1.96': '@napi-rs/canvas@0.1.97':
resolution: {integrity: sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==} resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.0.7': '@napi-rs/wasm-runtime@1.0.7':
@@ -772,60 +772,60 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.52': '@rolldown/pluginutils@1.0.0-beta.52':
resolution: {integrity: sha512-/L0htLJZbaZFL1g9OHOblTxbCYIGefErJjtYOwgl9ZqNx27P3L0SDfjhhHIss32gu5NWgnxuT2a2Hnnv6QGHKA==} resolution: {integrity: sha512-/L0htLJZbaZFL1g9OHOblTxbCYIGefErJjtYOwgl9ZqNx27P3L0SDfjhhHIss32gu5NWgnxuT2a2Hnnv6QGHKA==}
'@rspack/binding-darwin-arm64@1.7.8': '@rspack/binding-darwin-arm64@1.7.9':
resolution: {integrity: sha512-KS6SRc+4VYRdX1cKr1j1HEuMNyEzt7onBS0rkenaiCRRYF0z4WNZNyZqRiuxgM3qZ3TISF7gdmgJQyd4ZB43ig==} resolution: {integrity: sha512-64dgstte0If5czi9bA/cpOe0ryY6wC9AIQRtyJ3DlOF6Tt+y9cKkmUoGu3V+WYaYIZRT7HNk8V7kL8amVjFTYw==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rspack/binding-darwin-x64@1.7.8': '@rspack/binding-darwin-x64@1.7.9':
resolution: {integrity: sha512-uyXSDKLg2CtqIJrsJDlCqQH80YIPsCUiTToJ59cXAG3v4eke0Qbiv6d/+pV0h/mc0u4inAaSkr5dD18zkMIghw==} resolution: {integrity: sha512-2QSLs3w4rLy4UUGVnIlkt6IlIKOzR1e0RPsq2FYQW6s3p9JrwRCtOeHohyh7EJSqF54dtfhe9UZSAwba3LqH1Q==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rspack/binding-linux-arm64-gnu@1.7.8': '@rspack/binding-linux-arm64-gnu@1.7.9':
resolution: {integrity: sha512-dD6gSHA18Uj0eqc1FCwwQ5IO5mIckrpYN4H4kPk9Pjau+1mxWvC4y5Lryz1Z8P/Rh1lnQ/wwGE0XL9nd80+LqQ==} resolution: {integrity: sha512-qhUGI/uVfvLmKWts4QkVHGL8yfUyJkblZs+OFD5Upa2y676EOsbQgWsCwX4xGB6Tv+TOzFP0SLh/UfO8ZfdE+w==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rspack/binding-linux-arm64-musl@1.7.8': '@rspack/binding-linux-arm64-musl@1.7.9':
resolution: {integrity: sha512-m+uBi9mEVGkZ02PPOAYN2BSmmvc00XGa6v9CjV8qLpolpUXQIMzDNG+i1fD5SHp8LO+XWsZJOHypMsT0MzGTGw==} resolution: {integrity: sha512-VjfmR1hgO9n3L6MaE5KG+DXSrrLVqHHOkVcOtS2LMq3bjMTwbBywY7ycymcLnX5KJsol8d3ZGYep6IfSOt3lFA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rspack/binding-linux-x64-gnu@1.7.8': '@rspack/binding-linux-x64-gnu@1.7.9':
resolution: {integrity: sha512-IAPp2L3yS33MAEkcGn/I1gO+a+WExJHXz2ZlRlL2oFCUGpYi2ZQHyAcJ3o2tJqkXmdqsTiN+OjEVMd/RcLa24g==} resolution: {integrity: sha512-0kldV+3WTs/VYDWzxJ7K40hCW26IHtnk8xPK3whKoo1649rgeXXa0EdsU5P7hG8Ef5SWQjHHHZ/fuHYSO3Y6HA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rspack/binding-linux-x64-musl@1.7.8': '@rspack/binding-linux-x64-musl@1.7.9':
resolution: {integrity: sha512-do/QNzb4GWdXCsipblDcroqRDR3BFcbyzpZpAw/3j9ajvEqsOKpdHZpILT2NZX/VahhjqfqB3k0kJVt3uK7UYQ==} resolution: {integrity: sha512-Gi4872cFtc2d83FKATR6Qcf2VBa/tFCqffI/IwRRl6Hx5FulEBqx+tH7gAuRVF693vrbXNxK+FQ+k4iEsEJxrw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rspack/binding-wasm32-wasi@1.7.8': '@rspack/binding-wasm32-wasi@1.7.9':
resolution: {integrity: sha512-mHtgYTpdhx01i0XNKFYBZyCjtv9YUe/sDfpD1QK4FytPFB+1VpYnmZiaJIMM77VpNsjxGAqWhmUYxi2P6jWifw==} resolution: {integrity: sha512-5QEzqo6EaolpuZmK6w/mgSueorgGnnzp7dJaAvBj6ECFIg/aLXhXXmWCWbxt7Ws2gKvG5/PgaxDqbUxYL51juA==}
cpu: [wasm32] cpu: [wasm32]
'@rspack/binding-win32-arm64-msvc@1.7.8': '@rspack/binding-win32-arm64-msvc@1.7.9':
resolution: {integrity: sha512-Mkxg86F7kIT4pM9XvE/1LAGjK5NOQi/GJxKyyiKbUAeKM8XBUizVeNuvKR0avf2V5IDAIRXiH1SX8SpujMJteA==} resolution: {integrity: sha512-MMqvcrIc8aOqTuHjWkjdzilvoZ3Hv07Od0Foogiyq3JMudsS3Wcmh7T1dFerGg19MOJcRUeEkrg2NQOMOQ6xDA==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rspack/binding-win32-ia32-msvc@1.7.8': '@rspack/binding-win32-ia32-msvc@1.7.9':
resolution: {integrity: sha512-VmTOZ/X7M85lKFNwb2qJpCRzr4SgO42vucq/X7Uz1oSoTPAf8UUMNdi7BPnu+D4lgy6l8PwV804ZyHO3gGsvPA==} resolution: {integrity: sha512-4kYYS+NZ2CuNbKjq40yB/UEyB51o1PHj5wpr+Y943oOJXpEKWU2Q4vkF8VEohPEcnA9cKVotYCnqStme+02suA==}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@rspack/binding-win32-x64-msvc@1.7.8': '@rspack/binding-win32-x64-msvc@1.7.9':
resolution: {integrity: sha512-BK0I4HAwp/yQLnmdJpUtGHcht3x11e9fZwyaiMzznznFc+Oypbf+FS5h+aBgpb53QnNkPpdG7MfAPoKItOcU8A==} resolution: {integrity: sha512-1g+QyXXvs+838Un/4GaUvJfARDGHMCs15eXDYWBl5m/Skubyng8djWAgr6ag1+cVoJZXCPOvybTItcblWF3gbQ==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rspack/binding@1.7.8': '@rspack/binding@1.7.9':
resolution: {integrity: sha512-P4fbrQx5hRhAiC8TBTEMCTnNawrIzJLjWwAgrTwRxjgenpjNvimEkQBtSGrXOY+c+MV5Q74P+9wPvVWLKzRkQQ==} resolution: {integrity: sha512-A56e0NdfNwbOSJoilMkxzaPuVYaKCNn1shuiwWnCIBmhV9ix1n9S1XvquDjkGyv+gCdR1+zfJBOa5DMB7htLHw==}
'@rspack/core@1.7.8': '@rspack/core@1.7.9':
resolution: {integrity: sha512-kT6yYo8xjKoDfM7iB8N9AmN9DJIlrs7UmQDbpTu1N4zaZocN1/t2fIAWOKjr5+3eJlZQR2twKZhDVHNLbLPjOw==} resolution: {integrity: sha512-VHuSKvRkuv42Ya+TxEGO0LE0r9+8P4tKGokmomj4R1f/Nu2vtS3yoaIMfC4fR6VuHGd3MZ+KTI0cNNwHfFcskw==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
peerDependencies: peerDependencies:
'@swc/helpers': '>=0.5.1' '@swc/helpers': '>=0.5.1'
@@ -839,8 +839,8 @@ packages:
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@serve.zone/catalog@2.7.0': '@serve.zone/catalog@2.9.0':
resolution: {integrity: sha512-BSfLi9BZE5wvu5Dxh0p/mM9bE+9lf35PGHRZ1Cw/+YpWxOfIFPTZKkBz2OUn3yctWw+V7l1VBBYuLX1bVCKFfA==} resolution: {integrity: sha512-7FgwS44pD/DFVj29jS0Kwwyn1i5h8cf4/yWMBEY8+8GO70ab3QctbcKMu+BVa1G3gIrpLqhpmxLFDoeL/zDnQA==}
'@tempfix/idb@8.0.3': '@tempfix/idb@8.0.3':
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==} resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
@@ -1365,15 +1365,15 @@ packages:
fast-json-stable-stringify@2.1.0: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-xml-builder@1.1.3: fast-xml-builder@1.1.4:
resolution: {integrity: sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==} resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
fast-xml-parser@4.5.4: fast-xml-parser@4.5.4:
resolution: {integrity: sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==} resolution: {integrity: sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==}
hasBin: true hasBin: true
fast-xml-parser@5.5.5: fast-xml-parser@5.5.6:
resolution: {integrity: sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==} resolution: {integrity: sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==}
hasBin: true hasBin: true
fault@2.0.1: fault@2.0.1:
@@ -2340,7 +2340,7 @@ snapshots:
'@api.global/typedrequest': 3.3.0 '@api.global/typedrequest': 3.3.0
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.1) '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.1)
'@cloudflare/workers-types': 4.20260313.1 '@cloudflare/workers-types': 4.20260317.1
'@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.3.1 '@push.rocks/lik': 6.3.1
@@ -2400,7 +2400,7 @@ snapshots:
'@cfworker/json-schema@4.1.1': {} '@cfworker/json-schema@4.1.1': {}
'@cloudflare/workers-types@4.20260313.1': {} '@cloudflare/workers-types@4.20260317.1': {}
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
dependencies: dependencies:
@@ -2623,7 +2623,7 @@ snapshots:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartspawn': 3.0.3 '@push.rocks/smartspawn': 3.0.3
'@rspack/core': 1.7.8 '@rspack/core': 1.7.9
'@types/html-minifier': 4.0.6 '@types/html-minifier': 4.0.6
esbuild: 0.27.4 esbuild: 0.27.4
html-minifier: 4.0.0 html-minifier: 4.0.0
@@ -2817,52 +2817,52 @@ snapshots:
'@module-federation/runtime': 0.22.0 '@module-federation/runtime': 0.22.0
'@module-federation/sdk': 0.22.0 '@module-federation/sdk': 0.22.0
'@napi-rs/canvas-android-arm64@0.1.96': '@napi-rs/canvas-android-arm64@0.1.97':
optional: true optional: true
'@napi-rs/canvas-darwin-arm64@0.1.96': '@napi-rs/canvas-darwin-arm64@0.1.97':
optional: true optional: true
'@napi-rs/canvas-darwin-x64@0.1.96': '@napi-rs/canvas-darwin-x64@0.1.97':
optional: true optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.96': '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
optional: true optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.96': '@napi-rs/canvas-linux-arm64-gnu@0.1.97':
optional: true optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.96': '@napi-rs/canvas-linux-arm64-musl@0.1.97':
optional: true optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.96': '@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
optional: true optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.96': '@napi-rs/canvas-linux-x64-gnu@0.1.97':
optional: true optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.96': '@napi-rs/canvas-linux-x64-musl@0.1.97':
optional: true optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.96': '@napi-rs/canvas-win32-arm64-msvc@0.1.97':
optional: true optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.96': '@napi-rs/canvas-win32-x64-msvc@0.1.97':
optional: true optional: true
'@napi-rs/canvas@0.1.96': '@napi-rs/canvas@0.1.97':
optionalDependencies: optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.96 '@napi-rs/canvas-android-arm64': 0.1.97
'@napi-rs/canvas-darwin-arm64': 0.1.96 '@napi-rs/canvas-darwin-arm64': 0.1.97
'@napi-rs/canvas-darwin-x64': 0.1.96 '@napi-rs/canvas-darwin-x64': 0.1.97
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.96 '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97
'@napi-rs/canvas-linux-arm64-gnu': 0.1.96 '@napi-rs/canvas-linux-arm64-gnu': 0.1.97
'@napi-rs/canvas-linux-arm64-musl': 0.1.96 '@napi-rs/canvas-linux-arm64-musl': 0.1.97
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.96 '@napi-rs/canvas-linux-riscv64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-gnu': 0.1.96 '@napi-rs/canvas-linux-x64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-musl': 0.1.96 '@napi-rs/canvas-linux-x64-musl': 0.1.97
'@napi-rs/canvas-win32-arm64-msvc': 0.1.96 '@napi-rs/canvas-win32-arm64-msvc': 0.1.97
'@napi-rs/canvas-win32-x64-msvc': 0.1.96 '@napi-rs/canvas-win32-x64-msvc': 0.1.97
optional: true optional: true
'@napi-rs/wasm-runtime@1.0.7': '@napi-rs/wasm-runtime@1.0.7':
@@ -3276,7 +3276,7 @@ snapshots:
'@push.rocks/smartxml@2.0.0': '@push.rocks/smartxml@2.0.0':
dependencies: dependencies:
fast-xml-parser: 5.5.5 fast-xml-parser: 5.5.6
'@push.rocks/smartyaml@2.0.5': '@push.rocks/smartyaml@2.0.5':
dependencies: dependencies:
@@ -3422,62 +3422,62 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.52': {} '@rolldown/pluginutils@1.0.0-beta.52': {}
'@rspack/binding-darwin-arm64@1.7.8': '@rspack/binding-darwin-arm64@1.7.9':
optional: true optional: true
'@rspack/binding-darwin-x64@1.7.8': '@rspack/binding-darwin-x64@1.7.9':
optional: true optional: true
'@rspack/binding-linux-arm64-gnu@1.7.8': '@rspack/binding-linux-arm64-gnu@1.7.9':
optional: true optional: true
'@rspack/binding-linux-arm64-musl@1.7.8': '@rspack/binding-linux-arm64-musl@1.7.9':
optional: true optional: true
'@rspack/binding-linux-x64-gnu@1.7.8': '@rspack/binding-linux-x64-gnu@1.7.9':
optional: true optional: true
'@rspack/binding-linux-x64-musl@1.7.8': '@rspack/binding-linux-x64-musl@1.7.9':
optional: true optional: true
'@rspack/binding-wasm32-wasi@1.7.8': '@rspack/binding-wasm32-wasi@1.7.9':
dependencies: dependencies:
'@napi-rs/wasm-runtime': 1.0.7 '@napi-rs/wasm-runtime': 1.0.7
optional: true optional: true
'@rspack/binding-win32-arm64-msvc@1.7.8': '@rspack/binding-win32-arm64-msvc@1.7.9':
optional: true optional: true
'@rspack/binding-win32-ia32-msvc@1.7.8': '@rspack/binding-win32-ia32-msvc@1.7.9':
optional: true optional: true
'@rspack/binding-win32-x64-msvc@1.7.8': '@rspack/binding-win32-x64-msvc@1.7.9':
optional: true optional: true
'@rspack/binding@1.7.8': '@rspack/binding@1.7.9':
optionalDependencies: optionalDependencies:
'@rspack/binding-darwin-arm64': 1.7.8 '@rspack/binding-darwin-arm64': 1.7.9
'@rspack/binding-darwin-x64': 1.7.8 '@rspack/binding-darwin-x64': 1.7.9
'@rspack/binding-linux-arm64-gnu': 1.7.8 '@rspack/binding-linux-arm64-gnu': 1.7.9
'@rspack/binding-linux-arm64-musl': 1.7.8 '@rspack/binding-linux-arm64-musl': 1.7.9
'@rspack/binding-linux-x64-gnu': 1.7.8 '@rspack/binding-linux-x64-gnu': 1.7.9
'@rspack/binding-linux-x64-musl': 1.7.8 '@rspack/binding-linux-x64-musl': 1.7.9
'@rspack/binding-wasm32-wasi': 1.7.8 '@rspack/binding-wasm32-wasi': 1.7.9
'@rspack/binding-win32-arm64-msvc': 1.7.8 '@rspack/binding-win32-arm64-msvc': 1.7.9
'@rspack/binding-win32-ia32-msvc': 1.7.8 '@rspack/binding-win32-ia32-msvc': 1.7.9
'@rspack/binding-win32-x64-msvc': 1.7.8 '@rspack/binding-win32-x64-msvc': 1.7.9
'@rspack/core@1.7.8': '@rspack/core@1.7.9':
dependencies: dependencies:
'@module-federation/runtime-tools': 0.22.0 '@module-federation/runtime-tools': 0.22.0
'@rspack/binding': 1.7.8 '@rspack/binding': 1.7.9
'@rspack/lite-tapable': 1.1.0 '@rspack/lite-tapable': 1.1.0
'@rspack/lite-tapable@1.1.0': {} '@rspack/lite-tapable@1.1.0': {}
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
'@serve.zone/catalog@2.7.0(@tiptap/pm@2.27.2)': '@serve.zone/catalog@2.9.0(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.1 '@design.estate/dees-domtools': 2.5.1
@@ -4007,7 +4007,7 @@ snapshots:
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-xml-builder@1.1.3: fast-xml-builder@1.1.4:
dependencies: dependencies:
path-expression-matcher: 1.1.3 path-expression-matcher: 1.1.3
@@ -4015,9 +4015,9 @@ snapshots:
dependencies: dependencies:
strnum: 1.1.2 strnum: 1.1.2
fast-xml-parser@5.5.5: fast-xml-parser@5.5.6:
dependencies: dependencies:
fast-xml-builder: 1.1.3 fast-xml-builder: 1.1.4
path-expression-matcher: 1.1.3 path-expression-matcher: 1.1.3
strnum: 2.2.0 strnum: 2.2.0
@@ -4739,7 +4739,7 @@ snapshots:
pdfjs-dist@4.10.38: pdfjs-dist@4.10.38:
optionalDependencies: optionalDependencies:
'@napi-rs/canvas': 0.1.96 '@napi-rs/canvas': 0.1.97
peek-readable@5.4.2: {} peek-readable@5.4.2: {}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', name: '@serve.zone/onebox',
version: '1.20.0', version: '1.23.0',
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();
}
}

View File

@@ -113,6 +113,12 @@ export class BackupManager {
case 'clickhouse': case 'clickhouse':
await this.exportClickHouseDatabase(dataDir, resource, credentials); await this.exportClickHouseDatabase(dataDir, resource, credentials);
break; break;
case 'mariadb':
await this.exportMariaDBDatabase(dataDir, resource, credentials);
break;
case 'redis':
await this.exportRedisData(dataDir, resource, credentials);
break;
} }
} }
} }
@@ -358,6 +364,8 @@ export class BackupManager {
enableMongoDB: serviceConfig.platformRequirements?.mongodb, enableMongoDB: serviceConfig.platformRequirements?.mongodb,
enableS3: serviceConfig.platformRequirements?.s3, enableS3: serviceConfig.platformRequirements?.s3,
enableClickHouse: serviceConfig.platformRequirements?.clickhouse, enableClickHouse: serviceConfig.platformRequirements?.clickhouse,
enableRedis: serviceConfig.platformRequirements?.redis,
enableMariaDB: serviceConfig.platformRequirements?.mariadb,
}; };
service = await this.oneboxRef.services.deployService(deployOptions); service = await this.oneboxRef.services.deployService(deployOptions);
@@ -789,6 +797,24 @@ export class BackupManager {
); );
restoredCount++; restoredCount++;
break; break;
case 'mariadb':
await this.importMariaDBDatabase(
dataDir,
existing.resource,
existing.credentials,
backupResource.resourceName
);
restoredCount++;
break;
case 'redis':
await this.importRedisData(
dataDir,
existing.resource,
existing.credentials,
backupResource.resourceName
);
restoredCount++;
break;
} }
} catch (error) { } catch (error) {
warnings.push( warnings.push(
@@ -1003,6 +1029,230 @@ export class BackupManager {
logger.success(`ClickHouse database imported: ${resource.resourceName}`); logger.success(`ClickHouse database imported: ${resource.resourceName}`);
} }
/**
* Export MariaDB database
*/
private async exportMariaDBDatabase(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>,
): Promise<void> {
logger.info(`Exporting MariaDB database: ${resource.resourceName}`);
const mariadbService = this.oneboxRef.database.getPlatformServiceByType('mariadb');
if (!mariadbService || !mariadbService.containerId) {
throw new Error('MariaDB service not running');
}
const dbName = credentials.database || resource.resourceName;
const user = credentials.username || 'root';
const password = credentials.password || '';
if (!dbName) {
throw new Error('MariaDB database name not found in credentials');
}
// Use mariadb-dump via docker exec
const result = await this.oneboxRef.docker.execInContainer(mariadbService.containerId, [
'mariadb-dump',
'-u', user,
`-p${password}`,
'--single-transaction',
'--routines',
'--triggers',
dbName,
]);
if (result.exitCode !== 0) {
throw new Error(`MariaDB dump failed: ${result.stderr.substring(0, 500)}`);
}
await Deno.writeTextFile(`${dataDir}/${resource.resourceName}.sql`, result.stdout);
logger.success(`MariaDB database exported: ${resource.resourceName}`);
}
/**
* Import MariaDB database
*/
private async importMariaDBDatabase(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>,
backupResourceName: string,
): Promise<void> {
logger.info(`Importing MariaDB database: ${resource.resourceName}`);
const mariadbService = this.oneboxRef.database.getPlatformServiceByType('mariadb');
if (!mariadbService || !mariadbService.containerId) {
throw new Error('MariaDB service not running');
}
const dbName = credentials.database || resource.resourceName;
const user = credentials.username || 'root';
const password = credentials.password || '';
if (!dbName) {
throw new Error('MariaDB database name not found');
}
// Read the SQL dump
const sqlPath = `${dataDir}/${backupResourceName}.sql`;
let sqlContent: string;
try {
sqlContent = await Deno.readTextFile(sqlPath);
} catch {
logger.warn(`MariaDB dump file not found: ${sqlPath}`);
return;
}
if (!sqlContent.trim()) {
logger.warn(`MariaDB dump file is empty: ${sqlPath}`);
return;
}
// Split into individual statements and execute
const statements = sqlContent
.split(';\n')
.map(s => s.trim())
.filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('/*'));
for (const statement of statements) {
const result = await this.oneboxRef.docker.execInContainer(mariadbService.containerId, [
'mariadb',
'-u', user,
`-p${password}`,
dbName,
'-e', statement + ';',
]);
if (result.exitCode !== 0) {
logger.warn(`MariaDB statement failed: ${result.stderr.substring(0, 200)}`);
}
}
logger.success(`MariaDB database imported: ${resource.resourceName}`);
}
/**
* Export Redis data
*/
private async exportRedisData(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>,
): Promise<void> {
logger.info(`Exporting Redis data: ${resource.resourceName}`);
const redisService = this.oneboxRef.database.getPlatformServiceByType('redis');
if (!redisService || !redisService.containerId) {
throw new Error('Redis service not running');
}
const password = credentials.password || '';
const dbIndex = credentials.db || '0';
// Trigger a BGSAVE to ensure data is flushed
await this.oneboxRef.docker.execInContainer(redisService.containerId, [
'redis-cli', '-a', password, 'BGSAVE',
]);
// Wait for save to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
// Get all keys in the specific database and export them
const keysResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
'redis-cli', '-a', password, '-n', dbIndex, 'KEYS', '*',
]);
if (keysResult.exitCode !== 0) {
throw new Error(`Redis KEYS failed: ${keysResult.stderr.substring(0, 200)}`);
}
const keys = keysResult.stdout.trim().split('\n').filter(k => k.length > 0);
const exportData: Record<string, { type: string; value: string; ttl: number }> = {};
for (const key of keys) {
// Get key type
const typeResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
'redis-cli', '-a', password, '-n', dbIndex, 'TYPE', key,
]);
const keyType = typeResult.stdout.trim();
// Get TTL
const ttlResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
'redis-cli', '-a', password, '-n', dbIndex, 'TTL', key,
]);
const ttl = parseInt(ttlResult.stdout.trim(), 10);
// DUMP the key (binary-safe serialization)
const dumpResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
'redis-cli', '-a', password, '-n', dbIndex, '--no-auth-warning', 'DUMP', key,
]);
exportData[key] = {
type: keyType,
value: dumpResult.stdout,
ttl: ttl > 0 ? ttl : 0,
};
}
await Deno.writeTextFile(
`${dataDir}/${resource.resourceName}.json`,
JSON.stringify({ dbIndex, keys: exportData }, null, 2)
);
logger.success(`Redis data exported: ${resource.resourceName} (${keys.length} keys)`);
}
/**
* Import Redis data
*/
private async importRedisData(
dataDir: string,
resource: IPlatformResource,
credentials: Record<string, string>,
backupResourceName: string,
): Promise<void> {
logger.info(`Importing Redis data: ${resource.resourceName}`);
const redisService = this.oneboxRef.database.getPlatformServiceByType('redis');
if (!redisService || !redisService.containerId) {
throw new Error('Redis service not running');
}
const password = credentials.password || '';
const dbIndex = credentials.db || '0';
// Read the export file
const jsonPath = `${dataDir}/${backupResourceName}.json`;
let exportContent: string;
try {
exportContent = await Deno.readTextFile(jsonPath);
} catch {
logger.warn(`Redis export file not found: ${jsonPath}`);
return;
}
const exportData = JSON.parse(exportContent);
const keys = exportData.keys || {};
let importedCount = 0;
for (const [key, data] of Object.entries(keys) as Array<[string, { type: string; value: string; ttl: number }]>) {
// Use RESTORE to import the serialized key
const args = ['redis-cli', '-a', password, '-n', dbIndex, 'RESTORE', key, String(data.ttl * 1000), data.value, 'REPLACE'];
const result = await this.oneboxRef.docker.execInContainer(redisService.containerId, args);
if (result.exitCode !== 0) {
logger.warn(`Redis RESTORE failed for key '${key}': ${result.stderr.substring(0, 200)}`);
} else {
importedCount++;
}
}
logger.success(`Redis data imported: ${resource.resourceName} (${importedCount} keys)`);
}
/** /**
* Create tar archive from directory * Create tar archive from directory
*/ */

View File

@@ -857,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}`);

View File

@@ -20,6 +20,7 @@ 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';
@@ -40,6 +41,7 @@ export class Onebox {
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;
@@ -74,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);
@@ -173,6 +178,14 @@ 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();

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

@@ -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

@@ -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

@@ -50,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;
@@ -76,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

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

@@ -19,6 +19,7 @@ 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 type { BaseMigration } from './base-migration.ts'; import type { BaseMigration } from './base-migration.ts';
export class MigrationRunner { export class MigrationRunner {
@@ -42,6 +43,7 @@ export class MigrationRunner {
new Migration010BackupSchedules(), new Migration010BackupSchedules(),
new Migration011ScopeColumns(), new Migration011ScopeColumns(),
new Migration012GfsRetention(), new Migration012GfsRetention(),
new Migration013AppTemplateVersion(),
].sort((a, b) => a.version - b.version); ].sort((a, b) => a.version - b.version);
} }

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

@@ -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

@@ -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

@@ -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

@@ -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

File diff suppressed because one or more lines are too long

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

@@ -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

@@ -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.20.0', version: '1.23.0',
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

@@ -54,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;
} }
// ============================================================================ // ============================================================================
@@ -136,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',
{ {
@@ -913,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 };
}, },
); );
@@ -1054,6 +1070,68 @@ async function disconnectSocket() {
} }
} }
// ============================================================================
// 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 // Connect socket when logged in, disconnect when logged out
loginStatePart.select((s) => s).subscribe((loginState) => { loginStatePart.select((s) => s).subscribe((loginState) => {
if (loginState.isLoggedIn) { if (loginState.isLoggedIn) {

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,
@@ -38,6 +39,7 @@ export class ObAppShell extends DeesElement {
private viewTabs = [ private viewTabs = [
{ name: 'Dashboard', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() }, { name: 'Dashboard', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() },
{ name: 'App Store', iconName: 'lucide:store', element: (async () => (await import('./ob-view-appstore.js')).ObViewAppStore)() },
{ name: 'Services', iconName: 'lucide:boxes', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() }, { name: 'Services', iconName: 'lucide:boxes', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() },
{ name: 'Network', iconName: 'lucide:network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() }, { name: 'Network', iconName: 'lucide:network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() },
{ name: 'Registries', iconName: 'lucide:package', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() }, { name: 'Registries', iconName: 'lucide:package', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() },
@@ -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>
@@ -121,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);
@@ -130,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);
} }
@@ -142,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);
@@ -200,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,
@@ -114,11 +115,13 @@ export class ObViewDashboard extends DeesElement {
networkOut: status?.docker?.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,
@@ -159,9 +162,9 @@ 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');
} }
} }
@@ -178,7 +181,7 @@ export class ObViewDashboard extends DeesElement {
...appstate.servicesStatePart.getState(), ...appstate.servicesStatePart.getState(),
currentPlatformService: ps, currentPlatformService: ps,
}); });
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' }); 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,
@@ -135,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();
@@ -151,6 +164,13 @@ 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 = [
@@ -186,6 +206,18 @@ export class ObViewServices extends DeesElement {
width: 16px; width: 16px;
height: 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;
}
`, `,
]; ];
@@ -194,19 +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 // If a platform service was selected from the dashboard, navigate to its detail
const state = appstate.servicesStatePart.getState(); const state = appstate.servicesStatePart.getState();
if (state.currentPlatformService) { if (state.currentPlatformService) {
const type = state.currentPlatformService.type; const type = state.currentPlatformService.type;
// Clear the selection so it doesn't persist on next visit
appstate.servicesStatePart.setState({ appstate.servicesStatePart.setState({
...appstate.servicesStatePart.getState(), ...appstate.servicesStatePart.getState(),
currentPlatformService: null, currentPlatformService: null,
}); });
this.navigateToPlatformDetail(type); this.navigateToPlatformDetail(type);
} }
} }
public render(): TemplateResult { public render(): TemplateResult {
@@ -242,7 +275,14 @@ export class ObViewServices extends DeesElement {
default: return status; default: return status;
} }
}; };
const mappedPlatformServices = this.servicesState.platformServices.map((ps) => ({ // 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, name: ps.displayName,
status: displayStatus(ps.status), status: displayStatus(ps.status),
running: ps.status === 'running', running: ps.status === 'running',
@@ -278,22 +318,110 @@ export class ObViewServices extends DeesElement {
></sz-services-list-view> ></sz-services-list-view>
<ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading> <ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading>
<div style="max-width: 500px;"> <div style="max-width: 500px;">
<sz-platform-services-card ${mappedActivePlatformServices.length > 0 ? html`
.services=${mappedPlatformServices} <sz-platform-services-card
@service-click=${(e: CustomEvent) => { .services=${mappedActivePlatformServices}
const type = e.detail.type || this.servicesState.platformServices.find( @service-click=${(e: CustomEvent) => {
(ps) => ps.displayName === e.detail.name, const type = e.detail.type || this.servicesState.platformServices.find(
)?.type; (ps) => ps.displayName === e.detail.name,
if (type) { )?.type;
this.navigateToPlatformDetail(type); if (type) {
} this.navigateToPlatformDetail(type);
}} }
></sz-platform-services-card> }}
></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> </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
@@ -316,6 +444,8 @@ export class ObViewServices extends DeesElement {
enableMongoDB: formConfig.enableMongoDB || false, enableMongoDB: formConfig.enableMongoDB || false,
enableS3: formConfig.enableS3 || false, enableS3: formConfig.enableS3 || false,
enableClickHouse: formConfig.enableClickHouse || 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: serviceConfig, config: serviceConfig,
@@ -337,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}
@@ -347,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>
`; `;
} }
@@ -417,6 +610,8 @@ export class ObViewServices extends DeesElement {
minio: { host: 'onebox-minio', port: 9000, version: 'latest', config: { consolePort: 9001, region: 'us-east-1' } }, 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 } }, 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 } }, 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 const info = platformService
? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} } ? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} }

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>

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();