Compare commits

...

16 Commits

Author SHA1 Message Date
92555c5a5e v1.12.0
Some checks failed
Publish to npm / npm-publish (push) Failing after 4m33s
Release / build-and-release (push) Failing after 4m49s
2026-03-15 12:06:55 +00:00
ddc7fa4bee feat(cli,release): add self-upgrade command and automate CI, release, and npm publishing workflows 2026-03-15 12:06:55 +00:00
eceb5d99c8 v1.11.0 2026-03-03 11:57:41 +00:00
0631b7731f feat(services): map backend service data to UI components, add stats & logs parsing, fetch service stats, and fix logs request param 2026-03-03 11:57:41 +00:00
4c485cdc0a v1.10.3 2026-03-02 07:26:38 +00:00
0f0da0f2ef fix(bin): make bin/onebox-wrapper.js executable 2026-03-02 07:26:38 +00:00
88367f70eb v1.10.2 2026-03-02 07:18:54 +00:00
bfcfef79da fix(build): update build/watch configuration, switch to esbuild bundler and tswatch, and bump catalog and tooling dependencies 2026-03-02 07:18:54 +00:00
d95270613b v1.10.1 2026-02-24 20:31:08 +00:00
14f6746833 fix(package.json): update package metadata 2026-02-24 20:31:07 +00:00
fe8ca00337 v1.10.0 2026-02-24 18:15:44 +00:00
ba05cc84fe feat(opsserver): introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces 2026-02-24 18:15:44 +00:00
84c47cd7f5 v1.9.2 2025-12-03 22:10:56 +00:00
9365f20f6d fix(ui): Add VS Code configs for the UI workspace and normalize dark theme CSS variables 2025-12-03 22:10:56 +00:00
bc2ed4b03a v1.9.1 2025-11-27 22:35:35 +00:00
e4dd4cce0a fix(ui): Correct import success toast and add VS Code launch/tasks recommendations for the UI 2025-11-27 22:35:35 +00:00
148 changed files with 47389 additions and 20847 deletions

View File

@@ -1,140 +0,0 @@
# Onebox Development Notes
## ⚠️ CRITICAL DEVELOPMENT RULES ⚠️
### NEVER GUESS - ALWAYS READ THE ACTUAL CODE
**FUCKING ALWAYS look at the dependency actual code. Don't start fucking guessing stuff.**
run "pnpm run watch" when starting to do stuff, so the UI gets recompiled and the server automatically restarts on file changes.
When working with any dependency:
1. **READ the actual source code** in `node_modules/` or check the package documentation
2. **CHECK the exact API** - don't assume based on similar libraries
3. **VERIFY method names, return types, and property structures** before using them
4. **TEST with the actual implementation** - APIs change between versions
Common mistakes to avoid:
- ❌ Assuming API structure based on similar libraries
- ❌ Guessing method names or property paths
- ❌ Using outdated documentation without checking current version
- ✅ Read the actual TypeScript definitions in node_modules
- ✅ Check the package's README and changelog
- ✅ Test the actual behavior before implementing
## Architecture Changes
### Reverse Proxy Implementation
- **Replaced Nginx** with native Deno reverse proxy (`ts/classes/reverseproxy.ts`)
- Features:
- HTTP/HTTPS dual servers (ports 80/443)
- TLS/SSL certificate management with hot-reload
- WebSocket bidirectional proxying
- Dynamic routing from database
- SNI (Server Name Indication) support
### Code Organization
- Removed "onebox." prefix from all TypeScript files
- Organized into subfolders:
- `ts/classes/` - All class implementations
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
### WebSocket Real-time Communication
- **Backend**: WebSocket endpoint at `/api/ws` (`ts/classes/httpserver.ts:96-174`)
- Connection management with client Set tracking
- Broadcast methods: `broadcast()`, `broadcastServiceUpdate()`, `broadcastServiceStatus()`
- Integrated with service lifecycle (start/stop/restart actions)
- Status monitoring loop broadcasts changes automatically
- **Frontend**: Angular WebSocket service (`ui/src/app/core/services/websocket.service.ts`)
- Auto-connects on app initialization
- Exponential backoff reconnection (max 5 attempts)
- RxJS Observable-based message streaming
- Components subscribe to real-time updates
- **Message Types**:
- `connected` - Initial connection confirmation
- `service_update` - Service lifecycle changes (action: created/updated/deleted/started/stopped)
- `service_status` - Real-time status changes from monitoring loop
- `system_status` - System-wide updates
- **Testing**: Use `.nogit/test-ws-updates.ts` to monitor WebSocket messages
### Docker Configuration
- **System Docker**: Uses root Docker at `/var/run/docker.sock` (NOT rootless)
- **Swarm Mode**: Enabled for service orchestration
- **API Access**: Interact with Docker via direct API calls to the socket
- ❌ DO NOT switch Docker CLI contexts
- ✅ Use curl/HTTP requests to `/var/run/docker.sock`
- **Network**: Overlay network `onebox-network` with `Attachable: true`
- **Services vs Containers**: All workloads run as Swarm services (not standalone containers)
## Debugging Tips
### Backend Logs
Use the background bash task to check server logs:
```bash
# Check for specific patterns (e.g., Login attempts)
BashOutput tool with filter: "Login|error|Error"
# Check all recent output
BashOutput tool without filter
```
The dev server runs with `--watch` so it auto-restarts on file changes.
### Frontend Testing
Use Playwright for UI testing:
```typescript
// Navigate to app
mcp__playwright__browser_navigate({ url: "http://localhost:3000" })
// Fill login form
mcp__playwright__browser_fill_form({
fields: [
{ name: "Username", type: "textbox", ref: "...", value: "admin" },
{ name: "Password", type: "textbox", ref: "...", value: "admin" }
]
})
// Click button
mcp__playwright__browser_click({ element: "Sign in button", ref: "..." })
// Check console errors
// Playwright automatically shows console messages in results
```
### Common Issues
#### Login Issue (Fixed)
**Problem**: `admin/admin` credentials returned "Invalid credentials"
**Root Cause**: `rowToUser()` function in database.ts was accessing rows as arrays `row[2]` instead of objects `row.password_hash`. The @db/sqlite library returns rows as objects with snake_case column names.
**Fix**: Updated `rowToUser()` to support both access patterns:
```typescript
private rowToUser(row: any): IUser {
return {
passwordHash: String(row.password_hash || row[2]),
// ... other fields
};
}
```
**Location**: `ts/classes/database.ts:506-515`
## Default Credentials
- Username: `admin`
- Password: `admin`
- ⚠️ Change immediately after first login!
## Development Server
```bash
# Main server (port 3000)
deno task dev
# Check server status
curl http://localhost:3000/api/status
```
## API Endpoints
- `POST /api/auth/login` - Login (returns JWT-like token)
- `GET /api/status` - System status (requires auth)
- `GET /api/services` - List services (requires auth)
- See `ts/classes/httpserver.ts` for full API

View File

@@ -0,0 +1,37 @@
## Onebox {{VERSION}}
Pre-compiled binaries for multiple platforms.
### Installation
#### Option 1: Via npm (recommended)
```bash
npm install -g @serve.zone/onebox
```
#### Option 2: Via installer script
```bash
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
```
#### Option 3: Direct binary download
Download the appropriate binary for your platform from the assets below and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
- macOS ARM64 (Apple Silicon)
- Windows x86_64
### Checksums
SHA256 checksums are provided in `SHA256SUMS.txt` for binary verification.
### npm Package
The npm package includes automatic binary detection and installation for your platform.

83
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,83 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
check:
name: Type Check & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- 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
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Compile for current platform
run: |
echo "Testing compilation for Linux x86_64..."
deno 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
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Compile all platform binaries
run: bash scripts/compile-all.sh
- name: Upload all binaries as artifact
uses: actions/upload-artifact@v3
with:
name: onebox-binaries.zip
path: dist/binaries/*
retention-days: 30

View File

@@ -0,0 +1,129 @@
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
npm-publish:
runs-on: ubuntu-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

@@ -0,0 +1,248 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- 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 "Building 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 all platforms
run: |
echo "================================================"
echo " Onebox Release Compilation"
echo " Version: ${{ steps.version.outputs.version }}"
echo "================================================"
echo ""
# Clean up old binaries and create fresh directory
rm -rf dist/binaries
mkdir -p dist/binaries
echo "-> Cleaned old binaries from dist/binaries"
echo ""
# Linux x86_64
echo "-> Compiling for Linux x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/onebox-linux-x64 \
--target x86_64-unknown-linux-gnu mod.ts
echo " Done: Linux x86_64"
# Linux ARM64
echo "-> Compiling for Linux ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/onebox-linux-arm64 \
--target aarch64-unknown-linux-gnu mod.ts
echo " Done: Linux ARM64"
# macOS x86_64
echo "-> Compiling for macOS x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/onebox-macos-x64 \
--target x86_64-apple-darwin mod.ts
echo " Done: macOS x86_64"
# macOS ARM64
echo "-> Compiling for macOS ARM64..."
deno compile --allow-all --no-check \
--output dist/binaries/onebox-macos-arm64 \
--target aarch64-apple-darwin mod.ts
echo " Done: macOS ARM64"
# Windows x86_64
echo "-> Compiling for Windows x86_64..."
deno compile --allow-all --no-check \
--output dist/binaries/onebox-windows-x64.exe \
--target x86_64-pc-windows-msvc mod.ts
echo " Done: Windows x86_64"
echo ""
echo "All binaries compiled successfully!"
ls -lh dist/binaries/
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
cd ../..
- name: Extract changelog for this version
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
# Check if CHANGELOG.md exists
if [ ! -f CHANGELOG.md ] && [ ! -f changelog.md ]; then
echo "No changelog found, using default release notes"
cat > /tmp/release_notes.md << EOF
## Onebox $VERSION
Pre-compiled binaries for multiple platforms.
### Installation
Use the installation script:
\`\`\`bash
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
\`\`\`
Or download the binary for your platform and make it executable.
### Supported Platforms
- Linux x86_64 (x64)
- Linux ARM64 (aarch64)
- macOS x86_64 (Intel)
- macOS ARM64 (Apple Silicon)
- Windows x86_64
### Checksums
SHA256 checksums are provided in SHA256SUMS.txt
EOF
else
CHANGELOG_FILE=$([ -f CHANGELOG.md ] && echo "CHANGELOG.md" || echo "changelog.md")
awk "/## \[$VERSION\]/,/## \[/" "$CHANGELOG_FILE" | sed '$d' > /tmp/release_notes.md || cat > /tmp/release_notes.md << EOF
## Onebox $VERSION
See changelog.md for full details.
### Installation
Use the installation script:
\`\`\`bash
curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash
\`\`\`
EOF
fi
echo "Release notes:"
cat /tmp/release_notes.md
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Checking for existing release $VERSION..."
# Try to get existing release by tag
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
echo "Found existing release (ID: $EXISTING_RELEASE_ID), deleting..."
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/$EXISTING_RELEASE_ID"
echo "Existing release deleted"
sleep 2
else
echo "No existing release found, proceeding with creation"
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_NOTES=$(cat /tmp/release_notes.md)
# Create the release
echo "Creating release for $VERSION..."
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"Onebox $VERSION\",
\"body\": $(jq -Rs . /tmp/release_notes.md),
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
echo "Release created with ID: $RELEASE_ID"
# Upload binaries as release assets
for binary in dist/binaries/*; do
filename=$(basename "$binary")
echo "Uploading $filename..."
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$binary" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/$RELEASE_ID/assets?name=$filename"
done
echo "All assets uploaded successfully"
- name: Clean up old releases
run: |
echo "Cleaning up old releases (keeping only last 3)..."
# Fetch all releases sorted by creation date
RELEASES=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases" | \
jq -r 'sort_by(.created_at) | reverse | .[3:] | .[].id')
# Delete old releases
if [ -n "$RELEASES" ]; then
echo "Found releases to delete:"
for release_id in $RELEASES; do
echo " Deleting release ID: $release_id"
curl -X DELETE -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/$release_id"
done
echo "Old releases deleted successfully"
else
echo "No old releases to delete (less than 4 releases total)"
fi
echo ""
- name: Release Summary
run: |
echo "================================================"
echo " Release ${{ steps.version.outputs.version }} Complete!"
echo "================================================"
echo ""
echo "Binaries published:"
ls -lh dist/binaries/
echo ""
echo "Release URL:"
echo "https://code.foss.global/serve.zone/onebox/releases/tag/${{ steps.version.outputs.version }}"
echo ""
echo "Installation command:"
echo "curl -sSL https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh | sudo bash"
echo ""

View File

@@ -1,5 +1,70 @@
# Changelog
## 2026-03-15 - 1.12.0 - feat(cli,release)
add self-upgrade command and automate CI, release, and npm publishing workflows
- adds a new `onebox upgrade` CLI command that checks the latest release and reinstalls the current binary via the installer script
- introduces Gitea CI workflows for type checks, build verification, multi-platform binary compilation, release creation, and npm publishing
- adds a reusable release template describing installation options, supported platforms, and checksum availability
## 2026-03-03 - 1.11.0 - feat(services)
map backend service data to UI components, add stats & logs parsing, fetch service stats, and fix logs request param
- Fix: rename service logs request property from 'lines' to 'tail' when calling typedRequest
- Add data transformation helpers: formatBytes, parseImageString, mapStatus, toServiceDetail, toServiceStats, parseLogs
- Transform service list and detail props to match @serve.zone/catalog component interfaces (map status, image, repo/tag, timestamps, registry)
- Dispatch fetchServiceStatsAction on service click and surface transformed stats with default values to avoid nulls
- Parse and normalize logs into timestamp/message pairs for the detail view
## 2026-03-02 - 1.10.3 - fix(bin)
make bin/onebox-wrapper.js executable
- Metadata-only change: file mode updated for bin/onebox-wrapper.js to include the executable bit
- No source or behavior changes to the code
## 2026-03-02 - 1.10.2 - fix(build)
update build/watch configuration, switch to esbuild bundler and tswatch, and bump catalog and tooling dependencies
- Switch watch script to 'tswatch' (replaced previous concurrently command invoking deno + tswatch).
- npmextra.json: set bundler to 'esbuild', enable production mode, include html/index.html in the bundle, and extend watchPatterns to include ./html/**/*.
- Backend watcher: expanded watch globs and changed command to include --unstable-ffi and runtime flags (--ephemeral --monitor); restart and debounce kept.
- Bump runtime deps: @design.estate/dees-catalog -> ^3.43.3, @serve.zone/catalog -> ^2.5.0.
- Bump devDependencies: @git.zone/tsbundle -> ^2.9.0, @git.zone/tswatch -> ^3.2.0.
## 2026-02-24 - 1.10.1 - fix(package.json)
update package metadata
- Single metadata-only file changed (+1 -1)
- No source code or runtime behavior modified; safe patch release
- Current package version is 1.10.0; recommend patch bump to 1.10.1
## 2026-02-24 - 1.10.0 - feat(opsserver)
introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces
- Add OpsServer (ts/opsserver) with TypedRequest handlers for admin, services, platform, dns, domains, registry, network, backups, schedules, settings and logs.
- Integrate typedrequest/typedserver and smartjwt/smartguard plugins (ts/plugins.ts) and add comprehensive ts_interfaces for requests and data shapes.
- Replace legacy HTTP server usage with OpsServer throughout daemon, Onebox class and CLI (ts/classes/daemon.ts, ts/classes/onebox.ts, ts/cli.ts).
- Implement log streaming via VirtualStream and support for downloading/restoring backups and registry token management within handlers.
- Introduce new web UI built with dees-element web components under ts_web (ob-app-shell and views) and bundle/watch tooling (npmextra.json, tsbundle/tswatch integration).
- Update package.json: add build/watch scripts, tsbundle/tswatch dev deps and new runtime dependencies for typedrequest and catalog components.
- Remove large Angular-based ui application and related services/components in ui/ (major cleanup of Angular code and assets).
- Note: This adds many new endpoints and internal API changes (TypedRequest-based); consumers of the old UI/HTTP endpoints should migrate to the new OpsServer TypedRequest API and web components.
## 2025-12-03 - 1.9.2 - fix(ui)
Add VS Code configs for the UI workspace and normalize dark theme CSS variables
- Add VS Code workspace files under ui/.vscode:
- - extensions.json: recommend the Angular language support extension
- - launch.json: Chrome launch configurations for 'ng serve' and 'ng test' (preLaunchTask hooks)
- - tasks.json: npm 'start' and 'test' tasks with a background TypeScript problem matcher to improve dev workflow
- Update ui/src/styles.css dark theme variables to use neutral black/gray HSL values for background, foreground, cards, popovers, accents, borders, inputs and ring to improve contrast and consistency
## 2025-11-27 - 1.9.1 - fix(ui)
Correct import success toast and add VS Code launch/tasks recommendations for the UI
- Fix backup import success toast in backups-tab.component to reference response.data.service.name (previously response.data.serviceName), preventing incorrect service name display.
- Add VS Code workspace settings for the UI: extensions recommendation, launch configurations for 'ng serve' and 'ng test', and npm tasks for start/test to simplify local development and debugging.
## 2025-11-27 - 1.9.0 - feat(backups)
Add backup import API and improve backup download/import flow in UI

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.9.0",
"version": "1.12.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {
@@ -22,7 +22,12 @@
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0"
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1"
},
"compilerOptions": {
"lib": [

36196
dist_serve/bundle.js Normal file

File diff suppressed because one or more lines are too long

33
dist_serve/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta
name="viewport"
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="theme-color" content="#000000" />
<title>Onebox</title>
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<style>
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
position: relative;
background: #000;
margin: 0px;
}
</style>
</head>
<body>
<noscript>
<p style="color: #fff; text-align: center; margin-top: 100px;">
JavaScript is required to run the Onebox dashboard.
</p>
</noscript>
</body>
<script defer type="module" src="/bundle.js"></script>
</html>

33
html/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta
name="viewport"
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="theme-color" content="#000000" />
<title>Onebox</title>
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<style>
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
position: relative;
background: #000;
margin: 0px;
}
</style>
</head>
<body>
<noscript>
<p style="color: #fff; text-align: center; margin-top: 100px;">
JavaScript is required to run the Onebox dashboard.
</p>
</noscript>
</body>
<script defer type="module" src="/bundle.js"></script>
</html>

37
npmextra.json Normal file
View File

@@ -0,0 +1,37 @@
{
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
]
},
"@git.zone/tswatch": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./ts_bundled/bundle.ts",
"outputMode": "base64ts",
"bundler": "esbuild",
"production": true,
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
"includeFiles": [{"from": "./html/index.html", "to": "index.html"}]
}
],
"watchers": [
{
"name": "backend",
"watch": ["./ts/**/*", "./ts_interfaces/**/*", "./ts_bundled/**/*"],
"command": "deno run --allow-all --unstable-ffi mod.ts server --ephemeral --monitor",
"restart": true,
"debounce": 500,
"runOnStart": true
}
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.9.0",
"version": "1.12.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts",
"type": "module",
@@ -9,7 +9,9 @@
},
"scripts": {
"postinstall": "node scripts/install-binary.js",
"watch": "concurrently --kill-others --names \"BACKEND,UI\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor\" \"cd ui && pnpm run watch\""
"watch": "tswatch",
"build": "tsbundle",
"bundle": "tsbundle"
},
"keywords": [
"docker",
@@ -51,8 +53,14 @@
"arm64"
],
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"dependencies": {},
"dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19",
"@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6",
"@serve.zone/catalog": "^2.5.0"
},
"devDependencies": {
"concurrently": "^9.1.2"
"@git.zone/tsbundle": "^2.9.0",
"@git.zone/tswatch": "^3.2.0"
}
}

5214
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -131,9 +131,9 @@ export class OneboxDaemon {
// Start monitoring loop
this.startMonitoring();
// Start HTTP server
// Start OpsServer (serves new UI + TypedRequest API)
const httpPort = parseInt(this.oneboxRef.database.getSetting('httpPort') || '3000', 10);
await this.oneboxRef.httpServer.start(httpPort);
await this.oneboxRef.opsServer.start(httpPort);
logger.success('Onebox daemon started');
logger.info(`Web UI available at http://localhost:${httpPort}`);
@@ -163,8 +163,8 @@ export class OneboxDaemon {
// Stop monitoring
this.stopMonitoring();
// Stop HTTP server
await this.oneboxRef.httpServer.stop();
// Stop OpsServer
await this.oneboxRef.opsServer.stop();
// Remove PID file
await this.removePidFile();
@@ -280,31 +280,12 @@ export class OneboxDaemon {
}
/**
* Broadcast stats to WebSocket clients (real-time updates)
* Broadcast stats (placeholder for future WebSocket integration via OpsServer)
*/
private async broadcastStats(): Promise<void> {
try {
const services = this.oneboxRef.services.listServices();
const runningServices = services.filter(s => s.status === 'running' && s.containerID);
logger.info(`Broadcasting stats for ${runningServices.length} running services`);
for (const service of runningServices) {
try {
const stats = await this.oneboxRef.docker.getContainerStats(service.containerID!);
if (stats) {
logger.info(`Broadcasting stats for ${service.name}: CPU=${stats.cpuPercent.toFixed(1)}%, Mem=${Math.round(stats.memoryUsed / 1024 / 1024)}MB`);
this.oneboxRef.httpServer.broadcastStatsUpdate(service.name, stats);
} else {
logger.warn(`No stats returned for ${service.name} (containerID: ${service.containerID})`);
}
} catch (error) {
logger.warn(`Stats collection failed for ${service.name}: ${getErrorMessage(error)}`);
}
}
} catch (error) {
logger.error(`Broadcast stats error: ${getErrorMessage(error)}`);
}
// Stats broadcasting via WebSocket is not yet implemented in OpsServer.
// Metrics are still collected and stored in the DB by collectMetrics().
// The new UI fetches stats via TypedRequests on demand.
}
/**

View File

@@ -22,6 +22,7 @@ import { PlatformServicesManager } from './platform-services/index.ts';
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts';
import { OpsServer } from '../opsserver/index.ts';
export class Onebox {
public database: OneboxDatabase;
@@ -40,6 +41,7 @@ export class Onebox {
public caddyLogReceiver: CaddyLogReceiver;
public backupManager: BackupManager;
public backupScheduler: BackupScheduler;
public opsServer: OpsServer;
private initialized = false;
@@ -77,6 +79,9 @@ export class Onebox {
// Initialize Backup scheduler
this.backupScheduler = new BackupScheduler(this);
// Initialize OpsServer (TypedRequest-based server)
this.opsServer = new OpsServer(this);
}
/**
@@ -330,17 +335,17 @@ export class Onebox {
}
/**
* Start HTTP server
* Start OpsServer (TypedRequest-based, serves new UI)
*/
async startHttpServer(port?: number): Promise<void> {
await this.httpServer.start(port);
await this.opsServer.start(port || 3000);
}
/**
* Stop HTTP server
* Stop OpsServer
*/
async stopHttpServer(): Promise<void> {
await this.httpServer.stop();
await this.opsServer.stop();
}
/**
@@ -356,8 +361,8 @@ export class Onebox {
// Stop daemon if running
await this.daemon.stop();
// Stop HTTP server if running
await this.httpServer.stop();
// Stop OpsServer if running
await this.opsServer.stop();
// Stop reverse proxy if running
await this.reverseProxy.stop();

View File

@@ -72,6 +72,10 @@ export async function runCli(): Promise<void> {
await handleStatusCommand(onebox);
break;
case 'upgrade':
await handleUpgradeCommand();
break;
default:
logger.error(`Unknown command: ${command}`);
printHelp();
@@ -286,8 +290,8 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
logger.info('Starting Onebox server...');
// Start HTTP server
await onebox.httpServer.start(port);
// Start OpsServer (serves new UI + TypedRequest API)
await onebox.opsServer.start(port);
// Start monitoring if requested
if (monitor) {
@@ -308,7 +312,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
if (monitor) {
onebox.daemon.stopMonitoring();
}
await onebox.httpServer.stop();
await onebox.opsServer.stop();
await onebox.shutdown();
Deno.exit(0);
};
@@ -386,6 +390,78 @@ async function handleStatusCommand(onebox: Onebox) {
console.log(JSON.stringify(status, null, 2));
}
// Upgrade command - self-update onebox to latest version
async function handleUpgradeCommand(): Promise<void> {
// Check if running as root
if (Deno.uid() !== 0) {
logger.error('This command must be run as root to upgrade Onebox.');
logger.info('Try: sudo onebox upgrade');
Deno.exit(1);
}
logger.info('Checking for updates...');
try {
// Get current version
const currentVersion = projectInfo.version;
// Fetch latest version from Gitea API
const apiUrl = 'https://code.foss.global/api/v1/repos/serve.zone/onebox/releases/latest';
const curlCmd = new Deno.Command('curl', {
args: ['-sSL', apiUrl],
stdout: 'piped',
stderr: 'piped',
});
const curlResult = await curlCmd.output();
const response = new TextDecoder().decode(curlResult.stdout);
const release = JSON.parse(response);
const latestVersion = release.tag_name as string; // e.g., "v1.11.0"
// Normalize versions for comparison (ensure both have "v" prefix)
const normalizedCurrent = currentVersion.startsWith('v')
? currentVersion
: `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v')
? latestVersion
: `v${latestVersion}`;
console.log(` Current version: ${normalizedCurrent}`);
console.log(` Latest version: ${normalizedLatest}`);
console.log('');
// Compare normalized versions
if (normalizedCurrent === normalizedLatest) {
logger.success('Already up to date!');
return;
}
logger.info(`New version available: ${latestVersion}`);
logger.info('Downloading and installing...');
console.log('');
// Download and run the install script
const installUrl = 'https://code.foss.global/serve.zone/onebox/raw/branch/main/install.sh';
const installCmd = new Deno.Command('bash', {
args: ['-c', `curl -sSL ${installUrl} | bash`],
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
});
const installResult = await installCmd.output();
if (!installResult.success) {
logger.error('Upgrade failed');
Deno.exit(1);
}
console.log('');
logger.success(`Upgraded to ${latestVersion}`);
} catch (error) {
logger.error(`Upgrade failed: ${getErrorMessage(error)}`);
Deno.exit(1);
}
}
// Helpers
function getArg(args: string[], flag: string): string {
const arg = args.find((a) => a.startsWith(`${flag}=`));
@@ -441,6 +517,9 @@ Commands:
status
upgrade
Upgrade Onebox to the latest version (requires root)
Options:
--help, -h Show this help message
--version, -v Show version

View File

@@ -0,0 +1,76 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { Onebox } from '../classes/onebox.ts';
import * as handlers from './handlers/index.ts';
import { files as bundledFiles } from '../../ts_bundled/bundle.ts';
export class OpsServer {
public oneboxRef: Onebox;
public typedrouter = new plugins.typedrequest.TypedRouter();
public server!: plugins.typedserver.utilityservers.UtilityWebsiteServer;
// Handler instances
public adminHandler!: handlers.AdminHandler;
public statusHandler!: handlers.StatusHandler;
public servicesHandler!: handlers.ServicesHandler;
public platformHandler!: handlers.PlatformHandler;
public sslHandler!: handlers.SslHandler;
public domainsHandler!: handlers.DomainsHandler;
public dnsHandler!: handlers.DnsHandler;
public registryHandler!: handlers.RegistryHandler;
public networkHandler!: handlers.NetworkHandler;
public backupsHandler!: handlers.BackupsHandler;
public schedulesHandler!: handlers.SchedulesHandler;
public settingsHandler!: handlers.SettingsHandler;
public logsHandler!: handlers.LogsHandler;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
public async start(port = 3000) {
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
domain: 'localhost',
feedMetadata: undefined,
bundledContent: bundledFiles,
});
// Chain typedrouters: server -> opsServer -> individual handlers
this.server.typedrouter.addTypedRouter(this.typedrouter);
// Set up all handlers
await this.setupHandlers();
await this.server.start(port);
logger.success(`OpsServer started on http://localhost:${port}`);
}
private async setupHandlers(): Promise<void> {
// AdminHandler requires async initialization for JWT key generation
this.adminHandler = new handlers.AdminHandler(this);
await this.adminHandler.initialize();
// All other handlers self-register in their constructors
this.statusHandler = new handlers.StatusHandler(this);
this.servicesHandler = new handlers.ServicesHandler(this);
this.platformHandler = new handlers.PlatformHandler(this);
this.sslHandler = new handlers.SslHandler(this);
this.domainsHandler = new handlers.DomainsHandler(this);
this.dnsHandler = new handlers.DnsHandler(this);
this.registryHandler = new handlers.RegistryHandler(this);
this.networkHandler = new handlers.NetworkHandler(this);
this.backupsHandler = new handlers.BackupsHandler(this);
this.schedulesHandler = new handlers.SchedulesHandler(this);
this.settingsHandler = new handlers.SettingsHandler(this);
this.logsHandler = new handlers.LogsHandler(this);
logger.success('OpsServer TypedRequest handlers initialized');
}
public async stop() {
if (this.server) {
await this.server.stop();
logger.success('OpsServer stopped');
}
}
}

View File

@@ -0,0 +1,175 @@
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';
export interface IJwtData {
userId: string;
status: 'loggedIn' | 'loggedOut';
expiresAt: number;
}
export class AdminHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
}
public async initialize(): Promise<void> {
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
await this.smartjwtInstance.init();
await this.smartjwtInstance.createNewKeyPair();
this.registerHandlers();
}
private registerHandlers(): void {
// Login
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
try {
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(dataArg.username);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
}
// Verify password (base64 comparison to match existing DB scheme)
const passwordHash = btoa(dataArg.password);
if (passwordHash !== user.passwordHash) {
throw new plugins.typedrequest.TypedResponseError('Invalid credentials');
}
const expiresAt = Date.now() + 24 * 3600 * 1000;
const userId = String(user.id || user.username);
const jwt = await this.smartjwtInstance.createJWT({
userId,
status: 'loggedIn',
expiresAt,
});
logger.info(`User logged in: ${user.username}`);
return {
identity: {
jwt,
userId,
username: user.username,
expiresAt,
role: user.role,
},
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Login failed');
}
},
),
);
// Logout
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
'adminLogout',
async (_dataArg) => {
return { ok: true };
},
),
);
// Verify Identity
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
'verifyIdentity',
async (dataArg) => {
if (!dataArg.identity?.jwt) {
return { valid: false };
}
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
if (jwtData.expiresAt < Date.now()) return { valid: false };
if (jwtData.status !== 'loggedIn') return { valid: false };
return {
valid: true,
identity: {
jwt: dataArg.identity.jwt,
userId: jwtData.userId,
username: dataArg.identity.username,
expiresAt: jwtData.expiresAt,
role: dataArg.identity.role,
},
};
} catch {
return { valid: false };
}
},
),
);
// Change Password
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ChangePassword>(
'changePassword',
async (dataArg) => {
await this.requireValidIdentity(dataArg);
const user = this.opsServerRef.oneboxRef.database.getUserByUsername(dataArg.identity.username);
if (!user) {
throw new plugins.typedrequest.TypedResponseError('User not found');
}
const currentHash = btoa(dataArg.currentPassword);
if (currentHash !== user.passwordHash) {
throw new plugins.typedrequest.TypedResponseError('Current password is incorrect');
}
const newHash = btoa(dataArg.newPassword);
this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, newHash);
logger.info(`Password changed for user: ${user.username}`);
return { ok: true };
},
),
);
}
private async requireValidIdentity(dataArg: { identity: interfaces.data.IIdentity }): Promise<void> {
const passed = await this.validIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
}
// Guard for valid identity
public validIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
if (!dataArg.identity?.jwt) return false;
try {
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
if (jwtData.expiresAt < Date.now()) return false;
if (jwtData.status !== 'loggedIn') return false;
if (dataArg.identity.expiresAt !== jwtData.expiresAt) return false;
if (dataArg.identity.userId !== jwtData.userId) return false;
return true;
} catch {
return false;
}
},
{ failedHint: 'identity is not valid', name: 'validIdentityGuard' },
);
// Guard for admin identity
public adminIdentityGuard = new plugins.smartguard.Guard<{
identity: interfaces.data.IIdentity;
}>(
async (dataArg) => {
const isValid = await this.validIdentityGuard.exec(dataArg);
if (!isValid) return false;
return dataArg.identity.role === 'admin';
},
{ failedHint: 'user is not admin', name: 'adminIdentityGuard' },
);
}

View File

@@ -0,0 +1,100 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class BackupsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackups>(
'getBackups',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups();
return { backups };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackup>(
'getBackup',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found');
}
return { backup };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackup>(
'deleteBackup',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupManager.deleteBackup(dataArg.backupId);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestoreBackup>(
'restoreBackup',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backupPath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
if (!backupPath) {
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
}
const rawResult = await this.opsServerRef.oneboxRef.backupManager.restoreBackup(
backupPath,
dataArg.options,
);
return {
result: {
service: {
name: rawResult.service.name,
status: rawResult.service.status,
},
platformResourcesRestored: rawResult.platformResourcesRestored,
warnings: rawResult.warnings,
},
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DownloadBackup>(
'downloadBackup',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backup = this.opsServerRef.oneboxRef.database.getBackupById(dataArg.backupId);
if (!backup) {
throw new plugins.typedrequest.TypedResponseError('Backup not found');
}
const filePath = this.opsServerRef.oneboxRef.backupManager.getBackupFilePath(dataArg.backupId);
if (!filePath) {
throw new plugins.typedrequest.TypedResponseError('Backup file not found');
}
// Return a download URL that the client can fetch directly
return {
downloadUrl: `/api/backups/${dataArg.backupId}/download`,
filename: backup.filename,
};
},
),
);
}
}

View File

@@ -0,0 +1,65 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class DnsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
'getDnsRecords',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
return { records };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
'createDnsRecord',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.dns.addDNSRecord(dataArg.domain, dataArg.value);
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
const record = records.find((r: any) => r.domain === dataArg.domain);
return { record: record! };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
'deleteDnsRecord',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.dns.removeDNSRecord(dataArg.domain);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDns>(
'syncDns',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
if (!this.opsServerRef.oneboxRef.dns.isConfigured()) {
throw new plugins.typedrequest.TypedResponseError('DNS manager not configured');
}
await this.opsServerRef.oneboxRef.dns.syncFromCloudflare();
const records = this.opsServerRef.oneboxRef.dns.listDNSRecords();
return { records };
},
),
);
}
}

View File

@@ -0,0 +1,101 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class DomainsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private buildDomainViews(): interfaces.data.IDomainDetail[] {
const domains = this.opsServerRef.oneboxRef.database.getAllDomains();
const allServices = this.opsServerRef.oneboxRef.database.getAllServices();
return domains.map((domain: any) => {
const certificates = this.opsServerRef.oneboxRef.database.getCertificatesByDomain(domain.id!);
const requirements = this.opsServerRef.oneboxRef.database.getCertRequirementsByDomain(domain.id!);
const serviceCount = allServices.filter((service: any) => {
if (!service.domain) return false;
const baseDomain = service.domain.split('.').slice(-2).join('.');
return baseDomain === domain.domain;
}).length;
let certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none' = 'none';
let daysRemaining: number | null = null;
const validCerts = certificates.filter((cert: any) => cert.isValid && cert.expiryDate > Date.now());
if (validCerts.length > 0) {
const latestCert = validCerts.reduce((latest: any, cert: any) =>
cert.expiryDate > latest.expiryDate ? cert : latest
);
daysRemaining = Math.floor((latestCert.expiryDate - Date.now()) / (24 * 60 * 60 * 1000));
certificateStatus = daysRemaining <= 30 ? 'expiring-soon' : 'valid';
} else if (certificates.some((cert: any) => !cert.isValid)) {
certificateStatus = 'expired';
} else if (requirements.some((req: any) => req.status === 'pending')) {
certificateStatus = 'pending';
}
return {
domain,
certificates,
requirements,
serviceCount,
certificateStatus,
daysRemaining,
};
});
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
'getDomains',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const domains = this.buildDomainViews();
return { domains };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
'getDomain',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const domain = this.opsServerRef.oneboxRef.database.getDomainByName(dataArg.domainName);
if (!domain) {
throw new plugins.typedrequest.TypedResponseError('Domain not found');
}
const views = this.buildDomainViews();
const domainView = views.find((v) => v.domain.domain === dataArg.domainName);
if (!domainView) {
throw new plugins.typedrequest.TypedResponseError('Domain not found');
}
return { domain: domainView };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomains>(
'syncDomains',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
if (!this.opsServerRef.oneboxRef.cloudflareDomainSync) {
throw new plugins.typedrequest.TypedResponseError('Cloudflare domain sync not configured');
}
await this.opsServerRef.oneboxRef.cloudflareDomainSync.syncZones();
const domains = this.buildDomainViews();
return { domains };
},
),
);
}
}

View File

@@ -0,0 +1,13 @@
export * from './admin.handler.ts';
export * from './status.handler.ts';
export * from './services.handler.ts';
export * from './platform.handler.ts';
export * from './ssl.handler.ts';
export * from './domains.handler.ts';
export * from './dns.handler.ts';
export * from './registry.handler.ts';
export * from './network.handler.ts';
export * from './backups.handler.ts';
export * from './schedules.handler.ts';
export * from './settings.handler.ts';
export * from './logs.handler.ts';

View File

@@ -0,0 +1,219 @@
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 LogsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Service log stream
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogStream>(
'getServiceLogStream',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder();
// Get container and start streaming in background
(async () => {
try {
let container = await this.opsServerRef.oneboxRef.docker.getContainerById(service.containerID!);
if (!container) {
// Try finding by service label
const containers = await this.opsServerRef.oneboxRef.docker.listAllContainers();
const serviceContainer = containers.find((c: any) => {
const labels = c.Labels || {};
return labels['com.docker.swarm.service.id'] === service.containerID;
});
if (serviceContainer) {
container = await this.opsServerRef.oneboxRef.docker.getContainerById(serviceContainer.Id);
}
}
if (!container) {
virtualStream.sendData(encoder.encode(JSON.stringify({ error: 'Container not found' })));
return;
}
const logStream = await container.streamLogs({
stdout: true,
stderr: true,
timestamps: true,
tail: 100,
});
let buffer = new Uint8Array(0);
logStream.on('data', (chunk: Uint8Array) => {
// Append to buffer
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
newBuffer.set(chunk, buffer.length);
buffer = newBuffer;
// Process Docker multiplexed frames
while (buffer.length >= 8) {
const frameSize = (buffer[4] << 24) | (buffer[5] << 16) | (buffer[6] << 8) | buffer[7];
if (buffer.length < 8 + frameSize) break;
const frameData = buffer.slice(8, 8 + frameSize);
try {
virtualStream.sendData(frameData);
} catch {
logStream.destroy();
return;
}
buffer = buffer.slice(8 + frameSize);
}
});
logStream.on('error', (error: Error) => {
logger.error(`Log stream error for ${dataArg.serviceName}: ${error.message}`);
});
} catch (error) {
logger.error(`Failed to start log stream: ${error}`);
}
})();
return { logStream: virtualStream as any };
},
),
);
// Platform service log stream
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceLogStream>(
'getPlatformServiceLogStream',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const platformService = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(
dataArg.serviceType,
);
if (!platformService || !platformService.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
}
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
(async () => {
try {
const container = await this.opsServerRef.oneboxRef.docker.getContainerById(
platformService.containerId!,
);
if (!container) return;
const logStream = await container.streamLogs({
stdout: true,
stderr: true,
timestamps: true,
tail: 100,
});
let buffer = new Uint8Array(0);
logStream.on('data', (chunk: Uint8Array) => {
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
newBuffer.set(chunk, buffer.length);
buffer = newBuffer;
while (buffer.length >= 8) {
const frameSize = (buffer[4] << 24) | (buffer[5] << 16) | (buffer[6] << 8) | buffer[7];
if (buffer.length < 8 + frameSize) break;
const frameData = buffer.slice(8, 8 + frameSize);
try {
virtualStream.sendData(frameData);
} catch {
logStream.destroy();
return;
}
buffer = buffer.slice(8 + frameSize);
}
});
} catch (error) {
logger.error(`Failed to start platform log stream: ${error}`);
}
})();
return { logStream: virtualStream as any };
},
),
);
// Network log stream
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkLogStream>(
'getNetworkLogStream',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder();
const clientId = crypto.randomUUID();
// Create a mock WebSocket-like object for the CaddyLogReceiver
const mockSocket = {
readyState: 1, // WebSocket.OPEN
send: (data: string) => {
try {
virtualStream.sendData(encoder.encode(data));
} catch {
this.opsServerRef.oneboxRef.caddyLogReceiver.removeClient(clientId);
}
},
};
const filter = dataArg.filter || {};
this.opsServerRef.oneboxRef.caddyLogReceiver.addClient(
clientId,
mockSocket as any,
filter,
);
return { logStream: virtualStream as any };
},
),
);
// Event stream (general updates)
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEventStream>(
'getEventStream',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
const encoder = new TextEncoder();
// Send initial connection message
virtualStream.sendData(
encoder.encode(
JSON.stringify({
type: 'connected',
message: 'Connected to Onebox event stream',
timestamp: Date.now(),
}),
),
);
return { eventStream: virtualStream as any };
},
),
);
}
}

View File

@@ -0,0 +1,123 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
import type { TPlatformServiceType } from '../../types.ts';
export class NetworkHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private getPlatformServicePort(type: TPlatformServiceType): number {
const ports: Record<TPlatformServiceType, number> = {
mongodb: 27017,
minio: 9000,
redis: 6379,
postgresql: 5432,
rabbitmq: 5672,
caddy: 80,
clickhouse: 8123,
};
return ports[type] || 0;
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkTargets>(
'getNetworkTargets',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const targets: interfaces.data.INetworkTarget[] = [];
// Services
const services = this.opsServerRef.oneboxRef.services.listServices();
for (const svc of services) {
targets.push({
type: 'service',
name: svc.name,
domain: svc.domain || null,
targetHost: (svc as any).containerIP || svc.containerID || 'unknown',
targetPort: svc.port || 80,
status: svc.status,
});
}
// Registry
const registryStatus = this.opsServerRef.oneboxRef.registry.getStatus();
if (registryStatus.running) {
targets.push({
type: 'registry',
name: 'onebox-registry',
domain: null,
targetHost: 'localhost',
targetPort: registryStatus.port,
status: 'running',
});
}
// Platform services
const platformServices = this.opsServerRef.oneboxRef.platformServices.getAllPlatformServices();
for (const ps of platformServices) {
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(ps.type);
targets.push({
type: 'platform',
name: provider?.displayName || ps.type,
domain: null,
targetHost: 'localhost',
targetPort: this.getPlatformServicePort(ps.type),
status: ps.status,
});
}
return { targets };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
'getNetworkStats',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
const logReceiverStats = this.opsServerRef.oneboxRef.caddyLogReceiver.getStats();
return {
stats: {
proxy: {
running: proxyStatus.running ?? proxyStatus.http?.running ?? false,
httpPort: proxyStatus.httpPort ?? proxyStatus.http?.port ?? 80,
httpsPort: proxyStatus.httpsPort ?? proxyStatus.https?.port ?? 443,
routes: proxyStatus.routes ?? 0,
certificates: proxyStatus.certificates ?? proxyStatus.https?.certificates ?? 0,
},
logReceiver: {
running: logReceiverStats.running,
port: logReceiverStats.port,
clients: logReceiverStats.clients,
connections: logReceiverStats.connections,
sampleRate: logReceiverStats.sampleRate,
recentLogsCount: logReceiverStats.recentLogsCount,
},
},
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTrafficStats>(
'getTrafficStats',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const trafficStats = this.opsServerRef.oneboxRef.caddyLogReceiver.getTrafficStats(60);
return { stats: trafficStats };
},
),
);
}
}

View File

@@ -0,0 +1,169 @@
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 PlatformHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get all platform services
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServices>(
'getPlatformServices',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const platformServices = this.opsServerRef.oneboxRef.platformServices.getAllPlatformServices();
const providers = this.opsServerRef.oneboxRef.platformServices.getAllProviders();
const result = providers.map((provider: any) => {
const service = platformServices.find((s: any) => s.type === provider.type);
const isCore = 'isCore' in provider && (provider as any).isCore === true;
let status: string = service?.status || 'not-deployed';
if (provider.type === 'caddy') {
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
status = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
}
return {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: status as interfaces.data.TPlatformServiceStatus,
containerId: service?.containerId,
isCore,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
};
});
return { platformServices: result as interfaces.data.IPlatformService[] };
},
),
);
// Get specific platform service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformService>(
'getPlatformService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
}
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
const isCore = 'isCore' in provider && (provider as any).isCore === true;
let rawStatus: string = service?.status || 'not-deployed';
if (dataArg.serviceType === 'caddy') {
const proxyStatus = this.opsServerRef.oneboxRef.reverseProxy.getStatus() as any;
rawStatus = (proxyStatus.running ?? proxyStatus.http?.running) ? 'running' : 'stopped';
}
return {
platformService: {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: rawStatus as interfaces.data.TPlatformServiceStatus,
containerId: service?.containerId,
isCore,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
} as interfaces.data.IPlatformService,
};
},
),
);
// Start platform service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartPlatformService>(
'startPlatformService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
}
logger.info(`Starting platform service: ${dataArg.serviceType}`);
const service = await this.opsServerRef.oneboxRef.platformServices.ensureRunning(dataArg.serviceType);
return {
platformService: {
type: service.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: service.status,
containerId: service.containerId,
},
};
},
),
);
// Stop platform service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopPlatformService>(
'stopPlatformService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const provider = this.opsServerRef.oneboxRef.platformServices.getProvider(dataArg.serviceType);
if (!provider) {
throw new plugins.typedrequest.TypedResponseError(`Unknown platform service type: ${dataArg.serviceType}`);
}
const isCore = 'isCore' in provider && (provider as any).isCore === true;
if (isCore) {
throw new plugins.typedrequest.TypedResponseError(
`${provider.displayName} is a core service and cannot be stopped`,
);
}
logger.info(`Stopping platform service: ${dataArg.serviceType}`);
await this.opsServerRef.oneboxRef.platformServices.stopPlatformService(dataArg.serviceType);
return {
platformService: {
type: dataArg.serviceType,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: 'stopped' as const,
},
};
},
),
);
// Get platform service stats
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetPlatformServiceStats>(
'getPlatformServiceStats',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.database.getPlatformServiceByType(dataArg.serviceType);
if (!service || !service.containerId) {
throw new plugins.typedrequest.TypedResponseError('Platform service has no container');
}
const stats = await this.opsServerRef.oneboxRef.docker.getContainerStats(service.containerId);
if (!stats) {
throw new plugins.typedrequest.TypedResponseError('Could not retrieve container stats');
}
return { stats };
},
),
);
}
}

View File

@@ -0,0 +1,147 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class RegistryHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get registry tags
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTags>(
'getRegistryTags',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const tags = await this.opsServerRef.oneboxRef.registry.getImageTags(dataArg.serviceName);
return { tags };
},
),
);
// Get registry tokens
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRegistryTokens>(
'getRegistryTokens',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const rawTokens = this.opsServerRef.oneboxRef.database.getAllRegistryTokens();
const now = Date.now();
const tokens = rawTokens.map((token: any) => {
const isExpired = token.expiresAt !== null && token.expiresAt < now;
let scopeDisplay: string;
if (token.scope === 'all') {
scopeDisplay = 'All services';
} else if (Array.isArray(token.scope)) {
scopeDisplay = token.scope.length === 1 ? token.scope[0] : `${token.scope.length} services`;
} else {
scopeDisplay = 'Unknown';
}
return {
id: token.id!,
name: token.name,
type: token.type,
scope: token.scope,
scopeDisplay,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
createdBy: token.createdBy,
isExpired,
};
});
return { tokens };
},
),
);
// Create registry token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRegistryToken>(
'createRegistryToken',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const config = dataArg.tokenConfig;
// Calculate expiration
const now = Date.now();
let expiresAt: number | null = null;
if (config.expiresIn !== 'never') {
const daysMap: Record<string, number> = { '30d': 30, '90d': 90, '365d': 365 };
const days = daysMap[config.expiresIn];
if (days) expiresAt = now + days * 24 * 60 * 60 * 1000;
}
// Generate token
const plainToken = crypto.randomUUID() + crypto.randomUUID();
const encoder = new TextEncoder();
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(plainToken));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const tokenHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
const token = this.opsServerRef.oneboxRef.database.createRegistryToken({
name: config.name,
tokenHash,
type: config.type,
scope: config.scope,
expiresAt,
createdAt: now,
lastUsedAt: null,
createdBy: dataArg.identity.username,
});
let scopeDisplay: string;
if (token.scope === 'all') {
scopeDisplay = 'All services';
} else if (Array.isArray(token.scope)) {
scopeDisplay = token.scope.length === 1 ? token.scope[0] : `${token.scope.length} services`;
} else {
scopeDisplay = 'Unknown';
}
return {
result: {
token: {
id: token.id!,
name: token.name,
type: token.type,
scope: token.scope,
scopeDisplay,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
createdBy: token.createdBy,
isExpired: false,
},
plainToken,
},
};
},
),
);
// Delete registry token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRegistryToken>(
'deleteRegistryToken',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const token = this.opsServerRef.oneboxRef.database.getRegistryTokenById(dataArg.tokenId);
if (!token) {
throw new plugins.typedrequest.TypedResponseError('Token not found');
}
this.opsServerRef.oneboxRef.database.deleteRegistryToken(dataArg.tokenId);
return { ok: true };
},
),
);
}
}

View File

@@ -0,0 +1,93 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class SchedulesHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedules>(
'getBackupSchedules',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const schedules = this.opsServerRef.oneboxRef.backupScheduler.getAllSchedules();
return { schedules };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateBackupSchedule>(
'createBackupSchedule',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.createSchedule(
dataArg.scheduleConfig,
);
return { schedule };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupSchedule>(
'getBackupSchedule',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = this.opsServerRef.oneboxRef.backupScheduler.getScheduleById(dataArg.scheduleId);
if (!schedule) {
throw new plugins.typedrequest.TypedResponseError('Schedule not found');
}
return { schedule };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateBackupSchedule>(
'updateBackupSchedule',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const schedule = await this.opsServerRef.oneboxRef.backupScheduler.updateSchedule(
dataArg.scheduleId,
dataArg.updates,
);
return { schedule };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteBackupSchedule>(
'deleteBackupSchedule',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupScheduler.deleteSchedule(dataArg.scheduleId);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TriggerBackupSchedule>(
'triggerBackupSchedule',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.backupScheduler.triggerBackup(dataArg.scheduleId);
// triggerBackup is void; the backup is created async by the scheduler
// Return the most recent backup for the schedule
const allBackups = this.opsServerRef.oneboxRef.backupManager.listBackups();
const latestBackup = allBackups.find((b: any) => b.scheduleId === dataArg.scheduleId);
return { backup: latestBackup! };
},
),
);
}
}

View File

@@ -0,0 +1,244 @@
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 ServicesHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get all services
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServices>(
'getServices',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const services = this.opsServerRef.oneboxRef.services.listServices();
return { services };
},
),
);
// Get single service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetService>(
'getService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
return { service };
},
),
);
// Create service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateService>(
'createService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = await this.opsServerRef.oneboxRef.services.deployService(dataArg.serviceConfig);
return { service };
},
),
);
// Update service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateService>(
'updateService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = await this.opsServerRef.oneboxRef.services.updateService(
dataArg.serviceName,
dataArg.updates,
);
return { service };
},
),
);
// Delete service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteService>(
'deleteService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.removeService(dataArg.serviceName);
return { ok: true };
},
),
);
// Start service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartService>(
'startService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.startService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! };
},
),
);
// Stop service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StopService>(
'stopService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.stopService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! };
},
),
);
// Restart service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RestartService>(
'restartService',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.services.restartService(dataArg.serviceName);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
return { service: service! };
},
),
);
// Get service logs
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceLogs>(
'getServiceLogs',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const logs = await this.opsServerRef.oneboxRef.services.getServiceLogs(dataArg.serviceName);
return { logs: String(logs) };
},
),
);
// Get service stats
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceStats>(
'getServiceStats',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service || !service.containerID) {
throw new plugins.typedrequest.TypedResponseError('Service has no container');
}
const stats = await this.opsServerRef.oneboxRef.docker.getContainerStats(service.containerID);
if (!stats) {
throw new plugins.typedrequest.TypedResponseError('Could not retrieve container stats');
}
return { stats };
},
),
);
// Get service metrics
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceMetrics>(
'getServiceMetrics',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service || !service.id) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const metrics = this.opsServerRef.oneboxRef.database.getMetrics(service.id, dataArg.limit || 60);
return { metrics };
},
),
);
// Get service platform resources
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServicePlatformResources>(
'getServicePlatformResources',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const rawResources = await this.opsServerRef.oneboxRef.services.getServicePlatformResources(
dataArg.serviceName,
);
const resources = rawResources.map((r: any) => ({
id: r.resource.id,
resourceType: r.resource.resourceType,
resourceName: r.resource.resourceName,
platformService: {
type: r.platformService.type,
name: r.platformService.name,
status: r.platformService.status,
},
envVars: Object.keys(r.credentials).reduce((acc: Record<string, string>, key: string) => {
const value = r.credentials[key];
if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) {
acc[key] = '********';
} else {
acc[key] = value;
}
return acc;
}, {}),
createdAt: r.resource.createdAt,
}));
return { resources };
},
),
);
// Get service backups
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackups>(
'getServiceBackups',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backups = this.opsServerRef.oneboxRef.backupManager.listBackups(dataArg.serviceName);
return { backups };
},
),
);
// Create service backup
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateServiceBackup>(
'createServiceBackup',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const result = await this.opsServerRef.oneboxRef.backupManager.createBackup(dataArg.serviceName);
return { backup: result.backup };
},
),
);
// Get service backup schedules
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServiceBackupSchedules>(
'getServiceBackupSchedules',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const service = this.opsServerRef.oneboxRef.services.getService(dataArg.serviceName);
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Service not found');
}
const schedules = this.opsServerRef.oneboxRef.backupScheduler.getSchedulesForService(
dataArg.serviceName,
);
return { schedules };
},
),
);
}
}

View File

@@ -0,0 +1,86 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class SettingsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private getSettingsObject(): interfaces.data.ISettings {
const db = this.opsServerRef.oneboxRef.database;
const settingsMap = db.getAllSettings(); // Returns Record<string, string>
return {
cloudflareToken: settingsMap['cloudflareToken'] || '',
cloudflareZoneId: settingsMap['cloudflareZoneId'] || '',
autoRenewCerts: settingsMap['autoRenewCerts'] === 'true',
renewalThreshold: parseInt(settingsMap['renewalThreshold'] || '30', 10),
acmeEmail: settingsMap['acmeEmail'] || '',
httpPort: parseInt(settingsMap['httpPort'] || '80', 10),
httpsPort: parseInt(settingsMap['httpsPort'] || '443', 10),
forceHttps: settingsMap['forceHttps'] === 'true',
};
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSettings>(
'getSettings',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const settings = this.getSettingsObject();
return { settings };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSettings>(
'updateSettings',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const db = this.opsServerRef.oneboxRef.database;
const updates = dataArg.settings;
// Store each setting as key-value pair
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
db.setSetting(key, String(value));
}
}
const settings = this.getSettingsObject();
return { settings };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetBackupPassword>(
'setBackupPassword',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
this.opsServerRef.oneboxRef.database.setSetting('backupPassword', dataArg.password);
return { ok: true };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBackupPasswordStatus>(
'getBackupPasswordStatus',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const backupPassword = this.opsServerRef.oneboxRef.database.getSetting('backupPassword');
const isConfigured = !!backupPassword;
return { status: { isConfigured } };
},
),
);
}
}

View File

@@ -0,0 +1,64 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class SslHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ObtainCertificate>(
'obtainCertificate',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.ssl.obtainCertificate(dataArg.domain, false);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
return { certificate: certificate as unknown as interfaces.data.ICertificate };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListCertificates>(
'listCertificates',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const certificates = this.opsServerRef.oneboxRef.ssl.listCertificates();
return { certificates: certificates as unknown as interfaces.data.ICertificate[] };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificate>(
'getCertificate',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
if (!certificate) {
throw new plugins.typedrequest.TypedResponseError('Certificate not found');
}
return { certificate: certificate as unknown as interfaces.data.ICertificate };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RenewCertificate>(
'renewCertificate',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
await this.opsServerRef.oneboxRef.ssl.renewCertificate(dataArg.domain);
const certificate = this.opsServerRef.oneboxRef.ssl.getCertificate(dataArg.domain);
return { certificate: certificate as unknown as interfaces.data.ICertificate };
},
),
);
}
}

View File

@@ -0,0 +1,26 @@
import * as plugins from '../../plugins.ts';
import type { OpsServer } from '../classes.opsserver.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
import { requireValidIdentity } from '../helpers/guards.ts';
export class StatusHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSystemStatus>(
'getSystemStatus',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
const status = await this.opsServerRef.oneboxRef.getSystemStatus();
return { status: status as unknown as interfaces.data.ISystemStatus };
},
),
);
}
}

View File

@@ -0,0 +1,29 @@
import * as plugins from '../../plugins.ts';
import type { AdminHandler } from '../handlers/admin.handler.ts';
import * as interfaces from '../../../ts_interfaces/index.ts';
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler,
dataArg: T,
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
}
}
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
adminHandler: AdminHandler,
dataArg: T,
): Promise<void> {
if (!dataArg.identity) {
throw new plugins.typedrequest.TypedResponseError('No identity provided');
}
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
if (!passed) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
}

1
ts/opsserver/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './classes.opsserver.ts';

View File

@@ -61,3 +61,13 @@ export { crypto };
import * as nodeHttps from 'node:https';
import * as nodeHttp from 'node:http';
export { nodeHttps, nodeHttp };
// TypedRequest/TypedServer infrastructure
import * as typedrequest from '@api.global/typedrequest';
import * as typedserver from '@api.global/typedserver';
export { typedrequest, typedserver };
// Auth & Guards
import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt';
export { smartguard, smartjwt };

11
ts_bundled/bundle.ts Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
/**
* Auth-related data shapes for Onebox
*/
export interface IIdentity {
jwt: string;
userId: string;
username: string;
expiresAt: number;
role: 'admin' | 'user';
}
export interface IUser {
username: string;
role: 'admin' | 'user';
}

View File

@@ -0,0 +1,89 @@
/**
* Backup-related data shapes for Onebox
*/
import type { TPlatformServiceType } from './platform.ts';
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
export interface IRetentionPolicy {
hourly: number;
daily: number;
weekly: number;
monthly: number;
}
export const RETENTION_PRESETS = {
standard: { hourly: 0, daily: 7, weekly: 4, monthly: 12 },
frequent: { hourly: 24, daily: 7, weekly: 4, monthly: 12 },
minimal: { hourly: 0, daily: 3, weekly: 2, monthly: 6 },
longterm: { hourly: 0, daily: 14, weekly: 8, monthly: 24 },
} as const;
export type TRetentionPreset = keyof typeof RETENTION_PRESETS | 'custom';
export interface IBackup {
id?: number;
serviceId: number;
serviceName: string;
filename: string;
sizeBytes: number;
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[];
checksum: string;
scheduleId?: number;
}
export interface IBackupSchedule {
id?: number;
scopeType: TBackupScheduleScope;
scopePattern?: string;
serviceId?: number;
serviceName?: string;
cronExpression: string;
retention: IRetentionPolicy;
enabled: boolean;
lastRunAt: number | null;
nextRunAt: number | null;
lastStatus: 'success' | 'failed' | null;
lastError: string | null;
createdAt: number;
updatedAt: number;
}
export interface IBackupScheduleCreate {
scopeType: TBackupScheduleScope;
scopePattern?: string;
serviceName?: string;
cronExpression: string;
retention: IRetentionPolicy;
enabled?: boolean;
}
export interface IBackupScheduleUpdate {
cronExpression?: string;
retention?: IRetentionPolicy;
enabled?: boolean;
}
export interface IRestoreOptions {
mode: TBackupRestoreMode;
newServiceName?: string;
overwriteExisting?: boolean;
skipPlatformData?: boolean;
}
export interface IRestoreResult {
service: {
name: string;
status: string;
};
platformResourcesRestored: number;
warnings: string[];
}
export interface IBackupPasswordStatus {
isConfigured: boolean;
}

View File

@@ -0,0 +1,59 @@
/**
* Domain, DNS, and certificate data shapes for Onebox
*/
export interface IDomain {
id?: number;
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
cloudflareZoneId?: string;
isObsolete: boolean;
defaultWildcard: boolean;
createdAt: number;
updatedAt: number;
}
export interface ICertificate {
id?: number;
domainId: number;
certDomain: string;
isWildcard: boolean;
certPem: string;
keyPem: string;
fullchainPem: string;
expiryDate: number;
issuer: string;
isValid: boolean;
createdAt: number;
updatedAt: number;
}
export interface ICertRequirement {
id?: number;
domainId: number;
serviceId: number;
subdomain: string;
status: 'pending' | 'active' | 'renewing' | 'failed';
certificateId?: number;
createdAt: number;
updatedAt: number;
}
export interface IDomainDetail {
domain: IDomain;
certificates: ICertificate[];
requirements: ICertRequirement[];
serviceCount: number;
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
daysRemaining: number | null;
}
export interface IDnsRecord {
id?: number;
domain: string;
type: 'A' | 'AAAA' | 'CNAME';
value: string;
cloudflareID?: string;
createdAt: number;
updatedAt: number;
}

View File

@@ -0,0 +1,9 @@
export * from './auth.ts';
export * from './service.ts';
export * from './platform.ts';
export * from './network.ts';
export * from './domain.ts';
export * from './registry.ts';
export * from './backup.ts';
export * from './settings.ts';
export * from './system.ts';

View File

@@ -0,0 +1,64 @@
/**
* Network-related data shapes for Onebox
*/
export type TNetworkTargetType = 'service' | 'registry' | 'platform';
export interface INetworkTarget {
type: TNetworkTargetType;
name: string;
domain: string | null;
targetHost: string;
targetPort: number;
status: string;
}
export interface INetworkStats {
proxy: {
running: boolean;
httpPort: number;
httpsPort: number;
routes: number;
certificates: number;
};
logReceiver: {
running: boolean;
port: number;
clients: number;
connections: number;
sampleRate: number;
recentLogsCount: number;
};
}
export interface ITrafficStats {
requestCount: number;
errorCount: number;
avgResponseTime: number;
totalBytes: number;
statusCounts: Record<string, number>;
requestsPerMinute: number;
errorRate: number;
}
export interface ICaddyAccessLog {
ts: number;
request: {
remote_ip: string;
method: string;
host: string;
uri: string;
proto: string;
};
status: number;
duration: number;
size: number;
}
export interface INetworkLogMessage {
type: 'connected' | 'access_log' | 'filter_updated';
clientId?: string;
filter?: { domain?: string; sampleRate?: number };
data?: ICaddyAccessLog;
timestamp: number;
}

View File

@@ -0,0 +1,37 @@
/**
* Platform service data shapes for Onebox
*/
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export interface IPlatformRequirements {
mongodb?: boolean;
s3?: boolean;
clickhouse?: boolean;
}
export interface IPlatformService {
type: TPlatformServiceType;
displayName: string;
resourceTypes: TPlatformResourceType[];
status: TPlatformServiceStatus;
containerId?: string;
isCore?: boolean;
createdAt?: number;
updatedAt?: number;
}
export interface IPlatformResource {
id: number;
resourceType: TPlatformResourceType;
resourceName: string;
platformService: {
type: TPlatformServiceType;
name: string;
status: TPlatformServiceStatus;
};
envVars: Record<string, string>;
createdAt: number;
}

View File

@@ -0,0 +1,35 @@
/**
* Registry-related data shapes for Onebox
*/
export interface IRegistry {
id?: number;
url: string;
username: string;
createdAt: number;
}
export interface IRegistryToken {
id: number;
name: string;
type: 'global' | 'ci';
scope: 'all' | string[];
scopeDisplay: string;
expiresAt: number | null;
createdAt: number;
lastUsedAt: number | null;
createdBy: string;
isExpired: boolean;
}
export interface ICreateTokenRequest {
name: string;
type: 'global' | 'ci';
scope: 'all' | string[];
expiresIn: '30d' | '90d' | '365d' | 'never';
}
export interface ITokenCreatedResponse {
token: IRegistryToken;
plainToken: string;
}

View File

@@ -0,0 +1,82 @@
/**
* Service-related data shapes for Onebox
*/
import type { IPlatformRequirements } from './platform.ts';
export type TServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
export interface IService {
id?: number;
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
containerID?: string;
status: TServiceStatus;
createdAt: number;
updatedAt: number;
// Onebox Registry fields
useOneboxRegistry?: boolean;
registryRepository?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
// Platform service requirements
platformRequirements?: IPlatformRequirements;
// Backup settings
includeImageInBackup?: boolean;
}
export interface IServiceCreate {
name: string;
image: string;
port: number;
domain?: string;
envVars?: Record<string, string>;
useOneboxRegistry?: boolean;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
enableMongoDB?: boolean;
enableS3?: boolean;
enableClickHouse?: boolean;
}
export interface IServiceUpdate {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
}
export interface IContainerStats {
cpuPercent: number;
memoryUsed: number;
memoryLimit: number;
memoryPercent: number;
networkRx: number;
networkTx: number;
}
export interface IMetric {
id?: number;
serviceId: number;
timestamp: number;
cpuPercent: number;
memoryUsed: number;
memoryLimit: number;
networkRxBytes: number;
networkTxBytes: number;
}
export interface ILogEntry {
id?: number;
serviceId: number;
timestamp: number;
message: string;
level: 'info' | 'warn' | 'error' | 'debug';
source: 'stdout' | 'stderr';
}

View File

@@ -0,0 +1,14 @@
/**
* Settings data shapes for Onebox
*/
export interface ISettings {
cloudflareToken: string;
cloudflareZoneId: string;
autoRenewCerts: boolean;
renewalThreshold: number;
acmeEmail: string;
httpPort: number;
httpsPort: number;
forceHttps: boolean;
}

View File

@@ -0,0 +1,32 @@
/**
* System status data shapes for Onebox
*/
import type { TPlatformServiceType, TPlatformServiceStatus } from './platform.ts';
export interface ISystemStatus {
docker: {
running: boolean;
version: unknown;
};
reverseProxy: {
http: { running: boolean; port: number };
https: { running: boolean; port: number; certificates: number };
routes: number;
};
dns: { configured: boolean };
ssl: { configured: boolean; certificateCount: number };
services: { total: number; running: number; stopped: number };
platformServices: Array<{
type: TPlatformServiceType;
displayName: string;
status: TPlatformServiceStatus;
resourceCount: number;
}>;
certificateHealth: {
valid: number;
expiringSoon: number;
expired: number;
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
};
}

9
ts_interfaces/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export * from './plugins.ts';
// Data types
import * as data from './data/index.ts';
export { data };
// Request interfaces
import * as requests from './requests/index.ts';
export { requests };

6
ts_interfaces/plugins.ts Normal file
View File

@@ -0,0 +1,6 @@
// @apiglobal scope
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
export {
typedrequestInterfaces,
};

View File

@@ -0,0 +1,58 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_AdminLoginWithUsernameAndPassword
> {
method: 'adminLoginWithUsernameAndPassword';
request: {
username: string;
password: string;
};
response: {
identity?: data.IIdentity;
};
}
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_AdminLogout
> {
method: 'adminLogout';
request: {
identity: data.IIdentity;
};
response: {
ok: boolean;
};
}
export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_VerifyIdentity
> {
method: 'verifyIdentity';
request: {
identity: data.IIdentity;
};
response: {
valid: boolean;
identity?: data.IIdentity;
};
}
export interface IReq_ChangePassword extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ChangePassword
> {
method: 'changePassword';
request: {
identity: data.IIdentity;
currentPassword: string;
newPassword: string;
};
response: {
ok: boolean;
};
}

View File

@@ -0,0 +1,86 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetBackupSchedules extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetBackupSchedules
> {
method: 'getBackupSchedules';
request: {
identity: data.IIdentity;
};
response: {
schedules: data.IBackupSchedule[];
};
}
export interface IReq_CreateBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateBackupSchedule
> {
method: 'createBackupSchedule';
request: {
identity: data.IIdentity;
scheduleConfig: data.IBackupScheduleCreate;
};
response: {
schedule: data.IBackupSchedule;
};
}
export interface IReq_GetBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetBackupSchedule
> {
method: 'getBackupSchedule';
request: {
identity: data.IIdentity;
scheduleId: number;
};
response: {
schedule: data.IBackupSchedule;
};
}
export interface IReq_UpdateBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateBackupSchedule
> {
method: 'updateBackupSchedule';
request: {
identity: data.IIdentity;
scheduleId: number;
updates: data.IBackupScheduleUpdate;
};
response: {
schedule: data.IBackupSchedule;
};
}
export interface IReq_DeleteBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteBackupSchedule
> {
method: 'deleteBackupSchedule';
request: {
identity: data.IIdentity;
scheduleId: number;
};
response: {
ok: boolean;
};
}
export interface IReq_TriggerBackupSchedule extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_TriggerBackupSchedule
> {
method: 'triggerBackupSchedule';
request: {
identity: data.IIdentity;
scheduleId: number;
};
response: {
backup: data.IBackup;
};
}

View File

@@ -0,0 +1,73 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetBackups extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetBackups
> {
method: 'getBackups';
request: {
identity: data.IIdentity;
};
response: {
backups: data.IBackup[];
};
}
export interface IReq_GetBackup extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetBackup
> {
method: 'getBackup';
request: {
identity: data.IIdentity;
backupId: number;
};
response: {
backup: data.IBackup;
};
}
export interface IReq_DeleteBackup extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteBackup
> {
method: 'deleteBackup';
request: {
identity: data.IIdentity;
backupId: number;
};
response: {
ok: boolean;
};
}
export interface IReq_RestoreBackup extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RestoreBackup
> {
method: 'restoreBackup';
request: {
identity: data.IIdentity;
backupId: number;
options: data.IRestoreOptions;
};
response: {
result: data.IRestoreResult;
};
}
export interface IReq_DownloadBackup extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DownloadBackup
> {
method: 'downloadBackup';
request: {
identity: data.IIdentity;
backupId: number;
};
response: {
downloadUrl: string;
filename: string;
};
}

View File

@@ -0,0 +1,58 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDnsRecords
> {
method: 'getDnsRecords';
request: {
identity: data.IIdentity;
};
response: {
records: data.IDnsRecord[];
};
}
export interface IReq_CreateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateDnsRecord
> {
method: 'createDnsRecord';
request: {
identity: data.IIdentity;
domain: string;
type: 'A' | 'AAAA' | 'CNAME';
value: string;
};
response: {
record: data.IDnsRecord;
};
}
export interface IReq_DeleteDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteDnsRecord
> {
method: 'deleteDnsRecord';
request: {
identity: data.IIdentity;
domain: string;
};
response: {
ok: boolean;
};
}
export interface IReq_SyncDns extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SyncDns
> {
method: 'syncDns';
request: {
identity: data.IIdentity;
};
response: {
records: data.IDnsRecord[];
};
}

View File

@@ -0,0 +1,42 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDomains
> {
method: 'getDomains';
request: {
identity: data.IIdentity;
};
response: {
domains: data.IDomainDetail[];
};
}
export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetDomain
> {
method: 'getDomain';
request: {
identity: data.IIdentity;
domainName: string;
};
response: {
domain: data.IDomainDetail;
};
}
export interface IReq_SyncDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SyncDomains
> {
method: 'syncDomains';
request: {
identity: data.IIdentity;
};
response: {
domains: data.IDomainDetail[];
};
}

View File

@@ -0,0 +1,13 @@
export * from './admin.ts';
export * from './status.ts';
export * from './services.ts';
export * from './platform-services.ts';
export * from './ssl.ts';
export * from './domains.ts';
export * from './dns.ts';
export * from './registry.ts';
export * from './network.ts';
export * from './backups.ts';
export * from './backup-schedules.ts';
export * from './settings.ts';
export * from './logs.ts';

View File

@@ -0,0 +1,60 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetServiceLogStream extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServiceLogStream
> {
method: 'getServiceLogStream';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
logStream: plugins.typedrequestInterfaces.IVirtualStream;
};
}
export interface IReq_GetPlatformServiceLogStream extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetPlatformServiceLogStream
> {
method: 'getPlatformServiceLogStream';
request: {
identity: data.IIdentity;
serviceType: data.TPlatformServiceType;
};
response: {
logStream: plugins.typedrequestInterfaces.IVirtualStream;
};
}
export interface IReq_GetNetworkLogStream extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetNetworkLogStream
> {
method: 'getNetworkLogStream';
request: {
identity: data.IIdentity;
filter?: {
domain?: string;
sampleRate?: number;
};
};
response: {
logStream: plugins.typedrequestInterfaces.IVirtualStream;
};
}
export interface IReq_GetEventStream extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetEventStream
> {
method: 'getEventStream';
request: {
identity: data.IIdentity;
};
response: {
eventStream: plugins.typedrequestInterfaces.IVirtualStream;
};
}

View File

@@ -0,0 +1,41 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetNetworkTargets extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetNetworkTargets
> {
method: 'getNetworkTargets';
request: {
identity: data.IIdentity;
};
response: {
targets: data.INetworkTarget[];
};
}
export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetNetworkStats
> {
method: 'getNetworkStats';
request: {
identity: data.IIdentity;
};
response: {
stats: data.INetworkStats;
};
}
export interface IReq_GetTrafficStats extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetTrafficStats
> {
method: 'getTrafficStats';
request: {
identity: data.IIdentity;
};
response: {
stats: data.ITrafficStats;
};
}

View File

@@ -0,0 +1,71 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetPlatformServices extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetPlatformServices
> {
method: 'getPlatformServices';
request: {
identity: data.IIdentity;
};
response: {
platformServices: data.IPlatformService[];
};
}
export interface IReq_GetPlatformService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetPlatformService
> {
method: 'getPlatformService';
request: {
identity: data.IIdentity;
serviceType: data.TPlatformServiceType;
};
response: {
platformService: data.IPlatformService;
};
}
export interface IReq_StartPlatformService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_StartPlatformService
> {
method: 'startPlatformService';
request: {
identity: data.IIdentity;
serviceType: data.TPlatformServiceType;
};
response: {
platformService: data.IPlatformService;
};
}
export interface IReq_StopPlatformService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_StopPlatformService
> {
method: 'stopPlatformService';
request: {
identity: data.IIdentity;
serviceType: data.TPlatformServiceType;
};
response: {
platformService: data.IPlatformService;
};
}
export interface IReq_GetPlatformServiceStats extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetPlatformServiceStats
> {
method: 'getPlatformServiceStats';
request: {
identity: data.IIdentity;
serviceType: data.TPlatformServiceType;
};
response: {
stats: data.IContainerStats;
};
}

View File

@@ -0,0 +1,57 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetRegistryTags extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRegistryTags
> {
method: 'getRegistryTags';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
tags: string[];
};
}
export interface IReq_GetRegistryTokens extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRegistryTokens
> {
method: 'getRegistryTokens';
request: {
identity: data.IIdentity;
};
response: {
tokens: data.IRegistryToken[];
};
}
export interface IReq_CreateRegistryToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateRegistryToken
> {
method: 'createRegistryToken';
request: {
identity: data.IIdentity;
tokenConfig: data.ICreateTokenRequest;
};
response: {
result: data.ITokenCreatedResponse;
};
}
export interface IReq_DeleteRegistryToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteRegistryToken
> {
method: 'deleteRegistryToken';
request: {
identity: data.IIdentity;
tokenId: number;
};
response: {
ok: boolean;
};
}

View File

@@ -0,0 +1,214 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetServices extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServices
> {
method: 'getServices';
request: {
identity: data.IIdentity;
};
response: {
services: data.IService[];
};
}
export interface IReq_GetService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetService
> {
method: 'getService';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
service: data.IService;
};
}
export interface IReq_CreateService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateService
> {
method: 'createService';
request: {
identity: data.IIdentity;
serviceConfig: data.IServiceCreate;
};
response: {
service: data.IService;
};
}
export interface IReq_UpdateService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateService
> {
method: 'updateService';
request: {
identity: data.IIdentity;
serviceName: string;
updates: data.IServiceUpdate;
};
response: {
service: data.IService;
};
}
export interface IReq_DeleteService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteService
> {
method: 'deleteService';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
ok: boolean;
};
}
export interface IReq_StartService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_StartService
> {
method: 'startService';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
service: data.IService;
};
}
export interface IReq_StopService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_StopService
> {
method: 'stopService';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
service: data.IService;
};
}
export interface IReq_RestartService extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RestartService
> {
method: 'restartService';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
service: data.IService;
};
}
export interface IReq_GetServiceLogs extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServiceLogs
> {
method: 'getServiceLogs';
request: {
identity: data.IIdentity;
serviceName: string;
tail?: number;
};
response: {
logs: string;
};
}
export interface IReq_GetServiceStats extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServiceStats
> {
method: 'getServiceStats';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
stats: data.IContainerStats;
};
}
export interface IReq_GetServiceMetrics extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServiceMetrics
> {
method: 'getServiceMetrics';
request: {
identity: data.IIdentity;
serviceName: string;
limit?: number;
};
response: {
metrics: data.IMetric[];
};
}
export interface IReq_GetServicePlatformResources extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServicePlatformResources
> {
method: 'getServicePlatformResources';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
resources: data.IPlatformResource[];
};
}
export interface IReq_GetServiceBackups extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServiceBackups
> {
method: 'getServiceBackups';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
backups: data.IBackup[];
};
}
export interface IReq_CreateServiceBackup extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateServiceBackup
> {
method: 'createServiceBackup';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
backup: data.IBackup;
};
}
export interface IReq_GetServiceBackupSchedules extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetServiceBackupSchedules
> {
method: 'getServiceBackupSchedules';
request: {
identity: data.IIdentity;
serviceName: string;
};
response: {
schedules: data.IBackupSchedule[];
};
}

View File

@@ -0,0 +1,56 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetSettings extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSettings
> {
method: 'getSettings';
request: {
identity: data.IIdentity;
};
response: {
settings: data.ISettings;
};
}
export interface IReq_UpdateSettings extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateSettings
> {
method: 'updateSettings';
request: {
identity: data.IIdentity;
settings: Partial<data.ISettings>;
};
response: {
settings: data.ISettings;
};
}
export interface IReq_SetBackupPassword extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SetBackupPassword
> {
method: 'setBackupPassword';
request: {
identity: data.IIdentity;
password: string;
};
response: {
ok: boolean;
};
}
export interface IReq_GetBackupPasswordStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetBackupPasswordStatus
> {
method: 'getBackupPasswordStatus';
request: {
identity: data.IIdentity;
};
response: {
status: data.IBackupPasswordStatus;
};
}

View File

@@ -0,0 +1,57 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_ObtainCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ObtainCertificate
> {
method: 'obtainCertificate';
request: {
identity: data.IIdentity;
domain: string;
};
response: {
certificate: data.ICertificate;
};
}
export interface IReq_ListCertificates extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListCertificates
> {
method: 'listCertificates';
request: {
identity: data.IIdentity;
};
response: {
certificates: data.ICertificate[];
};
}
export interface IReq_GetCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetCertificate
> {
method: 'getCertificate';
request: {
identity: data.IIdentity;
domain: string;
};
response: {
certificate: data.ICertificate;
};
}
export interface IReq_RenewCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RenewCertificate
> {
method: 'renewCertificate';
request: {
identity: data.IIdentity;
domain: string;
};
response: {
certificate: data.ICertificate;
};
}

View File

@@ -0,0 +1,15 @@
import * as plugins from '../plugins.ts';
import * as data from '../data/index.ts';
export interface IReq_GetSystemStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSystemStatus
> {
method: 'getSystemStatus';
request: {
identity: data.IIdentity;
};
response: {
status: data.ISystemStatus;
};
}

View File

@@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.12.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

919
ts_web/appstate.ts Normal file
View File

@@ -0,0 +1,919 @@
import * as plugins from './plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
// ============================================================================
// Smartstate instance
// ============================================================================
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
// ============================================================================
// State Part Interfaces
// ============================================================================
export interface ILoginState {
identity: interfaces.data.IIdentity | null;
isLoggedIn: boolean;
}
export interface ISystemState {
status: interfaces.data.ISystemStatus | null;
}
export interface IServicesState {
services: interfaces.data.IService[];
currentService: interfaces.data.IService | null;
currentServiceLogs: interfaces.data.ILogEntry[];
currentServiceStats: interfaces.data.IContainerStats | null;
platformServices: interfaces.data.IPlatformService[];
currentPlatformService: interfaces.data.IPlatformService | null;
}
export interface INetworkState {
targets: interfaces.data.INetworkTarget[];
stats: interfaces.data.INetworkStats | null;
trafficStats: interfaces.data.ITrafficStats | null;
dnsRecords: interfaces.data.IDnsRecord[];
domains: interfaces.data.IDomainDetail[];
certificates: interfaces.data.ICertificate[];
}
export interface IRegistriesState {
tokens: interfaces.data.IRegistryToken[];
registryStatus: { running: boolean; port: number } | null;
}
export interface IBackupsState {
backups: interfaces.data.IBackup[];
schedules: interfaces.data.IBackupSchedule[];
}
export interface ISettingsState {
settings: interfaces.data.ISettings | null;
backupPasswordConfigured: boolean;
}
export interface IUiState {
activeView: string;
autoRefresh: boolean;
refreshInterval: number;
}
// ============================================================================
// State Parts
// ============================================================================
export const loginStatePart = await appState.getStatePart<ILoginState>(
'login',
{
identity: null,
isLoggedIn: false,
},
'persistent',
);
export const systemStatePart = await appState.getStatePart<ISystemState>(
'system',
{
status: null,
},
'soft',
);
export const servicesStatePart = await appState.getStatePart<IServicesState>(
'services',
{
services: [],
currentService: null,
currentServiceLogs: [],
currentServiceStats: null,
platformServices: [],
currentPlatformService: null,
},
'soft',
);
export const networkStatePart = await appState.getStatePart<INetworkState>(
'network',
{
targets: [],
stats: null,
trafficStats: null,
dnsRecords: [],
domains: [],
certificates: [],
},
'soft',
);
export const registriesStatePart = await appState.getStatePart<IRegistriesState>(
'registries',
{
tokens: [],
registryStatus: null,
},
'soft',
);
export const backupsStatePart = await appState.getStatePart<IBackupsState>(
'backups',
{
backups: [],
schedules: [],
},
'soft',
);
export const settingsStatePart = await appState.getStatePart<ISettingsState>(
'settings',
{
settings: null,
backupPasswordConfigured: false,
},
'soft',
);
export const uiStatePart = await appState.getStatePart<IUiState>(
'ui',
{
activeView: 'dashboard',
autoRefresh: true,
refreshInterval: 30000,
},
);
// ============================================================================
// Helpers
// ============================================================================
interface IActionContext {
identity: interfaces.data.IIdentity | null;
}
const getActionContext = (): IActionContext => {
return { identity: loginStatePart.getState().identity };
};
// ============================================================================
// Login Actions
// ============================================================================
export const loginAction = loginStatePart.createAction<{
username: string;
password: string;
}>(async (statePartArg, dataArg) => {
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
>('/typedrequest', 'adminLoginWithUsernameAndPassword');
const response = await typedRequest.fire({
username: dataArg.username,
password: dataArg.password,
});
return {
identity: response.identity,
isLoggedIn: true,
};
} catch (err) {
console.error('Login failed:', err);
return { identity: null, isLoggedIn: false };
}
});
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
if (context.identity) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLogout
>('/typedrequest', 'adminLogout');
await typedRequest.fire({ identity: context.identity });
}
} catch (err) {
console.error('Logout error:', err);
}
return { identity: null, isLoggedIn: false };
});
// ============================================================================
// System Status Actions
// ============================================================================
export const fetchSystemStatusAction = systemStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSystemStatus
>('/typedrequest', 'getSystemStatus');
const response = await typedRequest.fire({ identity: context.identity! });
return { status: response.status };
} catch (err) {
console.error('Failed to fetch system status:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Services Actions
// ============================================================================
export const fetchServicesAction = servicesStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServices
>('/typedrequest', 'getServices');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), services: response.services };
} catch (err) {
console.error('Failed to fetch services:', err);
return statePartArg.getState();
}
});
export const fetchServiceAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetService
>('/typedrequest', 'getService');
const response = await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
return { ...statePartArg.getState(), currentService: response.service };
} catch (err) {
console.error('Failed to fetch service:', err);
return statePartArg.getState();
}
},
);
export const createServiceAction = servicesStatePart.createAction<{
config: interfaces.data.IServiceCreate;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateService
>('/typedrequest', 'createService');
await typedRequest.fire({
identity: context.identity!,
serviceConfig: dataArg.config,
});
// Re-fetch services list
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServices
>('/typedrequest', 'getServices');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), services: listResp.services };
} catch (err) {
console.error('Failed to create service:', err);
return statePartArg.getState();
}
});
export const deleteServiceAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteService
>('/typedrequest', 'deleteService');
await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
const state = statePartArg.getState();
return {
...state,
services: state.services.filter((s) => s.name !== dataArg.name),
currentService: null,
};
} catch (err) {
console.error('Failed to delete service:', err);
return statePartArg.getState();
}
},
);
export const startServiceAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_StartService
>('/typedrequest', 'startService');
await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
// Re-fetch services
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServices
>('/typedrequest', 'getServices');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), services: listResp.services };
} catch (err) {
console.error('Failed to start service:', err);
return statePartArg.getState();
}
},
);
export const stopServiceAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_StopService
>('/typedrequest', 'stopService');
await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServices
>('/typedrequest', 'getServices');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), services: listResp.services };
} catch (err) {
console.error('Failed to stop service:', err);
return statePartArg.getState();
}
},
);
export const restartServiceAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RestartService
>('/typedrequest', 'restartService');
await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServices
>('/typedrequest', 'getServices');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), services: listResp.services };
} catch (err) {
console.error('Failed to restart service:', err);
return statePartArg.getState();
}
},
);
export const fetchServiceLogsAction = servicesStatePart.createAction<{
name: string;
lines?: number;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServiceLogs
>('/typedrequest', 'getServiceLogs');
const response = await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
tail: dataArg.lines || 200,
});
return { ...statePartArg.getState(), currentServiceLogs: response.logs };
} catch (err) {
console.error('Failed to fetch service logs:', err);
return statePartArg.getState();
}
});
export const fetchServiceStatsAction = servicesStatePart.createAction<{ name: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServiceStats
>('/typedrequest', 'getServiceStats');
const response = await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.name,
});
return { ...statePartArg.getState(), currentServiceStats: response.stats };
} catch (err) {
console.error('Failed to fetch service stats:', err);
return statePartArg.getState();
}
},
);
// ============================================================================
// Platform Services Actions
// ============================================================================
export const fetchPlatformServicesAction = servicesStatePart.createAction(
async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPlatformServices
>('/typedrequest', 'getPlatformServices');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), platformServices: response.platformServices };
} catch (err) {
console.error('Failed to fetch platform services:', err);
return statePartArg.getState();
}
},
);
export const startPlatformServiceAction = servicesStatePart.createAction<{
serviceType: interfaces.data.TPlatformServiceType;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_StartPlatformService
>('/typedrequest', 'startPlatformService');
await typedRequest.fire({
identity: context.identity!,
serviceType: dataArg.serviceType,
});
// Re-fetch platform services
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPlatformServices
>('/typedrequest', 'getPlatformServices');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), platformServices: listResp.platformServices };
} catch (err) {
console.error('Failed to start platform service:', err);
return statePartArg.getState();
}
});
export const stopPlatformServiceAction = servicesStatePart.createAction<{
serviceType: interfaces.data.TPlatformServiceType;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_StopPlatformService
>('/typedrequest', 'stopPlatformService');
await typedRequest.fire({
identity: context.identity!,
serviceType: dataArg.serviceType,
});
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPlatformServices
>('/typedrequest', 'getPlatformServices');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), platformServices: listResp.platformServices };
} catch (err) {
console.error('Failed to stop platform service:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Network Actions
// ============================================================================
export const fetchNetworkTargetsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkTargets
>('/typedrequest', 'getNetworkTargets');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), targets: response.targets };
} catch (err) {
console.error('Failed to fetch network targets:', err);
return statePartArg.getState();
}
});
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkStats
>('/typedrequest', 'getNetworkStats');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), stats: response.stats };
} catch (err) {
console.error('Failed to fetch network stats:', err);
return statePartArg.getState();
}
});
export const fetchTrafficStatsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetTrafficStats
>('/typedrequest', 'getTrafficStats');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), trafficStats: response.stats };
} catch (err) {
console.error('Failed to fetch traffic stats:', err);
return statePartArg.getState();
}
});
export const fetchDnsRecordsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDnsRecords
>('/typedrequest', 'getDnsRecords');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), dnsRecords: response.records };
} catch (err) {
console.error('Failed to fetch DNS records:', err);
return statePartArg.getState();
}
});
export const syncDnsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SyncDns
>('/typedrequest', 'syncDns');
await typedRequest.fire({ identity: context.identity! });
// Re-fetch DNS records
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDnsRecords
>('/typedrequest', 'getDnsRecords');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), dnsRecords: listResp.records };
} catch (err) {
console.error('Failed to sync DNS:', err);
return statePartArg.getState();
}
});
export const fetchDomainsAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDomains
>('/typedrequest', 'getDomains');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), domains: response.domains };
} catch (err) {
console.error('Failed to fetch domains:', err);
return statePartArg.getState();
}
});
export const fetchCertificatesAction = networkStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListCertificates
>('/typedrequest', 'listCertificates');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), certificates: response.certificates };
} catch (err) {
console.error('Failed to fetch certificates:', err);
return statePartArg.getState();
}
});
export const renewCertificateAction = networkStatePart.createAction<{ domain: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RenewCertificate
>('/typedrequest', 'renewCertificate');
await typedRequest.fire({
identity: context.identity!,
domain: dataArg.domain,
});
// Re-fetch certificates
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListCertificates
>('/typedrequest', 'listCertificates');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), certificates: listResp.certificates };
} catch (err) {
console.error('Failed to renew certificate:', err);
return statePartArg.getState();
}
},
);
// ============================================================================
// Registry Actions
// ============================================================================
export const fetchRegistryTokensAction = registriesStatePart.createAction(
async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetRegistryTokens
>('/typedrequest', 'getRegistryTokens');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), tokens: response.tokens };
} catch (err) {
console.error('Failed to fetch registry tokens:', err);
return statePartArg.getState();
}
},
);
export const createRegistryTokenAction = registriesStatePart.createAction<{
token: interfaces.data.ICreateTokenRequest;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateRegistryToken
>('/typedrequest', 'createRegistryToken');
await typedRequest.fire({
identity: context.identity!,
token: dataArg.token,
});
// Re-fetch tokens
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetRegistryTokens
>('/typedrequest', 'getRegistryTokens');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), tokens: listResp.tokens };
} catch (err) {
console.error('Failed to create registry token:', err);
return statePartArg.getState();
}
});
export const deleteRegistryTokenAction = registriesStatePart.createAction<{
tokenId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteRegistryToken
>('/typedrequest', 'deleteRegistryToken');
await typedRequest.fire({
identity: context.identity!,
tokenId: dataArg.tokenId,
});
const state = statePartArg.getState();
return {
...state,
tokens: state.tokens.filter((t) => t.id !== dataArg.tokenId),
};
} catch (err) {
console.error('Failed to delete registry token:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Backups Actions
// ============================================================================
export const fetchBackupsAction = backupsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBackups
>('/typedrequest', 'getBackups');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), backups: response.backups };
} catch (err) {
console.error('Failed to fetch backups:', err);
return statePartArg.getState();
}
});
export const deleteBackupAction = backupsStatePart.createAction<{ backupId: number }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteBackup
>('/typedrequest', 'deleteBackup');
await typedRequest.fire({
identity: context.identity!,
backupId: dataArg.backupId,
});
const state = statePartArg.getState();
return {
...state,
backups: state.backups.filter((b) => b.id !== dataArg.backupId),
};
} catch (err) {
console.error('Failed to delete backup:', err);
return statePartArg.getState();
}
},
);
export const fetchSchedulesAction = backupsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBackupSchedules
>('/typedrequest', 'getBackupSchedules');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), schedules: response.schedules };
} catch (err) {
console.error('Failed to fetch schedules:', err);
return statePartArg.getState();
}
});
export const createScheduleAction = backupsStatePart.createAction<{
config: interfaces.data.IBackupScheduleCreate;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateBackupSchedule
>('/typedrequest', 'createBackupSchedule');
await typedRequest.fire({
identity: context.identity!,
scheduleConfig: dataArg.config,
});
// Re-fetch schedules
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBackupSchedules
>('/typedrequest', 'getBackupSchedules');
const listResp = await listReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), schedules: listResp.schedules };
} catch (err) {
console.error('Failed to create schedule:', err);
return statePartArg.getState();
}
});
export const deleteScheduleAction = backupsStatePart.createAction<{ scheduleId: number }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteBackupSchedule
>('/typedrequest', 'deleteBackupSchedule');
await typedRequest.fire({
identity: context.identity!,
scheduleId: dataArg.scheduleId,
});
const state = statePartArg.getState();
return {
...state,
schedules: state.schedules.filter((s) => s.id !== dataArg.scheduleId),
};
} catch (err) {
console.error('Failed to delete schedule:', err);
return statePartArg.getState();
}
},
);
export const triggerScheduleAction = backupsStatePart.createAction<{ scheduleId: number }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_TriggerBackupSchedule
>('/typedrequest', 'triggerBackupSchedule');
await typedRequest.fire({
identity: context.identity!,
scheduleId: dataArg.scheduleId,
});
// Re-fetch backups
const backupsReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBackups
>('/typedrequest', 'getBackups');
const backupsResp = await backupsReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), backups: backupsResp.backups };
} catch (err) {
console.error('Failed to trigger schedule:', err);
return statePartArg.getState();
}
},
);
// ============================================================================
// Settings Actions
// ============================================================================
export const fetchSettingsAction = settingsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const [settingsResp, passwordResp] = await Promise.all([
new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSettings
>('/typedrequest', 'getSettings').fire({ identity: context.identity! }),
new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBackupPasswordStatus
>('/typedrequest', 'getBackupPasswordStatus').fire({ identity: context.identity! }),
]);
return {
settings: settingsResp.settings,
backupPasswordConfigured: passwordResp.status.isConfigured,
};
} catch (err) {
console.error('Failed to fetch settings:', err);
return statePartArg.getState();
}
});
export const updateSettingsAction = settingsStatePart.createAction<{
settings: Partial<interfaces.data.ISettings>;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateSettings
>('/typedrequest', 'updateSettings');
const response = await typedRequest.fire({
identity: context.identity!,
settings: dataArg.settings,
});
return { ...statePartArg.getState(), settings: response.settings };
} catch (err) {
console.error('Failed to update settings:', err);
return statePartArg.getState();
}
});
export const setBackupPasswordAction = settingsStatePart.createAction<{ password: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SetBackupPassword
>('/typedrequest', 'setBackupPassword');
await typedRequest.fire({
identity: context.identity!,
password: dataArg.password,
});
return { ...statePartArg.getState(), backupPasswordConfigured: true };
} catch (err) {
console.error('Failed to set backup password:', err);
return statePartArg.getState();
}
},
);
// ============================================================================
// UI Actions
// ============================================================================
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), activeView: dataArg.view };
},
);
export const toggleAutoRefreshAction = uiStatePart.createAction(async (statePartArg) => {
const state = statePartArg.getState();
return { ...state, autoRefresh: !state.autoRefresh };
});
// ============================================================================
// Auto-refresh system
// ============================================================================
let refreshIntervalHandle: ReturnType<typeof setInterval> | null = null;
const dispatchCombinedRefreshAction = async () => {
const loginState = loginStatePart.getState();
if (!loginState.isLoggedIn) return;
try {
await systemStatePart.dispatchAction(fetchSystemStatusAction, null);
} catch (err) {
// Silently fail on auto-refresh
}
};
const startAutoRefresh = () => {
const uiState = uiStatePart.getState();
const loginState = loginStatePart.getState();
if (uiState.autoRefresh && loginState.isLoggedIn) {
if (refreshIntervalHandle) {
clearInterval(refreshIntervalHandle);
}
refreshIntervalHandle = setInterval(() => {
dispatchCombinedRefreshAction();
}, uiState.refreshInterval);
} else {
if (refreshIntervalHandle) {
clearInterval(refreshIntervalHandle);
refreshIntervalHandle = null;
}
}
};
uiStatePart.select((s) => s).subscribe(() => startAutoRefresh());
loginStatePart.select((s) => s).subscribe(() => startAutoRefresh());
startAutoRefresh();

13
ts_web/elements/index.ts Normal file
View File

@@ -0,0 +1,13 @@
// Shared utilities
export * from './shared/index.js';
// App shell
export * from './ob-app-shell.js';
// View elements
export * from './ob-view-dashboard.js';
export * from './ob-view-services.js';
export * from './ob-view-network.js';
export * from './ob-view-registries.js';
export * from './ob-view-tokens.js';
export * from './ob-view-settings.js';

View File

@@ -0,0 +1,207 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../ts_interfaces/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import type { ObViewDashboard } from './ob-view-dashboard.js';
import type { ObViewServices } from './ob-view-services.js';
import type { ObViewNetwork } from './ob-view-network.js';
import type { ObViewRegistries } from './ob-view-registries.js';
import type { ObViewTokens } from './ob-view-tokens.js';
import type { ObViewSettings } from './ob-view-settings.js';
@customElement('ob-app-shell')
export class ObAppShell extends DeesElement {
@state()
accessor loginState: appstate.ILoginState = { identity: null, isLoggedIn: false };
@state()
accessor uiState: appstate.IUiState = {
activeView: 'dashboard',
autoRefresh: true,
refreshInterval: 30000,
};
@state()
accessor loginLoading: boolean = false;
@state()
accessor loginError: string = '';
private viewTabs = [
{ name: 'Dashboard', element: (async () => (await import('./ob-view-dashboard.js')).ObViewDashboard)() },
{ name: 'Services', element: (async () => (await import('./ob-view-services.js')).ObViewServices)() },
{ name: 'Network', element: (async () => (await import('./ob-view-network.js')).ObViewNetwork)() },
{ name: 'Registries', element: (async () => (await import('./ob-view-registries.js')).ObViewRegistries)() },
{ name: 'Tokens', element: (async () => (await import('./ob-view-tokens.js')).ObViewTokens)() },
{ name: 'Settings', element: (async () => (await import('./ob-view-settings.js')).ObViewSettings)() },
];
private resolvedViewTabs: Array<{ name: string; element: any }> = [];
constructor() {
super();
document.title = 'Onebox';
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg)
.subscribe((loginState) => {
this.loginState = loginState;
if (loginState.isLoggedIn) {
appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null);
}
});
this.rxSubscriptions.push(loginSubscription);
const uiSubscription = appstate.uiStatePart
.select((stateArg) => stateArg)
.subscribe((uiState) => {
this.uiState = uiState;
this.syncAppdashView(uiState.activeView);
});
this.rxSubscriptions.push(uiSubscription);
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
}
.maincontainer {
width: 100%;
height: 100vh;
}
`,
];
public render(): TemplateResult {
return html`
<div class="maincontainer">
<dees-simple-login name="Onebox">
<dees-simple-appdash
name="Onebox"
.viewTabs=${this.resolvedViewTabs}
>
</dees-simple-appdash>
</dees-simple-login>
</div>
`;
}
public async firstUpdated() {
// Resolve async view tab imports
this.resolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({
name: tab.name,
element: await tab.element,
})),
);
this.requestUpdate();
await this.updateComplete;
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
if (simpleLogin) {
simpleLogin.addEventListener('login', (e: CustomEvent) => {
this.login(e.detail.data.username, e.detail.data.password);
});
}
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => {
const viewName = e.detail.view.name.toLowerCase();
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: viewName });
});
appDash.addEventListener('logout', async () => {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
});
}
// Load the initial view on the appdash now that tabs are resolved
// (appdash's own firstUpdated already fired when viewTabs was still empty)
if (appDash && this.resolvedViewTabs.length > 0) {
const initialView = this.resolvedViewTabs.find(
(t) => t.name.toLowerCase() === this.uiState.activeView,
) || this.resolvedViewTabs[0];
await appDash.loadView(initialView);
}
// Check for stored session (persistent login state)
const loginState = appstate.loginStatePart.getState();
if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) {
// Validate token with server before switching to dashboard
// (server may have restarted with a new JWT secret)
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSystemStatus
>('/typedrequest', 'getSystemStatus');
const response = await typedRequest.fire({ identity: loginState.identity });
// Token is valid - switch to dashboard
appstate.systemStatePart.setState({ status: response.status });
this.loginState = loginState;
if (simpleLogin) {
await simpleLogin.switchToSlottedContent();
}
} catch (err) {
// Token rejected by server - clear session
console.warn('Stored session invalid, returning to login:', err);
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
} else {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
}
}
private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise;
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin?.shadowRoot?.querySelector('dees-form') as any;
if (form) {
form.setStatus('pending', 'Logging in...');
}
const newState = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
username,
password,
});
if (newState.identity) {
if (form) {
form.setStatus('success', 'Logged in!');
}
if (simpleLogin) {
await simpleLogin.switchToSlottedContent();
}
await appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null);
} else {
if (form) {
form.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000);
form.reset();
}
}
}
private syncAppdashView(viewName: string): void {
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash || this.resolvedViewTabs.length === 0) return;
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
if (!targetTab) return;
// Use appdash's own loadView method for proper view management
appDash.loadView(targetTab);
}
}

View File

@@ -0,0 +1,164 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ob-view-dashboard')
export class ObViewDashboard extends DeesElement {
@state()
accessor systemState: appstate.ISystemState = { status: null };
@state()
accessor servicesState: appstate.IServicesState = {
services: [],
currentService: null,
currentServiceLogs: [],
currentServiceStats: null,
platformServices: [],
currentPlatformService: null,
};
@state()
accessor networkState: appstate.INetworkState = {
targets: [],
stats: null,
trafficStats: null,
dnsRecords: [],
domains: [],
certificates: [],
};
constructor() {
super();
const systemSub = appstate.systemStatePart
.select((s) => s)
.subscribe((newState) => {
this.systemState = newState;
});
this.rxSubscriptions.push(systemSub);
const servicesSub = appstate.servicesStatePart
.select((s) => s)
.subscribe((newState) => {
this.servicesState = newState;
});
this.rxSubscriptions.push(servicesSub);
const networkSub = appstate.networkStatePart
.select((s) => s)
.subscribe((newState) => {
this.networkState = newState;
});
this.rxSubscriptions.push(networkSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css``,
];
async connectedCallback() {
super.connectedCallback();
await Promise.all([
appstate.systemStatePart.dispatchAction(appstate.fetchSystemStatusAction, null),
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchCertificatesAction, null),
]);
}
public render(): TemplateResult {
const status = this.systemState.status;
const services = this.servicesState.services;
const platformServices = this.servicesState.platformServices;
const networkStats = this.networkState.stats;
const certificates = this.networkState.certificates;
const runningServices = services.filter((s) => s.status === 'running').length;
const stoppedServices = services.filter((s) => s.status === 'stopped').length;
const validCerts = certificates.filter((c) => c.isValid).length;
const expiringCerts = certificates.filter(
(c) => c.isValid && c.expiresAt && c.expiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000,
).length;
const expiredCerts = certificates.filter((c) => !c.isValid).length;
return html`
<ob-sectionheading>Dashboard</ob-sectionheading>
<sz-dashboard-view
.data=${{
cluster: {
totalServices: services.length,
running: runningServices,
stopped: stoppedServices,
dockerStatus: status?.docker?.running ? 'running' : 'stopped',
},
resourceUsage: {
cpu: status?.docker?.cpuUsage || 0,
memoryUsed: status?.docker?.memoryUsage || 0,
memoryTotal: status?.docker?.memoryTotal || 0,
networkIn: 0,
networkOut: 0,
topConsumers: [],
},
platformServices: platformServices.map((ps) => ({
name: ps.displayName,
status: ps.status === 'running' ? 'running' : 'stopped',
running: ps.status === 'running',
})),
traffic: {
requests: 0,
errors: 0,
errorPercent: 0,
avgResponse: 0,
reqPerMin: 0,
status2xx: 0,
status3xx: 0,
status4xx: 0,
status5xx: 0,
},
proxy: {
httpPort: networkStats?.proxy?.httpPort || 80,
httpsPort: networkStats?.proxy?.httpsPort || 443,
httpActive: networkStats?.proxy?.running || false,
httpsActive: networkStats?.proxy?.running || false,
routeCount: networkStats?.proxy?.routes || 0,
},
certificates: {
valid: validCerts,
expiring: expiringCerts,
expired: expiredCerts,
},
dnsConfigured: true,
acmeConfigured: true,
quickActions: [
{ label: 'Deploy Service', icon: 'lucide:Plus', primary: true },
{ label: 'Add Domain', icon: 'lucide:Globe' },
{ label: 'View Logs', icon: 'lucide:FileText' },
],
}}
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
></sz-dashboard-view>
`;
}
private handleQuickAction(e: CustomEvent) {
const action = e.detail?.action || e.detail?.label;
if (action === 'Deploy Service') {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' });
} else if (action === 'Add Domain') {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'network' });
}
}
}

View File

@@ -0,0 +1,197 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ob-view-network')
export class ObViewNetwork extends DeesElement {
@state()
accessor networkState: appstate.INetworkState = {
targets: [],
stats: null,
trafficStats: null,
dnsRecords: [],
domains: [],
certificates: [],
};
@state()
accessor currentTab: 'proxy' | 'dns' | 'domains' | 'domain-detail' = 'proxy';
@state()
accessor selectedDomain: string = '';
constructor() {
super();
const networkSub = appstate.networkStatePart
.select((s) => s)
.subscribe((newState) => {
this.networkState = newState;
});
this.rxSubscriptions.push(networkSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css``,
];
async connectedCallback() {
super.connectedCallback();
await Promise.all([
appstate.networkStatePart.dispatchAction(appstate.fetchNetworkTargetsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchTrafficStatsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchDnsRecordsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchDomainsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchCertificatesAction, null),
]);
}
public render(): TemplateResult {
switch (this.currentTab) {
case 'dns':
return this.renderDnsView();
case 'domains':
return this.renderDomainsView();
case 'domain-detail':
return this.renderDomainDetailView();
default:
return this.renderProxyView();
}
}
private renderProxyView(): TemplateResult {
const stats = this.networkState.stats;
return html`
<ob-sectionheading>Network</ob-sectionheading>
<sz-network-proxy-view
.proxyStatus=${stats?.proxy?.running ? 'running' : 'stopped'}
.routeCount=${String(stats?.proxy?.routes || 0)}
.certificateCount=${String(stats?.proxy?.certificates || 0)}
.targetCount=${String(this.networkState.targets.length)}
.targets=${this.networkState.targets.map((t) => ({
type: t.type,
name: t.name,
domain: t.domain,
target: `${t.targetHost}:${t.targetPort}`,
status: t.status,
}))}
.logs=${[]}
@refresh=${() => {
appstate.networkStatePart.dispatchAction(appstate.fetchNetworkTargetsAction, null);
appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null);
}}
></sz-network-proxy-view>
`;
}
private renderDnsView(): TemplateResult {
return html`
<ob-sectionheading>DNS Records</ob-sectionheading>
<sz-network-dns-view
.records=${this.networkState.dnsRecords}
@sync=${() => {
appstate.networkStatePart.dispatchAction(appstate.syncDnsAction, null);
}}
@delete=${(e: CustomEvent) => {
console.log('Delete DNS record:', e.detail);
}}
></sz-network-dns-view>
`;
}
private renderDomainsView(): TemplateResult {
const certs = this.networkState.certificates;
return html`
<ob-sectionheading>Domains</ob-sectionheading>
<sz-network-domains-view
.domains=${this.networkState.domains.map((d) => {
const cert = certs.find((c) => c.certDomain === d.domain);
let certStatus: 'valid' | 'expiring' | 'expired' | 'pending' = 'pending';
if (cert) {
if (!cert.isValid) certStatus = 'expired';
else if (cert.expiresAt && cert.expiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000)
certStatus = 'expiring';
else certStatus = 'valid';
}
return {
domain: d.domain,
provider: 'cloudflare',
serviceCount: d.services?.length || 0,
certificateStatus: certStatus,
};
})}
@sync=${() => {
appstate.networkStatePart.dispatchAction(appstate.fetchDomainsAction, null);
}}
@view=${(e: CustomEvent) => {
this.selectedDomain = e.detail.domain || e.detail;
this.currentTab = 'domain-detail';
}}
></sz-network-domains-view>
`;
}
private renderDomainDetailView(): TemplateResult {
const domainDetail = this.networkState.domains.find(
(d) => d.domain === this.selectedDomain,
);
const cert = this.networkState.certificates.find(
(c) => c.certDomain === this.selectedDomain,
);
return html`
<ob-sectionheading>Domain Details</ob-sectionheading>
<sz-domain-detail-view
.domain=${domainDetail
? {
id: this.selectedDomain,
name: this.selectedDomain,
status: 'active',
verified: true,
createdAt: '',
}
: null}
.certificate=${cert
? {
id: cert.domainId,
domain: cert.certDomain,
issuer: 'Let\'s Encrypt',
validFrom: cert.issuedAt ? new Date(cert.issuedAt).toISOString() : '',
validUntil: cert.expiresAt ? new Date(cert.expiresAt).toISOString() : '',
daysRemaining: cert.expiresAt
? Math.floor((cert.expiresAt - Date.now()) / (24 * 60 * 60 * 1000))
: 0,
status: cert.isValid ? 'valid' : 'expired',
autoRenew: true,
}
: null}
.dnsRecords=${this.networkState.dnsRecords
.filter((r) => r.domain?.includes(this.selectedDomain))
.map((r) => ({
id: r.id || '',
type: r.type,
name: r.domain,
value: r.value,
ttl: 3600,
}))}
@renew-certificate=${() => {
appstate.networkStatePart.dispatchAction(appstate.renewCertificateAction, {
domain: this.selectedDomain,
});
}}
></sz-domain-detail-view>
`;
}
}

View File

@@ -0,0 +1,84 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ob-view-registries')
export class ObViewRegistries extends DeesElement {
@state()
accessor registriesState: appstate.IRegistriesState = {
tokens: [],
registryStatus: null,
};
@state()
accessor currentTab: 'onebox' | 'external' = 'onebox';
constructor() {
super();
const registriesSub = appstate.registriesStatePart
.select((s) => s)
.subscribe((newState) => {
this.registriesState = newState;
});
this.rxSubscriptions.push(registriesSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css``,
];
async connectedCallback() {
super.connectedCallback();
await appstate.registriesStatePart.dispatchAction(
appstate.fetchRegistryTokensAction,
null,
);
}
public render(): TemplateResult {
switch (this.currentTab) {
case 'external':
return this.renderExternalView();
default:
return this.renderOneboxView();
}
}
private renderOneboxView(): TemplateResult {
return html`
<ob-sectionheading>Registries</ob-sectionheading>
<sz-registry-advertisement
.status=${'running'}
.registryUrl=${'localhost:5000'}
@manage-tokens=${() => {
// tokens are managed via the tokens view
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'tokens' });
}}
></sz-registry-advertisement>
`;
}
private renderExternalView(): TemplateResult {
return html`
<ob-sectionheading>External Registries</ob-sectionheading>
<sz-registry-external-view
.registries=${[]}
@add=${(e: CustomEvent) => {
console.log('Add external registry:', e.detail);
}}
></sz-registry-external-view>
`;
}
}

View File

@@ -0,0 +1,321 @@
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 {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
// ============================================================================
// Data transformation helpers
// Maps backend data shapes to @serve.zone/catalog component interfaces
// ============================================================================
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(1)} ${units[i]}`;
}
function parseImageString(image: string): { repository: string; tag: string } {
const lastColon = image.lastIndexOf(':');
const lastSlash = image.lastIndexOf('/');
if (lastColon > lastSlash && lastColon > 0) {
return {
repository: image.substring(0, lastColon),
tag: image.substring(lastColon + 1),
};
}
return { repository: image, tag: 'latest' };
}
function mapStatus(status: string): 'running' | 'stopped' | 'starting' | 'error' {
switch (status) {
case 'running': return 'running';
case 'starting': return 'starting';
case 'failed': return 'error';
case 'stopped':
case 'stopping':
default: return 'stopped';
}
}
function toServiceDetail(service: interfaces.data.IService) {
const parsed = parseImageString(service.image);
return {
name: service.name,
status: mapStatus(service.status),
image: service.image,
port: service.port,
domain: service.domain || null,
containerId: service.containerID || '',
created: service.createdAt ? new Date(service.createdAt).toLocaleString() : '-',
updated: service.updatedAt ? new Date(service.updatedAt).toLocaleString() : '-',
registry: service.useOneboxRegistry ? 'Onebox Registry' : (service.registry || 'Docker Hub'),
repository: service.registryRepository || parsed.repository,
tag: service.registryImageTag || parsed.tag,
};
}
function toServiceStats(stats: interfaces.data.IContainerStats) {
return {
cpu: stats.cpuPercent,
memory: formatBytes(stats.memoryUsed),
memoryLimit: formatBytes(stats.memoryLimit),
networkIn: formatBytes(stats.networkRx),
networkOut: formatBytes(stats.networkTx),
};
}
function parseLogs(logs: any): Array<{ timestamp: string; message: string }> {
if (Array.isArray(logs)) {
return logs.map((entry: any) => ({
timestamp: entry.timestamp ? String(entry.timestamp) : '',
message: entry.message || String(entry),
}));
}
if (typeof logs === 'string' && logs.trim()) {
return logs.split('\n').filter((line: string) => line.trim()).map((line: string) => {
const match = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
if (match) {
return { timestamp: match[1], message: match[2] };
}
return { timestamp: '', message: line };
});
}
return [];
}
const defaultStats = { cpu: 0, memory: '0 B', memoryLimit: '0 B', networkIn: '0 B', networkOut: '0 B' };
@customElement('ob-view-services')
export class ObViewServices extends DeesElement {
@state()
accessor servicesState: appstate.IServicesState = {
services: [],
currentService: null,
currentServiceLogs: [],
currentServiceStats: null,
platformServices: [],
currentPlatformService: null,
};
@state()
accessor backupsState: appstate.IBackupsState = {
backups: [],
schedules: [],
};
@state()
accessor currentView: 'list' | 'create' | 'detail' | 'backups' | 'platform-detail' = 'list';
@state()
accessor selectedServiceName: string = '';
@state()
accessor selectedPlatformType: string = '';
constructor() {
super();
const servicesSub = appstate.servicesStatePart
.select((s) => s)
.subscribe((newState) => {
this.servicesState = newState;
});
this.rxSubscriptions.push(servicesSub);
const backupsSub = appstate.backupsStatePart
.select((s) => s)
.subscribe((newState) => {
this.backupsState = newState;
});
this.rxSubscriptions.push(backupsSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css``,
];
async connectedCallback() {
super.connectedCallback();
await Promise.all([
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
]);
}
public render(): TemplateResult {
switch (this.currentView) {
case 'create':
return this.renderCreateView();
case 'detail':
return this.renderDetailView();
case 'backups':
return this.renderBackupsView();
case 'platform-detail':
return this.renderPlatformDetailView();
default:
return this.renderListView();
}
}
private renderListView(): TemplateResult {
const mappedServices = this.servicesState.services.map((s) => ({
name: s.name,
image: s.image,
domain: s.domain || null,
status: mapStatus(s.status),
}));
return html`
<ob-sectionheading>Services</ob-sectionheading>
<sz-services-list-view
.services=${mappedServices}
@service-click=${(e: CustomEvent) => {
this.selectedServiceName = e.detail.name || e.detail.service?.name;
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceAction, {
name: this.selectedServiceName,
});
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceLogsAction, {
name: this.selectedServiceName,
});
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceStatsAction, {
name: this.selectedServiceName,
});
this.currentView = 'detail';
}}
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
></sz-services-list-view>
`;
}
private renderCreateView(): TemplateResult {
return html`
<ob-sectionheading>Create Service</ob-sectionheading>
<sz-service-create-view
.registries=${[]}
@create-service=${async (e: CustomEvent) => {
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
config: e.detail,
});
this.currentView = 'list';
}}
@cancel=${() => {
this.currentView = 'list';
}}
></sz-service-create-view>
`;
}
private renderDetailView(): TemplateResult {
const service = this.servicesState.currentService;
const transformedService = service ? toServiceDetail(service) : null;
const transformedStats = this.servicesState.currentServiceStats
? toServiceStats(this.servicesState.currentServiceStats)
: defaultStats;
const transformedLogs = parseLogs(this.servicesState.currentServiceLogs);
return html`
<ob-sectionheading>Service Details</ob-sectionheading>
<sz-service-detail-view
.service=${transformedService}
.logs=${transformedLogs}
.stats=${transformedStats}
@back=${() => {
this.currentView = 'list';
}}
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
></sz-service-detail-view>
`;
}
private renderBackupsView(): TemplateResult {
return html`
<ob-sectionheading>Backups</ob-sectionheading>
<sz-services-backups-view
.schedules=${this.backupsState.schedules}
.backups=${this.backupsState.backups}
@create-schedule=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.createScheduleAction, {
config: e.detail,
});
}}
@run-now=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.triggerScheduleAction, {
scheduleId: e.detail.scheduleId,
});
}}
@delete-backup=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.deleteBackupAction, {
backupId: e.detail.backupId,
});
}}
></sz-services-backups-view>
`;
}
private renderPlatformDetailView(): TemplateResult {
const platformService = this.servicesState.platformServices.find(
(ps) => ps.type === this.selectedPlatformType,
);
return html`
<ob-sectionheading>Platform Service</ob-sectionheading>
<sz-platform-service-detail-view
.service=${platformService
? {
id: platformService.type,
name: platformService.displayName,
type: platformService.type,
status: platformService.status,
version: '',
host: 'localhost',
port: 0,
config: {},
}
: null}
.logs=${[]}
@start=${() => {
appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, {
serviceType: this.selectedPlatformType as any,
});
}}
@stop=${() => {
appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, {
serviceType: this.selectedPlatformType as any,
});
}}
></sz-platform-service-detail-view>
`;
}
private async handleServiceAction(e: CustomEvent) {
const action = e.detail.action;
const name = e.detail.service?.name || e.detail.name || this.selectedServiceName;
switch (action) {
case 'start':
await appstate.servicesStatePart.dispatchAction(appstate.startServiceAction, { name });
break;
case 'stop':
await appstate.servicesStatePart.dispatchAction(appstate.stopServiceAction, { name });
break;
case 'restart':
await appstate.servicesStatePart.dispatchAction(appstate.restartServiceAction, { name });
break;
case 'delete':
await appstate.servicesStatePart.dispatchAction(appstate.deleteServiceAction, { name });
this.currentView = 'list';
break;
}
}
}

View File

@@ -0,0 +1,93 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ob-view-settings')
export class ObViewSettings extends DeesElement {
@state()
accessor settingsState: appstate.ISettingsState = {
settings: null,
backupPasswordConfigured: false,
};
@state()
accessor loginState: appstate.ILoginState = {
identity: null,
isLoggedIn: false,
};
constructor() {
super();
const settingsSub = appstate.settingsStatePart
.select((s) => s)
.subscribe((newState) => {
this.settingsState = newState;
});
this.rxSubscriptions.push(settingsSub);
const loginSub = appstate.loginStatePart
.select((s) => s)
.subscribe((newState) => {
this.loginState = newState;
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css``,
];
async connectedCallback() {
super.connectedCallback();
await appstate.settingsStatePart.dispatchAction(appstate.fetchSettingsAction, null);
}
public render(): TemplateResult {
return html`
<ob-sectionheading>Settings</ob-sectionheading>
<sz-settings-view
.settings=${this.settingsState.settings || {
darkMode: true,
cloudflareToken: '',
cloudflareZoneId: '',
autoRenewCerts: false,
renewalThreshold: 30,
acmeEmail: '',
httpPort: 80,
httpsPort: 443,
forceHttps: false,
}}
.currentUser=${this.loginState.identity?.username || 'admin'}
@setting-change=${(e: CustomEvent) => {
const { key, value } = e.detail;
appstate.settingsStatePart.dispatchAction(appstate.updateSettingsAction, {
settings: { [key]: value },
});
}}
@save=${(e: CustomEvent) => {
appstate.settingsStatePart.dispatchAction(appstate.updateSettingsAction, {
settings: e.detail,
});
}}
@change-password=${(e: CustomEvent) => {
console.log('Change password requested:', e.detail);
}}
@reset=${() => {
appstate.settingsStatePart.dispatchAction(appstate.fetchSettingsAction, null);
}}
></sz-settings-view>
`;
}
}

View File

@@ -0,0 +1,86 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ob-view-tokens')
export class ObViewTokens extends DeesElement {
@state()
accessor registriesState: appstate.IRegistriesState = {
tokens: [],
registryStatus: null,
};
constructor() {
super();
const registriesSub = appstate.registriesStatePart
.select((s) => s)
.subscribe((newState) => {
this.registriesState = newState;
});
this.rxSubscriptions.push(registriesSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css``,
];
async connectedCallback() {
super.connectedCallback();
await appstate.registriesStatePart.dispatchAction(
appstate.fetchRegistryTokensAction,
null,
);
}
public render(): TemplateResult {
const globalTokens = this.registriesState.tokens.filter((t) => t.type === 'global');
const ciTokens = this.registriesState.tokens.filter((t) => t.type === 'ci');
return html`
<ob-sectionheading>Tokens</ob-sectionheading>
<sz-tokens-view
.globalTokens=${globalTokens.map((t) => ({
id: t.id,
name: t.name,
type: 'global' as const,
createdAt: t.createdAt,
lastUsed: t.lastUsed,
}))}
.ciTokens=${ciTokens.map((t) => ({
id: t.id,
name: t.name,
type: 'ci' as const,
service: t.service,
createdAt: t.createdAt,
lastUsed: t.lastUsed,
}))}
@create=${(e: CustomEvent) => {
appstate.registriesStatePart.dispatchAction(appstate.createRegistryTokenAction, {
token: {
name: `new-${e.detail.type}-token`,
type: e.detail.type,
permissions: ['pull'],
},
});
}}
@delete=${(e: CustomEvent) => {
appstate.registriesStatePart.dispatchAction(appstate.deleteRegistryTokenAction, {
tokenId: e.detail.id || e.detail.tokenId,
});
}}
></sz-tokens-view>
`;
}
}

View File

@@ -0,0 +1,10 @@
import { css } from '@design.estate/dees-element';
export const viewHostCss = css`
:host {
display: block;
margin: auto;
max-width: 1280px;
padding: 16px 16px;
}
`;

View File

@@ -0,0 +1,2 @@
export * from './css.js';
export * from './ob-sectionheading.js';

View File

@@ -0,0 +1,37 @@
import {
DeesElement,
customElement,
html,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ob-sectionheading')
export class ObSectionHeading extends DeesElement {
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
margin-bottom: 24px;
}
.heading {
font-family: 'Cal Sans', 'Inter', sans-serif;
font-size: 28px;
font-weight: 600;
color: ${cssManager.bdTheme('#111', '#fff')};
margin: 0;
padding: 0;
}
`,
];
public render(): TemplateResult {
return html`
<h1 class="heading">
<slot></slot>
</h1>
`;
}
}

7
ts_web/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import * as plugins from './plugins.js';
import { html } from '@design.estate/dees-element';
import './elements/index.js';
plugins.deesElement.render(html`
<ob-app-shell></ob-app-shell>
`, document.body);

14
ts_web/plugins.ts Normal file
View File

@@ -0,0 +1,14 @@
// @design.estate scope
import * as deesElement from '@design.estate/dees-element';
import * as deesCatalog from '@design.estate/dees-catalog';
// @serve.zone scope — side-effect import registers all sz-* custom elements
import '@serve.zone/catalog';
export {
deesElement,
deesCatalog,
};
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities
export const domtools = deesElement.domtools;

View File

@@ -1,17 +0,0 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
ui/.gitignore vendored
View File

@@ -1,42 +0,0 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@@ -1,59 +0,0 @@
# Ui
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.19.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -1,94 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ui": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/ui",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "ui:build:production"
},
"development": {
"buildTarget": "ui:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
}
}
}
}
}

View File

@@ -1,41 +0,0 @@
{
"name": "ui",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"rxjs": "~7.8.0",
"tailwindcss": "^3.4.18",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.19",
"@angular/cli": "^19.2.19",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"@types/node": "^24.10.1",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

9197
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,8 +0,0 @@
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"ws": true,
"changeOrigin": true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,336 +0,0 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 3),
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://twitter.com/angular"
aria-label="Twitter"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Twitter"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />

View File

@@ -1,29 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'ui' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('ui');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, ui');
});
});

View File

@@ -1,14 +0,0 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ToasterComponent } from './ui/toast/toaster.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, ToasterComponent],
template: `
<router-outlet />
<ui-toaster />
`,
})
export class AppComponent {}

View File

@@ -1,13 +0,0 @@
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};

View File

@@ -1,129 +0,0 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [
{
path: 'login',
loadComponent: () =>
import('./features/login/login.component').then((m) => m.LoginComponent),
},
{
path: '',
loadComponent: () =>
import('./shared/components/layout/layout.component').then(
(m) => m.LayoutComponent
),
canActivate: [authGuard],
children: [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full',
},
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'services',
children: [
{
path: '',
redirectTo: 'user',
pathMatch: 'full',
},
{
path: 'create',
loadComponent: () =>
import('./features/services/service-create.component').then(
(m) => m.ServiceCreateComponent
),
},
{
path: 'platform/:type',
loadComponent: () =>
import('./features/services/platform-service-detail.component').then(
(m) => m.PlatformServiceDetailComponent
),
},
{
path: 'detail/:name',
loadComponent: () =>
import('./features/services/service-detail.component').then(
(m) => m.ServiceDetailComponent
),
},
{
path: ':tab',
loadComponent: () =>
import('./features/services/services-list.component').then(
(m) => m.ServicesListComponent
),
},
],
},
{
path: 'network',
children: [
{
path: '',
redirectTo: 'proxy',
pathMatch: 'full',
},
{
path: 'domains/:domain',
loadComponent: () =>
import('./features/domains/domain-detail.component').then(
(m) => m.DomainDetailComponent
),
},
{
path: ':tab',
loadComponent: () =>
import('./features/network/network.component').then(
(m) => m.NetworkComponent
),
},
],
},
{
path: 'registries',
children: [
{
path: '',
redirectTo: 'onebox',
pathMatch: 'full',
},
{
path: ':tab',
loadComponent: () =>
import('./features/registries/registries.component').then(
(m) => m.RegistriesComponent
),
},
],
},
{
path: 'tokens',
loadComponent: () =>
import('./features/tokens/tokens.component').then(
(m) => m.TokensComponent
),
},
{
path: 'settings',
loadComponent: () =>
import('./features/settings/settings.component').then(
(m) => m.SettingsComponent
),
},
],
},
{
path: '**',
redirectTo: 'dashboard',
},
];

View File

@@ -1,15 +0,0 @@
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
router.navigate(['/login']);
return false;
};

View File

@@ -1,27 +0,0 @@
import { inject } from '@angular/core';
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
) => {
const auth = inject(AuthService);
const token = auth.getToken();
// Skip auth header for login request
if (req.url.includes('/api/auth/login')) {
return next(req);
}
if (token) {
const authReq = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(authReq);
}
return next(req);
};

View File

@@ -1,334 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
IApiResponse,
IService,
IServiceCreate,
IServiceUpdate,
ISystemStatus,
IDomain,
IDomainDetail,
IDnsRecord,
IRegistry,
IRegistryCreate,
IRegistryToken,
ICreateTokenRequest,
ITokenCreatedResponse,
ISetting,
ISettings,
IPlatformService,
IPlatformResource,
TPlatformServiceType,
INetworkTarget,
INetworkStats,
IContainerStats,
IMetric,
ITrafficStats,
IBackup,
IRestoreOptions,
IRestoreResult,
IBackupPasswordStatus,
IBackupSchedule,
IBackupScheduleCreate,
IBackupScheduleUpdate,
} from '../types/api.types';
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
// System Status
async getStatus(): Promise<IApiResponse<ISystemStatus>> {
return firstValueFrom(this.http.get<IApiResponse<ISystemStatus>>('/api/status'));
}
// Services
async getServices(): Promise<IApiResponse<IService[]>> {
return firstValueFrom(this.http.get<IApiResponse<IService[]>>('/api/services'));
}
async getService(name: string): Promise<IApiResponse<IService>> {
return firstValueFrom(this.http.get<IApiResponse<IService>>(`/api/services/${name}`));
}
async createService(data: IServiceCreate): Promise<IApiResponse<IService>> {
return firstValueFrom(this.http.post<IApiResponse<IService>>('/api/services', data));
}
async updateService(name: string, data: IServiceUpdate): Promise<IApiResponse<IService>> {
return firstValueFrom(this.http.put<IApiResponse<IService>>(`/api/services/${name}`, data));
}
async deleteService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/services/${name}`));
}
async startService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/start`, {}));
}
async stopService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/stop`, {}));
}
async restartService(name: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/services/${name}/restart`, {}));
}
async getServiceLogs(name: string): Promise<IApiResponse<string>> {
return firstValueFrom(this.http.get<IApiResponse<string>>(`/api/services/${name}/logs`));
}
async getServiceStats(name: string): Promise<IApiResponse<IContainerStats>> {
return firstValueFrom(this.http.get<IApiResponse<IContainerStats>>(`/api/services/${name}/stats`));
}
async getServiceMetrics(name: string, limit?: number): Promise<IApiResponse<IMetric[]>> {
const params = limit ? `?limit=${limit}` : '';
return firstValueFrom(this.http.get<IApiResponse<IMetric[]>>(`/api/services/${name}/metrics${params}`));
}
// Registries
async getRegistries(): Promise<IApiResponse<IRegistry[]>> {
return firstValueFrom(this.http.get<IApiResponse<IRegistry[]>>('/api/registries'));
}
async createRegistry(data: IRegistryCreate): Promise<IApiResponse<IRegistry>> {
return firstValueFrom(this.http.post<IApiResponse<IRegistry>>('/api/registries', data));
}
async deleteRegistry(id: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registries/${id}`));
}
// Registry Tokens
async getRegistryTokens(): Promise<IApiResponse<IRegistryToken[]>> {
return firstValueFrom(this.http.get<IApiResponse<IRegistryToken[]>>('/api/registry/tokens'));
}
async createRegistryToken(data: ICreateTokenRequest): Promise<IApiResponse<ITokenCreatedResponse>> {
return firstValueFrom(this.http.post<IApiResponse<ITokenCreatedResponse>>('/api/registry/tokens', data));
}
async deleteRegistryToken(id: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registry/tokens/${id}`));
}
// DNS Records
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
return firstValueFrom(this.http.get<IApiResponse<IDnsRecord[]>>('/api/dns'));
}
async createDnsRecord(domain: string, ip?: string): Promise<IApiResponse<IDnsRecord>> {
return firstValueFrom(this.http.post<IApiResponse<IDnsRecord>>('/api/dns', { domain, ip }));
}
async deleteDnsRecord(domain: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/dns/${domain}`));
}
async syncDnsRecords(): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>('/api/dns/sync', {}));
}
// Domains
async getDomains(): Promise<IApiResponse<IDomainDetail[]>> {
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail[]>>('/api/domains'));
}
async getDomainDetail(domain: string): Promise<IApiResponse<IDomainDetail>> {
return firstValueFrom(this.http.get<IApiResponse<IDomainDetail>>(`/api/domains/${domain}`));
}
async syncCloudflareDomains(): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>('/api/domains/sync', {}));
}
// SSL Certificates
async obtainCertificate(domain: string, includeWildcard?: boolean): Promise<IApiResponse<void>> {
return firstValueFrom(
this.http.post<IApiResponse<void>>('/api/ssl/obtain', { domain, includeWildcard })
);
}
async renewCertificate(domain: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/ssl/${domain}/renew`, {}));
}
// Settings
async getSettings(): Promise<IApiResponse<ISetting[]>> {
return firstValueFrom(this.http.get<IApiResponse<ISetting[]>>('/api/settings'));
}
async updateSettings(settings: Record<string, string> | ISettings): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.put<IApiResponse<void>>('/api/settings', settings));
}
async updateSetting(key: string, value: string): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.put<IApiResponse<void>>('/api/settings', { key, value }));
}
// Auth
async changePassword(currentPassword: string, newPassword: string): Promise<IApiResponse<void>> {
return firstValueFrom(
this.http.post<IApiResponse<void>>('/api/auth/change-password', {
currentPassword,
newPassword,
})
);
}
// Platform Services
async getPlatformServices(): Promise<IApiResponse<IPlatformService[]>> {
return firstValueFrom(this.http.get<IApiResponse<IPlatformService[]>>('/api/platform-services'));
}
async getPlatformService(type: TPlatformServiceType): Promise<IApiResponse<IPlatformService>> {
return firstValueFrom(this.http.get<IApiResponse<IPlatformService>>(`/api/platform-services/${type}`));
}
async startPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/start`, {}));
}
async stopPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/stop`, {}));
}
async getPlatformServiceStats(type: TPlatformServiceType): Promise<IApiResponse<IContainerStats>> {
return firstValueFrom(this.http.get<IApiResponse<IContainerStats>>(`/api/platform-services/${type}/stats`));
}
async getServicePlatformResources(serviceName: string): Promise<IApiResponse<IPlatformResource[]>> {
return firstValueFrom(this.http.get<IApiResponse<IPlatformResource[]>>(`/api/services/${serviceName}/platform-resources`));
}
// Network
async getNetworkTargets(): Promise<IApiResponse<INetworkTarget[]>> {
return firstValueFrom(this.http.get<IApiResponse<INetworkTarget[]>>('/api/network/targets'));
}
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats'));
}
async getTrafficStats(minutes?: number): Promise<IApiResponse<ITrafficStats>> {
const params = minutes ? `?minutes=${minutes}` : '';
return firstValueFrom(this.http.get<IApiResponse<ITrafficStats>>(`/api/network/traffic-stats${params}`));
}
// Backups
async getBackups(): Promise<IApiResponse<IBackup[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>('/api/backups'));
}
async getServiceBackups(serviceName: string): Promise<IApiResponse<IBackup[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackup[]>>(`/api/services/${serviceName}/backups`));
}
async createBackup(serviceName: string): Promise<IApiResponse<IBackup>> {
return firstValueFrom(this.http.post<IApiResponse<IBackup>>(`/api/services/${serviceName}/backup`, {}));
}
async getBackup(backupId: number): Promise<IApiResponse<IBackup>> {
return firstValueFrom(this.http.get<IApiResponse<IBackup>>(`/api/backups/${backupId}`));
}
async deleteBackup(backupId: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backups/${backupId}`));
}
getBackupDownloadUrl(backupId: number): string {
return `/api/backups/${backupId}/download`;
}
async downloadBackup(backupId: number, filename: string): Promise<void> {
const token = localStorage.getItem('onebox_token');
const response = await fetch(`/api/backups/${backupId}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
throw new Error('Download failed');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async importBackupFromFile(file: File, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
const formData = new FormData();
formData.append('file', file);
if (newServiceName) {
formData.append('newServiceName', newServiceName);
}
return firstValueFrom(
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', formData)
);
}
async importBackupFromUrl(url: string, newServiceName?: string): Promise<IApiResponse<IRestoreResult>> {
return firstValueFrom(
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/import', {
url,
newServiceName,
})
);
}
async restoreBackup(backupId: number, options: IRestoreOptions): Promise<IApiResponse<IRestoreResult>> {
return firstValueFrom(
this.http.post<IApiResponse<IRestoreResult>>('/api/backups/restore', {
backupId,
...options,
})
);
}
async setBackupPassword(password: string): Promise<IApiResponse<void>> {
return firstValueFrom(
this.http.post<IApiResponse<void>>('/api/settings/backup-password', { password })
);
}
async checkBackupPassword(): Promise<IApiResponse<IBackupPasswordStatus>> {
return firstValueFrom(
this.http.get<IApiResponse<IBackupPasswordStatus>>('/api/settings/backup-password')
);
}
// Backup Schedules
async getBackupSchedules(): Promise<IApiResponse<IBackupSchedule[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>('/api/backup-schedules'));
}
async getBackupSchedule(scheduleId: number): Promise<IApiResponse<IBackupSchedule>> {
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`));
}
async createBackupSchedule(data: IBackupScheduleCreate): Promise<IApiResponse<IBackupSchedule>> {
return firstValueFrom(this.http.post<IApiResponse<IBackupSchedule>>('/api/backup-schedules', data));
}
async updateBackupSchedule(scheduleId: number, data: IBackupScheduleUpdate): Promise<IApiResponse<IBackupSchedule>> {
return firstValueFrom(this.http.put<IApiResponse<IBackupSchedule>>(`/api/backup-schedules/${scheduleId}`, data));
}
async deleteBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}`));
}
async triggerBackupSchedule(scheduleId: number): Promise<IApiResponse<void>> {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/backup-schedules/${scheduleId}/trigger`, {}));
}
async getServiceBackupSchedules(serviceName: string): Promise<IApiResponse<IBackupSchedule[]>> {
return firstValueFrom(this.http.get<IApiResponse<IBackupSchedule[]>>(`/api/services/${serviceName}/backup-schedules`));
}
}

View File

@@ -1,54 +0,0 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { IApiResponse, ILoginResponse, IUser } from '../types/api.types';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
private token = signal<string | null>(this.loadToken());
currentUser = signal<IUser | null>(null);
isAuthenticated = computed(() => !!this.token());
private loadToken(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem('onebox_token');
}
async login(username: string, password: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await firstValueFrom(
this.http.post<IApiResponse<ILoginResponse>>('/api/auth/login', { username, password })
);
if (response?.success && response.data) {
this.token.set(response.data.token);
this.currentUser.set(response.data.user);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('onebox_token', response.data.token);
}
return { success: true };
}
return { success: false, error: response?.error || 'Login failed' };
} catch (err: any) {
const errorMessage = err?.error?.error || err?.message || 'Login failed';
return { success: false, error: errorMessage };
}
}
logout(): void {
this.token.set(null);
this.currentUser.set(null);
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('onebox_token');
}
this.router.navigate(['/login']);
}
getToken(): string | null {
return this.token();
}
}

View File

@@ -1,227 +0,0 @@
import { Injectable, signal } from '@angular/core';
export interface ILogStreamState {
connected: boolean;
error: string | null;
serviceName: string | null;
}
@Injectable({ providedIn: 'root' })
export class LogStreamService {
private ws: WebSocket | null = null;
private currentService: string | null = null;
// Signals for reactive state
state = signal<ILogStreamState>({
connected: false,
error: null,
serviceName: null,
});
logs = signal<string[]>([]);
isStreaming = signal(false);
/**
* Connect to log stream for a service
*/
connect(serviceName: string): void {
// Disconnect any existing stream
this.disconnect();
this.currentService = serviceName;
this.isStreaming.set(true);
this.logs.set([]);
this.state.set({
connected: false,
error: null,
serviceName,
});
if (typeof window === 'undefined') return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const url = `${protocol}//${host}/api/services/${serviceName}/logs/stream`;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
// Connection established, waiting for 'connected' message from server
};
this.ws.onmessage = (event) => {
const data = event.data;
// Try to parse as JSON (for control messages)
try {
const json = JSON.parse(data);
if (json.type === 'connected') {
this.state.set({
connected: true,
error: null,
serviceName: json.serviceName,
});
return;
}
if (json.error) {
this.state.update((s) => ({ ...s, error: json.error }));
return;
}
} catch {
// Not JSON - it's a log line
this.logs.update((lines) => {
const newLines = [...lines, data];
// Keep last 1000 lines to prevent memory issues
if (newLines.length > 1000) {
return newLines.slice(-1000);
}
return newLines;
});
}
};
this.ws.onclose = () => {
this.state.update((s) => ({ ...s, connected: false }));
this.isStreaming.set(false);
this.ws = null;
};
this.ws.onerror = () => {
this.state.update((s) => ({
...s,
connected: false,
error: 'WebSocket connection failed',
}));
this.isStreaming.set(false);
};
} catch (error) {
this.state.set({
connected: false,
error: 'Failed to connect to log stream',
serviceName,
});
this.isStreaming.set(false);
}
}
/**
* Disconnect from log stream
*/
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.currentService = null;
this.isStreaming.set(false);
this.logs.set([]); // Clear logs when disconnecting to prevent stale logs showing on next service
this.state.set({
connected: false,
error: null,
serviceName: null,
});
}
/**
* Clear logs buffer
*/
clearLogs(): void {
this.logs.set([]);
}
/**
* Get current service name being streamed
*/
getCurrentService(): string | null {
return this.currentService;
}
/**
* Connect to log stream for a platform service (MongoDB, MinIO, etc.)
*/
connectPlatform(type: string): void {
// Disconnect any existing stream
this.disconnect();
this.currentService = `platform:${type}`;
this.isStreaming.set(true);
this.logs.set([]);
this.state.set({
connected: false,
error: null,
serviceName: type,
});
if (typeof window === 'undefined') return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const url = `${protocol}//${host}/api/platform-services/${type}/logs/stream`;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
// Connection established, waiting for 'connected' message from server
};
this.ws.onmessage = (event) => {
const data = event.data;
// Try to parse as JSON (for control messages)
try {
const json = JSON.parse(data);
if (json.type === 'connected') {
this.state.set({
connected: true,
error: null,
serviceName: json.serviceName || type,
});
return;
}
if (json.error) {
this.state.update((s) => ({ ...s, error: json.error }));
return;
}
} catch {
// Not JSON - it's a log line
this.logs.update((lines) => {
const newLines = [...lines, data];
// Keep last 1000 lines to prevent memory issues
if (newLines.length > 1000) {
return newLines.slice(-1000);
}
return newLines;
});
}
};
this.ws.onclose = () => {
this.state.update((s) => ({ ...s, connected: false }));
this.isStreaming.set(false);
this.ws = null;
};
this.ws.onerror = () => {
this.state.update((s) => ({
...s,
connected: false,
error: 'WebSocket connection failed',
}));
this.isStreaming.set(false);
};
} catch (error) {
this.state.set({
connected: false,
error: 'Failed to connect to log stream',
serviceName: type,
});
this.isStreaming.set(false);
}
}
}

View File

@@ -1,187 +0,0 @@
import { Injectable, signal } from '@angular/core';
import type { ICaddyAccessLog, INetworkLogMessage } from '../types/api.types';
export interface INetworkLogStreamState {
connected: boolean;
error: string | null;
clientId: string | null;
}
export interface INetworkLogFilter {
domain?: string;
sampleRate?: number;
}
@Injectable({ providedIn: 'root' })
export class NetworkLogStreamService {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
// Signals for reactive state
state = signal<INetworkLogStreamState>({
connected: false,
error: null,
clientId: null,
});
logs = signal<ICaddyAccessLog[]>([]);
isStreaming = signal(false);
filter = signal<INetworkLogFilter | null>(null);
/**
* Connect to network log stream
*/
connect(initialFilter?: INetworkLogFilter): void {
// Disconnect any existing stream
this.disconnect();
this.isStreaming.set(true);
this.logs.set([]);
this.filter.set(initialFilter || null);
this.state.set({
connected: false,
error: null,
clientId: null,
});
if (typeof window === 'undefined') return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
let url = `${protocol}//${host}/api/network/logs/stream`;
// Add initial filter as query params
if (initialFilter?.domain) {
url += `?domain=${encodeURIComponent(initialFilter.domain)}`;
}
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as INetworkLogMessage;
if (message.type === 'connected') {
this.state.set({
connected: true,
error: null,
clientId: message.clientId || null,
});
if (message.filter) {
this.filter.set(message.filter);
}
return;
}
if (message.type === 'filter_updated') {
this.filter.set(message.filter || null);
return;
}
if (message.type === 'access_log' && message.data) {
this.logs.update((lines) => {
const newLines = [...lines, message.data!];
// Keep last 500 logs to prevent memory issues
if (newLines.length > 500) {
return newLines.slice(-500);
}
return newLines;
});
}
} catch (error) {
console.error('Failed to parse network log message:', error);
}
};
this.ws.onclose = () => {
this.state.update((s) => ({ ...s, connected: false }));
this.ws = null;
// Auto-reconnect with exponential backoff
if (this.isStreaming() && this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
this.reconnectTimeout = setTimeout(() => {
this.connect(this.filter() || undefined);
}, delay);
} else {
this.isStreaming.set(false);
}
};
this.ws.onerror = () => {
this.state.update((s) => ({
...s,
connected: false,
error: 'WebSocket connection failed',
}));
};
} catch (error) {
this.state.set({
connected: false,
error: 'Failed to connect to network log stream',
clientId: null,
});
this.isStreaming.set(false);
}
}
/**
* Disconnect from log stream
*/
disconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isStreaming.set(false);
this.reconnectAttempts = 0;
this.state.set({
connected: false,
error: null,
clientId: null,
});
}
/**
* Update filter on existing connection
*/
setFilter(newFilter: INetworkLogFilter | null): void {
this.filter.set(newFilter);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'set_filter',
domain: newFilter?.domain,
sampleRate: newFilter?.sampleRate,
}));
}
}
/**
* Clear logs buffer
*/
clearLogs(): void {
this.logs.set([]);
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.state().connected;
}
}

View File

@@ -1,64 +0,0 @@
import { Injectable, signal, effect } from '@angular/core';
export type Theme = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeService {
theme = signal<Theme>(this.loadTheme());
constructor() {
effect(() => {
this.applyTheme(this.theme());
});
// Listen for system preference changes
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.theme() === 'system') {
this.applyTheme('system');
}
});
}
}
private loadTheme(): Theme {
if (typeof localStorage === 'undefined') return 'system';
const stored = localStorage.getItem('onebox-theme');
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
}
return 'system';
}
setTheme(theme: Theme): void {
this.theme.set(theme);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('onebox-theme', theme);
}
}
toggle(): void {
const resolved = this.resolvedTheme();
this.setTheme(resolved === 'dark' ? 'light' : 'dark');
}
isDark(): boolean {
return this.resolvedTheme() === 'dark';
}
resolvedTheme(): 'light' | 'dark' {
if (this.theme() === 'system') {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return this.theme() as 'light' | 'dark';
}
private applyTheme(theme: Theme): void {
if (typeof document === 'undefined') return;
const resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.classList.toggle('dark', resolved === 'dark');
}
}

View File

@@ -1,48 +0,0 @@
import { Injectable, signal } from '@angular/core';
import { IToast, ToastType } from '../types/api.types';
@Injectable({ providedIn: 'root' })
export class ToastService {
toasts = signal<IToast[]>([]);
private generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
show(type: ToastType, message: string, duration = 5000): string {
const id = this.generateId();
const toast: IToast = { id, type, message, duration };
this.toasts.update(toasts => [...toasts, toast]);
if (duration > 0) {
setTimeout(() => this.dismiss(id), duration);
}
return id;
}
success(message: string, duration?: number): string {
return this.show('success', message, duration);
}
error(message: string, duration?: number): string {
return this.show('error', message, duration);
}
info(message: string, duration?: number): string {
return this.show('info', message, duration);
}
warning(message: string, duration?: number): string {
return this.show('warning', message, duration);
}
dismiss(id: string): void {
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
}
dismissAll(): void {
this.toasts.set([]);
}
}

Some files were not shown because too many files have changed in this diff Show More