Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eceb5d99c8 | |||
| 0631b7731f | |||
| 4c485cdc0a | |||
| 0f0da0f2ef | |||
| 88367f70eb | |||
| bfcfef79da | |||
| d95270613b | |||
| 14f6746833 | |||
| fe8ca00337 | |||
| ba05cc84fe | |||
| 84c47cd7f5 | |||
| 9365f20f6d | |||
| bc2ed4b03a | |||
| e4dd4cce0a | |||
| 34c90e21db | |||
| ea7bb1395f | |||
| c529dfe34d | |||
| 6ba7e655e3 | |||
| c5d239ab28 | |||
| 5cd7e7c252 | |||
| e7ade45097 | |||
| 7b159a3486 | |||
| 9470c7911d | |||
| 3d7727c304 | |||
| ff5b51072f | |||
| 633cbe696e | |||
| 0247ab45c7 | |||
| 0d932239d2 |
130
changelog.md
130
changelog.md
@@ -1,5 +1,135 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Backend: add /api/backups/import endpoint to accept multipart file uploads or JSON with a URL and import backups (saves temp file, validates .tar.enc, calls backupManager.restoreBackup in import mode).
|
||||||
|
- Backend: server-side import handler downloads remote backup URLs, stores temporary file, invokes restore/import logic and cleans up temp files.
|
||||||
|
- Frontend: add downloadBackup, importBackupFromFile and importBackupFromUrl methods to ApiService; trigger browser download using Blob and object URL with Authorization header.
|
||||||
|
- Frontend: replace raw download link in service detail UI with a Download button that calls downloadBackup and shows success/error toasts.
|
||||||
|
- Dev: add VS Code launch, tasks and recommended extensions for the ui workspace to simplify local development.
|
||||||
|
|
||||||
|
## 2025-11-27 - 1.8.0 - feat(backup)
|
||||||
|
Add backup scheduling system with GFS retention, API and UI integration
|
||||||
|
|
||||||
|
- Introduce backup scheduling subsystem (BackupScheduler) and integrate it into Onebox lifecycle (init & shutdown)
|
||||||
|
- Extend BackupManager.createBackup to accept schedule metadata (scheduleId) so scheduled runs are tracked
|
||||||
|
- Add GFS-style retention policy support (IRetentionPolicy + RETENTION_PRESETS) and expose per-tier retention in types
|
||||||
|
- Database migrations and repository changes: create backups and backup_schedules tables, add schedule_id, per-tier retention columns, and scope (all/pattern/service) support (migrations up to version 12)
|
||||||
|
- HTTP API: add backup schedule endpoints (GET/POST/PUT/DELETE /api/backup-schedules), trigger endpoint (/api/backup-schedules/:id/trigger), and service-scoped schedule endpoints
|
||||||
|
- UI: add API client methods for backup schedules and register a Backups tab in Services UI to surface schedules/backups
|
||||||
|
- Add task scheduling dependency (@push.rocks/taskbuffer) and export it via plugins.ts; update deno.json accordingly
|
||||||
|
- Type and repository updates across codebase to support schedule-aware backups, schedule CRUD, and retention enforcement
|
||||||
|
|
||||||
|
## 2025-11-27 - 1.7.0 - feat(backup)
|
||||||
|
Add backup system: BackupManager, DB schema, API endpoints and UI support
|
||||||
|
|
||||||
|
Introduce a complete service backup/restore subsystem with encrypted archives, database records and REST endpoints. Implements BackupManager with export/import for service config, platform resources (MongoDB, MinIO, ClickHouse), and Docker images; adds BackupRepository and migrations for backups table and include_image_in_backup; integrates backup flows into the HTTP API and the UI client; exposes backup password management and restore modes (restore/import/clone). Wire BackupManager into Onebox initialization.
|
||||||
|
|
||||||
|
- Add BackupManager implementing create/restore/export/import/encrypt/decrypt workflows (service config, platform resource dumps, Docker image export/import) and support for restore modes: restore, import, clone.
|
||||||
|
- Add BackupRepository and database migrations: create backups table and add include_image_in_backup column to services; database API methods for create/get/list/delete backups.
|
||||||
|
- Add HTTP API endpoints for backup management: list/create/get/download/delete backups, restore backups (/api/backups/restore) and backup password endpoints (/api/settings/backup-password).
|
||||||
|
- Update UI ApiService and types: add IBackup, IRestoreOptions, IRestoreResult, IBackupPasswordStatus and corresponding ApiService methods (getBackups, createBackup, getBackup, deleteBackup, getBackupDownloadUrl, restoreBackup, setBackupPassword, checkBackupPassword).
|
||||||
|
- Expose includeImageInBackup flag on service model and persist it in ServiceRepository (defaults to true for existing rows); service update flow supports toggling this option.
|
||||||
|
- Integrate BackupManager into Onebox core (initialized in Onebox constructor) and wire HTTP handlers to use the new manager; add DB repository export/import glue so backups are stored and referenced by ID.
|
||||||
|
|
||||||
|
## 2025-11-27 - 1.6.0 - feat(ui.dashboard)
|
||||||
|
Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config
|
||||||
|
|
||||||
|
- Introduce ResourceUsageCardComponent and include it as a full-width row in the dashboard layout.
|
||||||
|
- Make several dashboard card components (Certificates, Traffic, Platform Services) full-height by adding host classes and applying h-full to ui-card elements for consistent card sizing.
|
||||||
|
- Reflow dashboard rows (insert Resource Usage as a dedicated row and update row numbering) to improve visual layout.
|
||||||
|
- Add VSCode workspace configuration: recommended Angular extension, launch configurations for ng serve/ng test, and npm tasks to run/start the UI in development.
|
||||||
|
|
||||||
|
## 2025-11-27 - 1.5.0 - feat(network)
|
||||||
|
Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting
|
||||||
|
|
||||||
|
- Add /api/network/traffic-stats GET endpoint to the HTTP API with an optional minutes query parameter (validated, 1-60).
|
||||||
|
- Implement traffic statistics aggregation in CaddyLogReceiver using rolling per-minute buckets (requestCount, errorCount, avgResponseTime, totalBytes, statusCounts, requestsPerMinute, errorRate).
|
||||||
|
- Expose getTrafficStats(minutes?) in the Angular ApiService and add ITrafficStats type to the client API types.
|
||||||
|
- Add dashboard UI components: TrafficCard, PlatformServicesCard, CertificatesCard and integrate them into the main Dashboard (including links to Platform Services).
|
||||||
|
- Enhance system status data: platformServices entries now include displayName and resourceCount; add certificateHealth summary (valid, expiringSoon, expired, expiringDomains) returned by Onebox status.
|
||||||
|
- Platform services manager and Onebox code updated to surface provider information and resource counts for the UI.
|
||||||
|
- Add VSCode workspace launch/tasks recommendations for the UI development environment.
|
||||||
|
|
||||||
|
## 2025-11-26 - 1.4.0 - feat(platform-services)
|
||||||
|
Add ClickHouse platform service support and improve related healthchecks and tooling
|
||||||
|
|
||||||
|
- Add ClickHouse as a first-class platform service: register provider, provision/cleanup support and env var injection
|
||||||
|
- Expose ClickHouse endpoints in the HTTP API routing (list/get/start/stop/stats) and map default port (8123)
|
||||||
|
- Enable services to request ClickHouse as a platform requirement (enableClickHouse / platformRequirements) during deploy/provision flows
|
||||||
|
- Fix ClickHouse container health check to use absolute wget path (/usr/bin/wget) for more reliable in-container checks
|
||||||
|
- Add VS Code workspace launch/tasks/extensions configs for the UI (ui/.vscode/*) to improve local dev experience
|
||||||
|
|
||||||
|
## 2025-11-26 - 1.3.0 - feat(platform-services)
|
||||||
|
Add ClickHouse platform service support (provider, types, provisioning, UI and port mappings)
|
||||||
|
|
||||||
|
- Introduce ClickHouse as a first-class platform service: added ClickHouseProvider and registered it in PlatformServicesManager
|
||||||
|
- Support provisioning ClickHouse resources for user services and storing encrypted credentials in platform_resources
|
||||||
|
- Add ClickHouse to core types (TPlatformServiceType, IPlatformRequirements, IServiceDeployOptions) and service DB handling so services can request ClickHouse
|
||||||
|
- Inject ClickHouse-related environment variables into deployed services (CLICKHOUSE_* mappings) when provisioning resources
|
||||||
|
- Expose ClickHouse default port (8123) in platform port mappings / network targets
|
||||||
|
- UI: add checkbox and description for enabling ClickHouse during service creation; form now submits enableClickHouse
|
||||||
|
- Add VS Code recommendations and launch/tasks for the UI development workflow
|
||||||
|
|
||||||
## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
|
## 2025-11-26 - 1.2.1 - fix(platform-services/minio)
|
||||||
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning
|
Improve MinIO provider: reuse existing data and credentials, use host-bound port for provisioning, and safer provisioning/deprovisioning
|
||||||
|
|
||||||
|
|||||||
10
deno.json
10
deno.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.2.1",
|
"version": "1.11.0",
|
||||||
"exports": "./mod.ts",
|
"exports": "./mod.ts",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
@@ -21,7 +21,13 @@
|
|||||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
|
||||||
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
|
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
|
||||||
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0"
|
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.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": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|||||||
36196
dist_serve/bundle.js
Normal file
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
33
dist_serve/index.html
Normal 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
33
html/index.html
Normal 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
37
npmextra.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/onebox",
|
"name": "@serve.zone/onebox",
|
||||||
"version": "1.2.1",
|
"version": "1.11.0",
|
||||||
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
|
||||||
"main": "mod.ts",
|
"main": "mod.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/install-binary.js",
|
"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": [
|
"keywords": [
|
||||||
"docker",
|
"docker",
|
||||||
@@ -51,8 +53,14 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
"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": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"@git.zone/tsbundle": "^2.9.0",
|
||||||
|
"@git.zone/tswatch": "^3.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5212
pnpm-lock.yaml
generated
5212
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/onebox',
|
name: '@serve.zone/onebox',
|
||||||
version: '1.2.1',
|
version: '1.11.0',
|
||||||
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||||
}
|
}
|
||||||
|
|||||||
1117
ts/classes/backup-manager.ts
Normal file
1117
ts/classes/backup-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
650
ts/classes/backup-scheduler.ts
Normal file
650
ts/classes/backup-scheduler.ts
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
/**
|
||||||
|
* Backup Scheduler for Onebox
|
||||||
|
*
|
||||||
|
* Uses @push.rocks/taskbuffer for cron-based scheduled backups
|
||||||
|
* with GFS (Grandfather-Father-Son) time-window based retention scheme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import type {
|
||||||
|
IBackupSchedule,
|
||||||
|
IBackupScheduleCreate,
|
||||||
|
IBackupScheduleUpdate,
|
||||||
|
IService,
|
||||||
|
IRetentionPolicy,
|
||||||
|
} from '../types.ts';
|
||||||
|
import { RETENTION_PRESETS } from '../types.ts';
|
||||||
|
import { logger } from '../logging.ts';
|
||||||
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
|
import type { Onebox } from './onebox.ts';
|
||||||
|
|
||||||
|
export class BackupScheduler {
|
||||||
|
private oneboxRef: Onebox;
|
||||||
|
private taskManager!: plugins.taskbuffer.TaskManager;
|
||||||
|
private scheduledTasks: Map<number, plugins.taskbuffer.Task> = new Map();
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(oneboxRef: Onebox) {
|
||||||
|
this.oneboxRef = oneboxRef;
|
||||||
|
// TaskManager is created in init() to avoid log spam before ready
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the scheduler and load enabled schedules
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.initialized) {
|
||||||
|
logger.warn('BackupScheduler already initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create TaskManager here (not in constructor) to avoid "no cronjobs" log spam
|
||||||
|
this.taskManager = new plugins.taskbuffer.TaskManager();
|
||||||
|
|
||||||
|
// Add heartbeat task immediately to prevent "no cronjobs specified" log spam
|
||||||
|
// This runs hourly and does nothing, but keeps taskbuffer happy
|
||||||
|
const heartbeatTask = new plugins.taskbuffer.Task({
|
||||||
|
name: 'backup-scheduler-heartbeat',
|
||||||
|
taskFunction: async () => {
|
||||||
|
// No-op heartbeat task
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.taskManager.addAndScheduleTask(heartbeatTask, '0 * * * *'); // Hourly
|
||||||
|
|
||||||
|
// Load all enabled schedules from database
|
||||||
|
const schedules = this.oneboxRef.database.getEnabledBackupSchedules();
|
||||||
|
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
await this.registerTask(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the task manager (activates cron scheduling)
|
||||||
|
await this.taskManager.start();
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
logger.info(`Backup scheduler started with ${schedules.length} enabled schedule(s)`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to initialize backup scheduler: ${getErrorMessage(error)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the scheduler
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.initialized || !this.taskManager) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.taskManager.stop();
|
||||||
|
this.scheduledTasks.clear();
|
||||||
|
this.initialized = false;
|
||||||
|
logger.info('Backup scheduler stopped');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to stop backup scheduler: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new backup schedule
|
||||||
|
*/
|
||||||
|
async createSchedule(request: IBackupScheduleCreate): Promise<IBackupSchedule> {
|
||||||
|
// Validate based on scope type
|
||||||
|
let serviceId: number | undefined;
|
||||||
|
let serviceName: string | undefined;
|
||||||
|
|
||||||
|
switch (request.scopeType) {
|
||||||
|
case 'service':
|
||||||
|
// Validate service exists
|
||||||
|
if (!request.serviceName) {
|
||||||
|
throw new Error('serviceName is required for service-specific schedules');
|
||||||
|
}
|
||||||
|
const service = this.oneboxRef.database.getServiceByName(request.serviceName);
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service not found: ${request.serviceName}`);
|
||||||
|
}
|
||||||
|
serviceId = service.id!;
|
||||||
|
serviceName = service.name;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pattern':
|
||||||
|
// Validate pattern is provided
|
||||||
|
if (!request.scopePattern) {
|
||||||
|
throw new Error('scopePattern is required for pattern-based schedules');
|
||||||
|
}
|
||||||
|
// Validate pattern matches at least one service
|
||||||
|
const matchingServices = this.getServicesMatchingPattern(request.scopePattern);
|
||||||
|
if (matchingServices.length === 0) {
|
||||||
|
logger.warn(`Pattern "${request.scopePattern}" currently matches no services`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'all':
|
||||||
|
// No validation needed for global schedules
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid scope type: ${request.scopeType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use provided cron expression
|
||||||
|
const cronExpression = request.cronExpression;
|
||||||
|
|
||||||
|
// Calculate next run time
|
||||||
|
const nextRunAt = this.calculateNextRun(cronExpression);
|
||||||
|
|
||||||
|
// Create schedule in database
|
||||||
|
const schedule = this.oneboxRef.database.createBackupSchedule({
|
||||||
|
scopeType: request.scopeType,
|
||||||
|
scopePattern: request.scopePattern,
|
||||||
|
serviceId,
|
||||||
|
serviceName,
|
||||||
|
cronExpression,
|
||||||
|
retention: request.retention,
|
||||||
|
enabled: request.enabled !== false,
|
||||||
|
lastRunAt: null,
|
||||||
|
nextRunAt,
|
||||||
|
lastStatus: null,
|
||||||
|
lastError: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register task if enabled
|
||||||
|
if (schedule.enabled) {
|
||||||
|
await this.registerTask(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeDesc = this.getScopeDescription(schedule);
|
||||||
|
const retentionDesc = this.getRetentionDescription(schedule.retention);
|
||||||
|
logger.info(`Backup schedule created: ${schedule.id} for ${scopeDesc} (${retentionDesc})`);
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing backup schedule
|
||||||
|
*/
|
||||||
|
async updateSchedule(scheduleId: number, updates: IBackupScheduleUpdate): Promise<IBackupSchedule> {
|
||||||
|
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new Error(`Backup schedule not found: ${scheduleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deschedule existing task if present
|
||||||
|
await this.descheduleTask(scheduleId);
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
this.oneboxRef.database.updateBackupSchedule(scheduleId, updates);
|
||||||
|
|
||||||
|
// Get updated schedule
|
||||||
|
const updatedSchedule = this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
|
||||||
|
|
||||||
|
// Calculate new next run time if cron changed
|
||||||
|
if (updates.cronExpression) {
|
||||||
|
const nextRunAt = this.calculateNextRun(updatedSchedule.cronExpression);
|
||||||
|
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-register task if enabled
|
||||||
|
if (updatedSchedule.enabled) {
|
||||||
|
await this.registerTask(updatedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Backup schedule updated: ${scheduleId}`);
|
||||||
|
return this.oneboxRef.database.getBackupScheduleById(scheduleId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup schedule
|
||||||
|
*/
|
||||||
|
async deleteSchedule(scheduleId: number): Promise<void> {
|
||||||
|
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new Error(`Backup schedule not found: ${scheduleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deschedule task
|
||||||
|
await this.descheduleTask(scheduleId);
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
this.oneboxRef.database.deleteBackupSchedule(scheduleId);
|
||||||
|
|
||||||
|
logger.info(`Backup schedule deleted: ${scheduleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger immediate backup for a schedule
|
||||||
|
*/
|
||||||
|
async triggerBackup(scheduleId: number): Promise<void> {
|
||||||
|
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new Error(`Backup schedule not found: ${scheduleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Manually triggering backup for schedule ${scheduleId}`);
|
||||||
|
await this.executeBackup(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all schedules
|
||||||
|
*/
|
||||||
|
getAllSchedules(): IBackupSchedule[] {
|
||||||
|
return this.oneboxRef.database.getAllBackupSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get schedule by ID
|
||||||
|
*/
|
||||||
|
getScheduleById(id: number): IBackupSchedule | null {
|
||||||
|
return this.oneboxRef.database.getBackupScheduleById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get schedules for a service
|
||||||
|
*/
|
||||||
|
getSchedulesForService(serviceName: string): IBackupSchedule[] {
|
||||||
|
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.oneboxRef.database.getBackupSchedulesByService(service.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get retention presets
|
||||||
|
*/
|
||||||
|
getRetentionPresets(): typeof RETENTION_PRESETS {
|
||||||
|
return RETENTION_PRESETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Private Methods ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a task for a schedule
|
||||||
|
*/
|
||||||
|
private async registerTask(schedule: IBackupSchedule): Promise<void> {
|
||||||
|
const taskName = `backup-${schedule.id}`;
|
||||||
|
|
||||||
|
const task = new plugins.taskbuffer.Task({
|
||||||
|
name: taskName,
|
||||||
|
taskFunction: async () => {
|
||||||
|
await this.executeBackup(schedule);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add and schedule the task
|
||||||
|
this.taskManager.addAndScheduleTask(task, schedule.cronExpression);
|
||||||
|
this.scheduledTasks.set(schedule.id!, task);
|
||||||
|
|
||||||
|
// Update next run time in database
|
||||||
|
this.updateNextRunTime(schedule.id!);
|
||||||
|
|
||||||
|
logger.debug(`Registered backup task: ${taskName} with cron: ${schedule.cronExpression}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deschedule a task
|
||||||
|
*/
|
||||||
|
private async descheduleTask(scheduleId: number): Promise<void> {
|
||||||
|
const task = this.scheduledTasks.get(scheduleId);
|
||||||
|
if (task) {
|
||||||
|
await this.taskManager.descheduleTask(task);
|
||||||
|
this.scheduledTasks.delete(scheduleId);
|
||||||
|
logger.debug(`Descheduled backup task for schedule ${scheduleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a backup for a schedule
|
||||||
|
*/
|
||||||
|
private async executeBackup(schedule: IBackupSchedule): Promise<void> {
|
||||||
|
const scopeDesc = this.getScopeDescription(schedule);
|
||||||
|
const servicesToBackup = this.getServicesForSchedule(schedule);
|
||||||
|
|
||||||
|
if (servicesToBackup.length === 0) {
|
||||||
|
logger.warn(`No services to backup for schedule ${schedule.id} (${scopeDesc})`);
|
||||||
|
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
|
||||||
|
lastRunAt: Date.now(),
|
||||||
|
lastStatus: 'success',
|
||||||
|
lastError: 'No matching services found',
|
||||||
|
});
|
||||||
|
this.updateNextRunTime(schedule.id!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retentionDesc = this.getRetentionDescription(schedule.retention);
|
||||||
|
logger.info(`Executing scheduled backup for ${scopeDesc}: ${servicesToBackup.length} service(s) (${retentionDesc})`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const service of servicesToBackup) {
|
||||||
|
try {
|
||||||
|
// Create backup with schedule ID
|
||||||
|
await this.oneboxRef.backupManager.createBackup(service.name, {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply time-window based retention policy for this service
|
||||||
|
await this.applyRetention(schedule, service.id!);
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
logger.success(`Scheduled backup completed for ${service.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
logger.error(`Scheduled backup failed for ${service.name}: ${errorMessage}`);
|
||||||
|
errors.push(`${service.name}: ${errorMessage}`);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update schedule status
|
||||||
|
const lastStatus = failCount === 0 ? 'success' : 'failed';
|
||||||
|
const lastError = errors.length > 0 ? errors.join('; ') : null;
|
||||||
|
|
||||||
|
this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
|
||||||
|
lastRunAt: Date.now(),
|
||||||
|
lastStatus,
|
||||||
|
lastError,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
logger.success(`Scheduled backup completed for ${scopeDesc}: ${successCount} service(s)`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Scheduled backup partially failed for ${scopeDesc}: ${successCount} succeeded, ${failCount} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update next run time
|
||||||
|
this.updateNextRunTime(schedule.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply time-window based retention policy
|
||||||
|
* Works correctly regardless of backup frequency (cron schedule)
|
||||||
|
*/
|
||||||
|
private async applyRetention(schedule: IBackupSchedule, serviceId: number): Promise<void> {
|
||||||
|
// Get all backups for this schedule and service
|
||||||
|
const allBackups = this.oneboxRef.database.getBackupsByService(serviceId);
|
||||||
|
const backups = allBackups.filter(b => b.scheduleId === schedule.id);
|
||||||
|
|
||||||
|
if (backups.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hourly, daily, weekly, monthly } = schedule.retention;
|
||||||
|
const now = Date.now();
|
||||||
|
const toKeep = new Set<number>();
|
||||||
|
|
||||||
|
// Hourly: Keep up to N most recent backups from last 24 hours
|
||||||
|
if (hourly > 0) {
|
||||||
|
const recentBackups = backups
|
||||||
|
.filter(b => now - b.createdAt < 24 * 60 * 60 * 1000)
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
.slice(0, hourly);
|
||||||
|
recentBackups.forEach(b => toKeep.add(b.id!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily: Keep oldest backup per day for last N days
|
||||||
|
if (daily > 0) {
|
||||||
|
for (let i = 0; i < daily; i++) {
|
||||||
|
const dayStart = this.getStartOfDay(now, i);
|
||||||
|
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
|
||||||
|
const dayBackups = backups.filter(b =>
|
||||||
|
b.createdAt >= dayStart && b.createdAt < dayEnd
|
||||||
|
);
|
||||||
|
if (dayBackups.length > 0) {
|
||||||
|
// Keep oldest from this day (most representative)
|
||||||
|
const oldest = dayBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||||
|
toKeep.add(oldest.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly: Keep oldest backup per week for last N weeks
|
||||||
|
if (weekly > 0) {
|
||||||
|
for (let i = 0; i < weekly; i++) {
|
||||||
|
const weekStart = this.getStartOfWeek(now, i);
|
||||||
|
const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const weekBackups = backups.filter(b =>
|
||||||
|
b.createdAt >= weekStart && b.createdAt < weekEnd
|
||||||
|
);
|
||||||
|
if (weekBackups.length > 0) {
|
||||||
|
const oldest = weekBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||||
|
toKeep.add(oldest.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly: Keep oldest backup per month for last N months
|
||||||
|
if (monthly > 0) {
|
||||||
|
for (let i = 0; i < monthly; i++) {
|
||||||
|
const { start, end } = this.getMonthRange(now, i);
|
||||||
|
const monthBackups = backups.filter(b =>
|
||||||
|
b.createdAt >= start && b.createdAt < end
|
||||||
|
);
|
||||||
|
if (monthBackups.length > 0) {
|
||||||
|
const oldest = monthBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||||
|
toKeep.add(oldest.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete anything not in toKeep
|
||||||
|
for (const backup of backups) {
|
||||||
|
if (!toKeep.has(backup.id!)) {
|
||||||
|
try {
|
||||||
|
await this.oneboxRef.backupManager.deleteBackup(backup.id!);
|
||||||
|
logger.info(`Deleted backup ${backup.filename} (retention policy)`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to delete old backup ${backup.filename}: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get start of day (midnight) for N days ago
|
||||||
|
*/
|
||||||
|
private getStartOfDay(now: number, daysAgo: number): number {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(date.getDate() - daysAgo);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get start of week (Sunday midnight) for N weeks ago
|
||||||
|
*/
|
||||||
|
private getStartOfWeek(now: number, weeksAgo: number): number {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(date.getDate() - (weeksAgo * 7) - date.getDay());
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get month range for N months ago
|
||||||
|
*/
|
||||||
|
private getMonthRange(now: number, monthsAgo: number): { start: number; end: number } {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setMonth(date.getMonth() - monthsAgo);
|
||||||
|
date.setDate(1);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
const start = date.getTime();
|
||||||
|
|
||||||
|
date.setMonth(date.getMonth() + 1);
|
||||||
|
const end = date.getTime();
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update next run time for a schedule
|
||||||
|
*/
|
||||||
|
private updateNextRunTime(scheduleId: number): void {
|
||||||
|
const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId);
|
||||||
|
if (!schedule) return;
|
||||||
|
|
||||||
|
const nextRunAt = this.calculateNextRun(schedule.cronExpression);
|
||||||
|
this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate next run time from cron expression
|
||||||
|
*/
|
||||||
|
private calculateNextRun(cronExpression: string): number {
|
||||||
|
try {
|
||||||
|
// Get next scheduled runs from task manager
|
||||||
|
const scheduledTasks = this.taskManager.getScheduledTasks();
|
||||||
|
|
||||||
|
// Find our task and get its next run
|
||||||
|
for (const taskInfo of scheduledTasks) {
|
||||||
|
if (taskInfo.schedule === cronExpression && taskInfo.nextRun) {
|
||||||
|
return taskInfo.nextRun.getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: parse cron and calculate next occurrence
|
||||||
|
// Simple implementation for common patterns
|
||||||
|
const now = new Date();
|
||||||
|
const parts = cronExpression.split(' ');
|
||||||
|
|
||||||
|
if (parts.length === 5) {
|
||||||
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||||
|
|
||||||
|
// For daily schedules (e.g., "0 2 * * *")
|
||||||
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||||
|
const nextRun = new Date(now);
|
||||||
|
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||||
|
if (nextRun <= now) {
|
||||||
|
nextRun.setDate(nextRun.getDate() + 1);
|
||||||
|
}
|
||||||
|
return nextRun.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For weekly schedules (e.g., "0 2 * * 0")
|
||||||
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
|
||||||
|
const targetDay = parseInt(dayOfWeek);
|
||||||
|
const nextRun = new Date(now);
|
||||||
|
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||||
|
const currentDay = now.getDay();
|
||||||
|
let daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||||
|
if (daysUntilTarget === 0 && nextRun <= now) {
|
||||||
|
daysUntilTarget = 7;
|
||||||
|
}
|
||||||
|
nextRun.setDate(nextRun.getDate() + daysUntilTarget);
|
||||||
|
return nextRun.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For monthly schedules (e.g., "0 2 1 * *")
|
||||||
|
if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
|
||||||
|
const targetDay = parseInt(dayOfMonth);
|
||||||
|
const nextRun = new Date(now);
|
||||||
|
nextRun.setDate(targetDay);
|
||||||
|
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||||
|
if (nextRun <= now) {
|
||||||
|
nextRun.setMonth(nextRun.getMonth() + 1);
|
||||||
|
}
|
||||||
|
return nextRun.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For yearly schedules (e.g., "0 2 1 1 *")
|
||||||
|
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
|
||||||
|
const targetMonth = parseInt(month) - 1; // JavaScript months are 0-indexed
|
||||||
|
const targetDay = parseInt(dayOfMonth);
|
||||||
|
const nextRun = new Date(now);
|
||||||
|
nextRun.setMonth(targetMonth, targetDay);
|
||||||
|
nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
|
||||||
|
if (nextRun <= now) {
|
||||||
|
nextRun.setFullYear(nextRun.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
return nextRun.getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: next day at 2 AM
|
||||||
|
const fallback = new Date(now);
|
||||||
|
fallback.setDate(fallback.getDate() + 1);
|
||||||
|
fallback.setHours(2, 0, 0, 0);
|
||||||
|
return fallback.getTime();
|
||||||
|
} catch {
|
||||||
|
// On any error, return tomorrow at 2 AM
|
||||||
|
const fallback = new Date();
|
||||||
|
fallback.setDate(fallback.getDate() + 1);
|
||||||
|
fallback.setHours(2, 0, 0, 0);
|
||||||
|
return fallback.getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get services that match a schedule based on its scope type
|
||||||
|
*/
|
||||||
|
private getServicesForSchedule(schedule: IBackupSchedule): IService[] {
|
||||||
|
const allServices = this.oneboxRef.database.getAllServices();
|
||||||
|
|
||||||
|
switch (schedule.scopeType) {
|
||||||
|
case 'all':
|
||||||
|
return allServices;
|
||||||
|
|
||||||
|
case 'pattern':
|
||||||
|
if (!schedule.scopePattern) return [];
|
||||||
|
return this.getServicesMatchingPattern(schedule.scopePattern);
|
||||||
|
|
||||||
|
case 'service':
|
||||||
|
if (!schedule.serviceId) return [];
|
||||||
|
const service = allServices.find(s => s.id === schedule.serviceId);
|
||||||
|
return service ? [service] : [];
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get services that match a glob pattern
|
||||||
|
*/
|
||||||
|
private getServicesMatchingPattern(pattern: string): IService[] {
|
||||||
|
const allServices = this.oneboxRef.database.getAllServices();
|
||||||
|
return allServices.filter(s => this.matchesGlobPattern(s.name, pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple glob pattern matching (supports * and ?)
|
||||||
|
*/
|
||||||
|
private matchesGlobPattern(text: string, pattern: string): boolean {
|
||||||
|
// Convert glob pattern to regex
|
||||||
|
// Escape special regex characters except * and ?
|
||||||
|
const regexPattern = pattern
|
||||||
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||||
|
.replace(/\*/g, '.*') // * matches any characters
|
||||||
|
.replace(/\?/g, '.'); // ? matches single character
|
||||||
|
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`, 'i');
|
||||||
|
return regex.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable description of a schedule's scope
|
||||||
|
*/
|
||||||
|
private getScopeDescription(schedule: IBackupSchedule): string {
|
||||||
|
switch (schedule.scopeType) {
|
||||||
|
case 'all':
|
||||||
|
return 'all services';
|
||||||
|
case 'pattern':
|
||||||
|
return `pattern "${schedule.scopePattern}"`;
|
||||||
|
case 'service':
|
||||||
|
return `service "${schedule.serviceName}"`;
|
||||||
|
default:
|
||||||
|
return 'unknown scope';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable description of retention policy
|
||||||
|
*/
|
||||||
|
private getRetentionDescription(retention: IRetentionPolicy): string {
|
||||||
|
return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,8 +79,84 @@ export class CaddyLogReceiver {
|
|||||||
private recentLogs: ICaddyAccessLog[] = [];
|
private recentLogs: ICaddyAccessLog[] = [];
|
||||||
private maxRecentLogs = 100;
|
private maxRecentLogs = 100;
|
||||||
|
|
||||||
|
// Traffic stats aggregation (hourly rolling window)
|
||||||
|
private trafficStats: {
|
||||||
|
timestamp: number;
|
||||||
|
requestCount: number;
|
||||||
|
errorCount: number; // 4xx + 5xx
|
||||||
|
totalDuration: number; // microseconds
|
||||||
|
totalSize: number; // bytes
|
||||||
|
statusCounts: Record<string, number>; // "2xx", "3xx", "4xx", "5xx"
|
||||||
|
}[] = [];
|
||||||
|
private maxStatsAge = 3600 * 1000; // 1 hour in ms
|
||||||
|
private statsInterval = 60 * 1000; // 1 minute buckets
|
||||||
|
|
||||||
constructor(port = 9999) {
|
constructor(port = 9999) {
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
// Initialize first stats bucket
|
||||||
|
this.trafficStats.push(this.createStatsBucket());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new stats bucket
|
||||||
|
*/
|
||||||
|
private createStatsBucket(): typeof this.trafficStats[0] {
|
||||||
|
return {
|
||||||
|
timestamp: Math.floor(Date.now() / this.statsInterval) * this.statsInterval,
|
||||||
|
requestCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalSize: 0,
|
||||||
|
statusCounts: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current stats bucket, creating new one if needed
|
||||||
|
*/
|
||||||
|
private getCurrentStatsBucket(): typeof this.trafficStats[0] {
|
||||||
|
const now = Date.now();
|
||||||
|
const currentBucketTime = Math.floor(now / this.statsInterval) * this.statsInterval;
|
||||||
|
|
||||||
|
// Get or create current bucket
|
||||||
|
let bucket = this.trafficStats[this.trafficStats.length - 1];
|
||||||
|
if (!bucket || bucket.timestamp !== currentBucketTime) {
|
||||||
|
bucket = this.createStatsBucket();
|
||||||
|
this.trafficStats.push(bucket);
|
||||||
|
|
||||||
|
// Clean up old buckets
|
||||||
|
const cutoff = now - this.maxStatsAge;
|
||||||
|
while (this.trafficStats.length > 0 && this.trafficStats[0].timestamp < cutoff) {
|
||||||
|
this.trafficStats.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a request in traffic stats
|
||||||
|
*/
|
||||||
|
private recordTrafficStats(log: ICaddyAccessLog): void {
|
||||||
|
const bucket = this.getCurrentStatsBucket();
|
||||||
|
|
||||||
|
bucket.requestCount++;
|
||||||
|
bucket.totalDuration += log.duration;
|
||||||
|
bucket.totalSize += log.size || 0;
|
||||||
|
|
||||||
|
// Categorize status code
|
||||||
|
const statusCategory = Math.floor(log.status / 100);
|
||||||
|
if (statusCategory === 2) {
|
||||||
|
bucket.statusCounts['2xx']++;
|
||||||
|
} else if (statusCategory === 3) {
|
||||||
|
bucket.statusCounts['3xx']++;
|
||||||
|
} else if (statusCategory === 4) {
|
||||||
|
bucket.statusCounts['4xx']++;
|
||||||
|
bucket.errorCount++;
|
||||||
|
} else if (statusCategory === 5) {
|
||||||
|
bucket.statusCounts['5xx']++;
|
||||||
|
bucket.errorCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,6 +257,9 @@ export class CaddyLogReceiver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always record traffic stats (before sampling) for accurate aggregation
|
||||||
|
this.recordTrafficStats(log);
|
||||||
|
|
||||||
// Update adaptive sampling
|
// Update adaptive sampling
|
||||||
this.updateSampling();
|
this.updateSampling();
|
||||||
|
|
||||||
@@ -414,4 +493,57 @@ export class CaddyLogReceiver {
|
|||||||
recentLogsCount: this.recentLogs.length,
|
recentLogsCount: this.recentLogs.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get aggregated traffic stats for the specified time range
|
||||||
|
* @param minutes Number of minutes to aggregate (default: 60)
|
||||||
|
*/
|
||||||
|
getTrafficStats(minutes = 60): {
|
||||||
|
requestCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
avgResponseTime: number; // in milliseconds
|
||||||
|
totalBytes: number;
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
requestsPerMinute: number;
|
||||||
|
errorRate: number; // percentage
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoff = now - (minutes * 60 * 1000);
|
||||||
|
|
||||||
|
// Aggregate all buckets within the time range
|
||||||
|
let requestCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
let totalDuration = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
const statusCounts: Record<string, number> = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
|
||||||
|
|
||||||
|
for (const bucket of this.trafficStats) {
|
||||||
|
if (bucket.timestamp >= cutoff) {
|
||||||
|
requestCount += bucket.requestCount;
|
||||||
|
errorCount += bucket.errorCount;
|
||||||
|
totalDuration += bucket.totalDuration;
|
||||||
|
totalBytes += bucket.totalSize;
|
||||||
|
for (const [status, count] of Object.entries(bucket.statusCounts)) {
|
||||||
|
statusCounts[status] = (statusCounts[status] || 0) + count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate averages
|
||||||
|
const avgResponseTime = requestCount > 0
|
||||||
|
? (totalDuration / requestCount) / 1000 // Convert from microseconds to milliseconds
|
||||||
|
: 0;
|
||||||
|
const requestsPerMinute = requestCount / Math.max(minutes, 1);
|
||||||
|
const errorRate = requestCount > 0 ? (errorCount / requestCount) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestCount,
|
||||||
|
errorCount,
|
||||||
|
avgResponseTime: Math.round(avgResponseTime * 100) / 100, // Round to 2 decimal places
|
||||||
|
totalBytes,
|
||||||
|
statusCounts,
|
||||||
|
requestsPerMinute: Math.round(requestsPerMinute * 100) / 100,
|
||||||
|
errorRate: Math.round(errorRate * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,9 +131,9 @@ export class OneboxDaemon {
|
|||||||
// Start monitoring loop
|
// Start monitoring loop
|
||||||
this.startMonitoring();
|
this.startMonitoring();
|
||||||
|
|
||||||
// Start HTTP server
|
// Start OpsServer (serves new UI + TypedRequest API)
|
||||||
const httpPort = parseInt(this.oneboxRef.database.getSetting('httpPort') || '3000', 10);
|
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.success('Onebox daemon started');
|
||||||
logger.info(`Web UI available at http://localhost:${httpPort}`);
|
logger.info(`Web UI available at http://localhost:${httpPort}`);
|
||||||
@@ -163,8 +163,8 @@ export class OneboxDaemon {
|
|||||||
// Stop monitoring
|
// Stop monitoring
|
||||||
this.stopMonitoring();
|
this.stopMonitoring();
|
||||||
|
|
||||||
// Stop HTTP server
|
// Stop OpsServer
|
||||||
await this.oneboxRef.httpServer.stop();
|
await this.oneboxRef.opsServer.stop();
|
||||||
|
|
||||||
// Remove PID file
|
// Remove PID file
|
||||||
await this.removePidFile();
|
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> {
|
private async broadcastStats(): Promise<void> {
|
||||||
try {
|
// Stats broadcasting via WebSocket is not yet implemented in OpsServer.
|
||||||
const services = this.oneboxRef.services.listServices();
|
// Metrics are still collected and stored in the DB by collectMetrics().
|
||||||
const runningServices = services.filter(s => s.status === 'running' && s.containerID);
|
// The new UI fetches stats via TypedRequests on demand.
|
||||||
|
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import * as plugins from '../plugins.ts';
|
|||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { getErrorMessage } from '../utils/error.ts';
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
import type { Onebox } from './onebox.ts';
|
import type { Onebox } from './onebox.ts';
|
||||||
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType, IContainerStats } from '../types.ts';
|
import type {
|
||||||
|
IApiResponse,
|
||||||
|
ICreateRegistryTokenRequest,
|
||||||
|
IRegistryTokenView,
|
||||||
|
TPlatformServiceType,
|
||||||
|
IContainerStats,
|
||||||
|
IBackupScheduleCreate,
|
||||||
|
IBackupScheduleUpdate,
|
||||||
|
} from '../types.ts';
|
||||||
|
|
||||||
export class OneboxHttpServer {
|
export class OneboxHttpServer {
|
||||||
private oneboxRef: Onebox;
|
private oneboxRef: Onebox;
|
||||||
@@ -297,16 +305,16 @@ export class OneboxHttpServer {
|
|||||||
// Platform Services endpoints
|
// Platform Services endpoints
|
||||||
} else if (path === '/api/platform-services' && method === 'GET') {
|
} else if (path === '/api/platform-services' && method === 'GET') {
|
||||||
return await this.handleListPlatformServicesRequest();
|
return await this.handleListPlatformServicesRequest();
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)$/) && method === 'GET') {
|
||||||
const type = path.split('/').pop()! as TPlatformServiceType;
|
const type = path.split('/').pop()! as TPlatformServiceType;
|
||||||
return await this.handleGetPlatformServiceRequest(type);
|
return await this.handleGetPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/start$/) && method === 'POST') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/start$/) && method === 'POST') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleStartPlatformServiceRequest(type);
|
return await this.handleStartPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stop$/) && method === 'POST') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleStopPlatformServiceRequest(type);
|
return await this.handleStopPlatformServiceRequest(type);
|
||||||
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stats$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stats$/) && method === 'GET') {
|
||||||
const type = path.split('/')[3] as TPlatformServiceType;
|
const type = path.split('/')[3] as TPlatformServiceType;
|
||||||
return await this.handleGetPlatformServiceStatsRequest(type);
|
return await this.handleGetPlatformServiceStatsRequest(type);
|
||||||
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
||||||
@@ -317,6 +325,54 @@ export class OneboxHttpServer {
|
|||||||
return await this.handleGetNetworkTargetsRequest();
|
return await this.handleGetNetworkTargetsRequest();
|
||||||
} else if (path === '/api/network/stats' && method === 'GET') {
|
} else if (path === '/api/network/stats' && method === 'GET') {
|
||||||
return await this.handleGetNetworkStatsRequest();
|
return await this.handleGetNetworkStatsRequest();
|
||||||
|
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
|
||||||
|
return await this.handleGetTrafficStatsRequest(new URL(req.url));
|
||||||
|
// Backup endpoints
|
||||||
|
} else if (path === '/api/backups' && method === 'GET') {
|
||||||
|
return await this.handleListBackupsRequest();
|
||||||
|
} else if (path.match(/^\/api\/services\/[^/]+\/backups$/) && method === 'GET') {
|
||||||
|
const serviceName = path.split('/')[3];
|
||||||
|
return await this.handleListServiceBackupsRequest(serviceName);
|
||||||
|
} else if (path.match(/^\/api\/services\/[^/]+\/backup$/) && method === 'POST') {
|
||||||
|
const serviceName = path.split('/')[3];
|
||||||
|
return await this.handleCreateBackupRequest(serviceName);
|
||||||
|
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'GET') {
|
||||||
|
const backupId = Number(path.split('/').pop());
|
||||||
|
return await this.handleGetBackupRequest(backupId);
|
||||||
|
} else if (path.match(/^\/api\/backups\/\d+\/download$/) && method === 'GET') {
|
||||||
|
const backupId = Number(path.split('/')[3]);
|
||||||
|
return await this.handleDownloadBackupRequest(backupId);
|
||||||
|
} else if (path.match(/^\/api\/backups\/\d+$/) && method === 'DELETE') {
|
||||||
|
const backupId = Number(path.split('/').pop());
|
||||||
|
return await this.handleDeleteBackupRequest(backupId);
|
||||||
|
} else if (path === '/api/backups/restore' && method === 'POST') {
|
||||||
|
return await this.handleRestoreBackupRequest(req);
|
||||||
|
} else if (path === '/api/backups/import' && method === 'POST') {
|
||||||
|
return await this.handleImportBackupRequest(req);
|
||||||
|
} else if (path === '/api/settings/backup-password' && method === 'POST') {
|
||||||
|
return await this.handleSetBackupPasswordRequest(req);
|
||||||
|
} else if (path === '/api/settings/backup-password' && method === 'GET') {
|
||||||
|
return await this.handleCheckBackupPasswordRequest();
|
||||||
|
// Backup Schedule endpoints
|
||||||
|
} else if (path === '/api/backup-schedules' && method === 'GET') {
|
||||||
|
return await this.handleListBackupSchedulesRequest();
|
||||||
|
} else if (path === '/api/backup-schedules' && method === 'POST') {
|
||||||
|
return await this.handleCreateBackupScheduleRequest(req);
|
||||||
|
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'GET') {
|
||||||
|
const scheduleId = Number(path.split('/').pop());
|
||||||
|
return await this.handleGetBackupScheduleRequest(scheduleId);
|
||||||
|
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'PUT') {
|
||||||
|
const scheduleId = Number(path.split('/').pop());
|
||||||
|
return await this.handleUpdateBackupScheduleRequest(scheduleId, req);
|
||||||
|
} else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'DELETE') {
|
||||||
|
const scheduleId = Number(path.split('/').pop());
|
||||||
|
return await this.handleDeleteBackupScheduleRequest(scheduleId);
|
||||||
|
} else if (path.match(/^\/api\/backup-schedules\/\d+\/trigger$/) && method === 'POST') {
|
||||||
|
const scheduleId = Number(path.split('/')[3]);
|
||||||
|
return await this.handleTriggerBackupScheduleRequest(scheduleId);
|
||||||
|
} else if (path.match(/^\/api\/services\/[^/]+\/backup-schedules$/) && method === 'GET') {
|
||||||
|
const serviceName = path.split('/')[3];
|
||||||
|
return await this.handleListServiceBackupSchedulesRequest(serviceName);
|
||||||
} else {
|
} else {
|
||||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||||
}
|
}
|
||||||
@@ -1323,6 +1379,7 @@ export class OneboxHttpServer {
|
|||||||
postgresql: 5432,
|
postgresql: 5432,
|
||||||
rabbitmq: 5672,
|
rabbitmq: 5672,
|
||||||
caddy: 80,
|
caddy: 80,
|
||||||
|
clickhouse: 8123,
|
||||||
};
|
};
|
||||||
return ports[type] || 0;
|
return ports[type] || 0;
|
||||||
}
|
}
|
||||||
@@ -1364,6 +1421,37 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get traffic stats from Caddy access logs
|
||||||
|
*/
|
||||||
|
private async handleGetTrafficStatsRequest(url: URL): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Get minutes parameter (default: 60)
|
||||||
|
const minutesParam = url.searchParams.get('minutes');
|
||||||
|
const minutes = minutesParam ? parseInt(minutesParam, 10) : 60;
|
||||||
|
|
||||||
|
if (isNaN(minutes) || minutes < 1 || minutes > 60) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid minutes parameter. Must be between 1 and 60.',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trafficStats = this.oneboxRef.caddyLogReceiver.getTrafficStats(minutes);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: trafficStats,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get traffic stats: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to get traffic stats',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast message to all connected WebSocket clients
|
* Broadcast message to all connected WebSocket clients
|
||||||
*/
|
*/
|
||||||
@@ -1983,6 +2071,626 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Backup Endpoints ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all backups
|
||||||
|
*/
|
||||||
|
private async handleListBackupsRequest(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const backups = this.oneboxRef.backupManager.listBackups();
|
||||||
|
return this.jsonResponse({ success: true, data: backups });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list backups: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to list backups',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List backups for a specific service
|
||||||
|
*/
|
||||||
|
private async handleListServiceBackupsRequest(serviceName: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const service = this.oneboxRef.services.getService(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = this.oneboxRef.backupManager.listBackups(serviceName);
|
||||||
|
return this.jsonResponse({ success: true, data: backups });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list backups for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to list backups',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a backup for a service
|
||||||
|
*/
|
||||||
|
private async handleCreateBackupRequest(serviceName: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const service = this.oneboxRef.services.getService(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.oneboxRef.backupManager.createBackup(serviceName);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Backup created for service ${serviceName}`,
|
||||||
|
data: result.backup,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create backup for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to create backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific backup by ID
|
||||||
|
*/
|
||||||
|
private async handleGetBackupRequest(backupId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||||
|
if (!backup) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jsonResponse({ success: true, data: backup });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get backup ${backupId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to get backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a backup file
|
||||||
|
*/
|
||||||
|
private async handleDownloadBackupRequest(backupId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||||
|
if (!filePath) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
try {
|
||||||
|
await Deno.stat(filePath);
|
||||||
|
} catch {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file and return as download
|
||||||
|
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||||
|
const file = await Deno.readFile(filePath);
|
||||||
|
|
||||||
|
return new Response(file, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${backup?.filename || 'backup.tar.enc'}"`,
|
||||||
|
'Content-Length': String(file.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to download backup ${backupId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to download backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup
|
||||||
|
*/
|
||||||
|
private async handleDeleteBackupRequest(backupId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const backup = this.oneboxRef.database.getBackupById(backupId);
|
||||||
|
if (!backup) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.oneboxRef.backupManager.deleteBackup(backupId);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to delete backup ${backupId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to delete backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a backup
|
||||||
|
*/
|
||||||
|
private async handleRestoreBackupRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { backupId, mode, newServiceName, overwriteExisting, skipPlatformData } = body;
|
||||||
|
|
||||||
|
if (!backupId) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Backup ID is required',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mode || !['restore', 'import', 'clone'].includes(mode)) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Valid mode required: restore, import, or clone',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup file path
|
||||||
|
const filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId);
|
||||||
|
if (!filePath) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate mode-specific requirements
|
||||||
|
if ((mode === 'import' || mode === 'clone') && !newServiceName) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `New service name required for '${mode}' mode`,
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
|
||||||
|
mode,
|
||||||
|
newServiceName,
|
||||||
|
overwriteExisting: overwriteExisting === true,
|
||||||
|
skipPlatformData: skipPlatformData === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Backup restored successfully as service '${result.service.name}'`,
|
||||||
|
data: {
|
||||||
|
service: result.service,
|
||||||
|
platformResourcesRestored: result.platformResourcesRestored,
|
||||||
|
warnings: result.warnings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to restore backup: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to restore backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a backup from file upload or URL
|
||||||
|
*/
|
||||||
|
private async handleImportBackupRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const contentType = req.headers.get('content-type') || '';
|
||||||
|
let filePath: string | null = null;
|
||||||
|
let newServiceName: string | undefined;
|
||||||
|
let tempFile = false;
|
||||||
|
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
// Handle file upload
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
newServiceName = formData.get('newServiceName')?.toString() || undefined;
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'No file provided',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file extension
|
||||||
|
if (!file.name.endsWith('.tar.enc')) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid file format. Expected .tar.enc file',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to temp location
|
||||||
|
const tempDir = './.nogit/temp-imports';
|
||||||
|
await Deno.mkdir(tempDir, { recursive: true });
|
||||||
|
filePath = `${tempDir}/${Date.now()}-${file.name}`;
|
||||||
|
tempFile = true;
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
logger.info(`Saved uploaded backup to ${filePath}`);
|
||||||
|
} else {
|
||||||
|
// Handle JSON body with URL
|
||||||
|
const body = await req.json();
|
||||||
|
const { url, newServiceName: serviceName } = body;
|
||||||
|
newServiceName = serviceName;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'URL is required when not uploading a file',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download from URL
|
||||||
|
const tempDir = './.nogit/temp-imports';
|
||||||
|
await Deno.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const urlFilename = url.split('/').pop() || 'backup.tar.enc';
|
||||||
|
filePath = `${tempDir}/${Date.now()}-${urlFilename}`;
|
||||||
|
tempFile = true;
|
||||||
|
|
||||||
|
logger.info(`Downloading backup from ${url}...`);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `Failed to download from URL: ${response.statusText}`,
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
await Deno.writeFile(filePath, new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
logger.info(`Downloaded backup to ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import using restoreBackup with mode='import'
|
||||||
|
const result = await this.oneboxRef.backupManager.restoreBackup(filePath, {
|
||||||
|
mode: 'import',
|
||||||
|
newServiceName,
|
||||||
|
overwriteExisting: false,
|
||||||
|
skipPlatformData: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
if (tempFile && filePath) {
|
||||||
|
try {
|
||||||
|
await Deno.remove(filePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Backup imported successfully as service '${result.service.name}'`,
|
||||||
|
data: {
|
||||||
|
service: result.service,
|
||||||
|
platformResourcesRestored: result.platformResourcesRestored,
|
||||||
|
warnings: result.warnings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to import backup: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to import backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set backup encryption password
|
||||||
|
*/
|
||||||
|
private async handleSetBackupPasswordRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { password } = body;
|
||||||
|
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Password is required',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Password must be at least 8 characters',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store password in settings
|
||||||
|
this.oneboxRef.database.setSetting('backup_encryption_password', password);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup password set successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to set backup password: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to set backup password',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if backup password is configured
|
||||||
|
*/
|
||||||
|
private async handleCheckBackupPasswordRequest(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const password = this.oneboxRef.database.getSetting('backup_encryption_password');
|
||||||
|
const isConfigured = password !== null && password.length > 0;
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isConfigured,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to check backup password: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to check backup password',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Backup Schedule Endpoints ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all backup schedules
|
||||||
|
*/
|
||||||
|
private async handleListBackupSchedulesRequest(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const schedules = this.oneboxRef.backupScheduler.getAllSchedules();
|
||||||
|
return this.jsonResponse({ success: true, data: schedules });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list backup schedules: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to list backup schedules',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new backup schedule
|
||||||
|
*/
|
||||||
|
private async handleCreateBackupScheduleRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as IBackupScheduleCreate;
|
||||||
|
|
||||||
|
// Validate scope type
|
||||||
|
if (!body.scopeType) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Scope type is required (all, pattern, or service)',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['all', 'pattern', 'service'].includes(body.scopeType)) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid scope type. Must be: all, pattern, or service',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate scope-specific requirements
|
||||||
|
if (body.scopeType === 'service' && !body.serviceName) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Service name is required for service-specific schedules',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.scopeType === 'pattern' && !body.scopePattern) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Scope pattern is required for pattern-based schedules',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.cronExpression) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Cron expression is required',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.retention) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Retention policy is required',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate retention policy
|
||||||
|
const { hourly, daily, weekly, monthly } = body.retention;
|
||||||
|
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
|
||||||
|
typeof weekly !== 'number' || typeof monthly !== 'number') {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Retention values must be non-negative',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await this.oneboxRef.backupScheduler.createSchedule(body);
|
||||||
|
|
||||||
|
// Build descriptive message based on scope type
|
||||||
|
let scopeDesc: string;
|
||||||
|
switch (body.scopeType) {
|
||||||
|
case 'all':
|
||||||
|
scopeDesc = 'all services';
|
||||||
|
break;
|
||||||
|
case 'pattern':
|
||||||
|
scopeDesc = `pattern '${body.scopePattern}'`;
|
||||||
|
break;
|
||||||
|
case 'service':
|
||||||
|
scopeDesc = `service '${body.serviceName}'`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Backup schedule created for ${scopeDesc}`,
|
||||||
|
data: schedule,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create backup schedule: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to create backup schedule',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific backup schedule
|
||||||
|
*/
|
||||||
|
private async handleGetBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const schedule = this.oneboxRef.backupScheduler.getScheduleById(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Backup schedule not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jsonResponse({ success: true, data: schedule });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to get backup schedule',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a backup schedule
|
||||||
|
*/
|
||||||
|
private async handleUpdateBackupScheduleRequest(scheduleId: number, req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as IBackupScheduleUpdate;
|
||||||
|
|
||||||
|
// Validate retention policy if provided
|
||||||
|
if (body.retention) {
|
||||||
|
const { hourly, daily, weekly, monthly } = body.retention;
|
||||||
|
if (typeof hourly !== 'number' || typeof daily !== 'number' ||
|
||||||
|
typeof weekly !== 'number' || typeof monthly !== 'number') {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Retention values must be non-negative',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await this.oneboxRef.backupScheduler.updateSchedule(scheduleId, body);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup schedule updated',
|
||||||
|
data: schedule,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to update backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to update backup schedule',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a backup schedule
|
||||||
|
*/
|
||||||
|
private async handleDeleteBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
await this.oneboxRef.backupScheduler.deleteSchedule(scheduleId);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup schedule deleted',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to delete backup schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to delete backup schedule',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger immediate backup for a schedule
|
||||||
|
*/
|
||||||
|
private async handleTriggerBackupScheduleRequest(scheduleId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
await this.oneboxRef.backupScheduler.triggerBackup(scheduleId);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Backup triggered successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to trigger backup for schedule ${scheduleId}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to trigger backup',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List backup schedules for a specific service
|
||||||
|
*/
|
||||||
|
private async handleListServiceBackupSchedulesRequest(serviceName: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const service = this.oneboxRef.services.getService(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedules = this.oneboxRef.backupScheduler.getSchedulesForService(serviceName);
|
||||||
|
return this.jsonResponse({ success: true, data: schedules });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list backup schedules for service ${serviceName}: ${getErrorMessage(error)}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error) || 'Failed to list backup schedules',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to create JSON response
|
* Helper to create JSON response
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { CertRequirementManager } from './cert-requirement-manager.ts';
|
|||||||
import { RegistryManager } from './registry.ts';
|
import { RegistryManager } from './registry.ts';
|
||||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||||
import { CaddyLogReceiver } from './caddy-log-receiver.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 {
|
export class Onebox {
|
||||||
public database: OneboxDatabase;
|
public database: OneboxDatabase;
|
||||||
@@ -36,6 +39,9 @@ export class Onebox {
|
|||||||
public registry: RegistryManager;
|
public registry: RegistryManager;
|
||||||
public platformServices: PlatformServicesManager;
|
public platformServices: PlatformServicesManager;
|
||||||
public caddyLogReceiver: CaddyLogReceiver;
|
public caddyLogReceiver: CaddyLogReceiver;
|
||||||
|
public backupManager: BackupManager;
|
||||||
|
public backupScheduler: BackupScheduler;
|
||||||
|
public opsServer: OpsServer;
|
||||||
|
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
@@ -67,6 +73,15 @@ export class Onebox {
|
|||||||
|
|
||||||
// Initialize Caddy log receiver
|
// Initialize Caddy log receiver
|
||||||
this.caddyLogReceiver = new CaddyLogReceiver(9999);
|
this.caddyLogReceiver = new CaddyLogReceiver(9999);
|
||||||
|
|
||||||
|
// Initialize Backup manager
|
||||||
|
this.backupManager = new BackupManager(this);
|
||||||
|
|
||||||
|
// Initialize Backup scheduler
|
||||||
|
this.backupScheduler = new BackupScheduler(this);
|
||||||
|
|
||||||
|
// Initialize OpsServer (TypedRequest-based server)
|
||||||
|
this.opsServer = new OpsServer(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,6 +176,14 @@ export class Onebox {
|
|||||||
// Start auto-update monitoring for registry services
|
// Start auto-update monitoring for registry services
|
||||||
this.services.startAutoUpdateMonitoring();
|
this.services.startAutoUpdateMonitoring();
|
||||||
|
|
||||||
|
// Initialize Backup Scheduler (non-critical)
|
||||||
|
try {
|
||||||
|
await this.backupScheduler.init();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Backup scheduler initialization failed - scheduled backups will be disabled');
|
||||||
|
logger.warn(`Error: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
logger.success('Onebox initialized successfully');
|
logger.success('Onebox initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -219,12 +242,51 @@ export class Onebox {
|
|||||||
const runningServices = services.filter((s) => s.status === 'running').length;
|
const runningServices = services.filter((s) => s.status === 'running').length;
|
||||||
const totalServices = services.length;
|
const totalServices = services.length;
|
||||||
|
|
||||||
// Get platform services status
|
// Get platform services status with resource counts
|
||||||
const platformServices = this.platformServices.getAllPlatformServices();
|
const platformServices = this.platformServices.getAllPlatformServices();
|
||||||
const platformServicesStatus = platformServices.map((ps) => ({
|
const providers = this.platformServices.getAllProviders();
|
||||||
type: ps.type,
|
const platformServicesStatus = providers.map((provider) => {
|
||||||
status: ps.status,
|
const service = platformServices.find((s) => s.type === provider.type);
|
||||||
}));
|
// For Caddy, check actual runtime status since it starts without a DB record
|
||||||
|
let status = service?.status || 'not-deployed';
|
||||||
|
if (provider.type === 'caddy') {
|
||||||
|
status = proxyStatus.http.running ? 'running' : 'stopped';
|
||||||
|
}
|
||||||
|
// Count resources for this platform service
|
||||||
|
const resourceCount = service?.id
|
||||||
|
? this.database.getPlatformResourcesByPlatformService(service.id).length
|
||||||
|
: 0;
|
||||||
|
return {
|
||||||
|
type: provider.type,
|
||||||
|
displayName: provider.displayName,
|
||||||
|
status,
|
||||||
|
resourceCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get certificate health summary
|
||||||
|
const certificates = this.ssl.listCertificates();
|
||||||
|
const now = Date.now();
|
||||||
|
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
let validCount = 0;
|
||||||
|
let expiringCount = 0;
|
||||||
|
let expiredCount = 0;
|
||||||
|
const expiringDomains: { domain: string; daysRemaining: number }[] = [];
|
||||||
|
|
||||||
|
for (const cert of certificates) {
|
||||||
|
if (cert.expiryDate <= now) {
|
||||||
|
expiredCount++;
|
||||||
|
} else if (cert.expiryDate <= now + thirtyDaysMs) {
|
||||||
|
expiringCount++;
|
||||||
|
const daysRemaining = Math.floor((cert.expiryDate - now) / (24 * 60 * 60 * 1000));
|
||||||
|
expiringDomains.push({ domain: cert.domain, daysRemaining });
|
||||||
|
} else {
|
||||||
|
validCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort expiring domains by days remaining (ascending)
|
||||||
|
expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
docker: {
|
docker: {
|
||||||
@@ -245,6 +307,12 @@ export class Onebox {
|
|||||||
stopped: totalServices - runningServices,
|
stopped: totalServices - runningServices,
|
||||||
},
|
},
|
||||||
platformServices: platformServicesStatus,
|
platformServices: platformServicesStatus,
|
||||||
|
certificateHealth: {
|
||||||
|
valid: validCount,
|
||||||
|
expiringSoon: expiringCount,
|
||||||
|
expired: expiredCount,
|
||||||
|
expiringDomains: expiringDomains.slice(0, 5), // Top 5 expiring
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
|
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
|
||||||
@@ -267,17 +335,17 @@ export class Onebox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start HTTP server
|
* Start OpsServer (TypedRequest-based, serves new UI)
|
||||||
*/
|
*/
|
||||||
async startHttpServer(port?: number): Promise<void> {
|
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> {
|
async stopHttpServer(): Promise<void> {
|
||||||
await this.httpServer.stop();
|
await this.opsServer.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,11 +355,14 @@ export class Onebox {
|
|||||||
try {
|
try {
|
||||||
logger.info('Shutting down Onebox...');
|
logger.info('Shutting down Onebox...');
|
||||||
|
|
||||||
|
// Stop backup scheduler
|
||||||
|
await this.backupScheduler.stop();
|
||||||
|
|
||||||
// Stop daemon if running
|
// Stop daemon if running
|
||||||
await this.daemon.stop();
|
await this.daemon.stop();
|
||||||
|
|
||||||
// Stop HTTP server if running
|
// Stop OpsServer if running
|
||||||
await this.httpServer.stop();
|
await this.opsServer.stop();
|
||||||
|
|
||||||
// Stop reverse proxy if running
|
// Stop reverse proxy if running
|
||||||
await this.reverseProxy.stop();
|
await this.reverseProxy.stop();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { IPlatformServiceProvider } from './providers/base.ts';
|
|||||||
import { MongoDBProvider } from './providers/mongodb.ts';
|
import { MongoDBProvider } from './providers/mongodb.ts';
|
||||||
import { MinioProvider } from './providers/minio.ts';
|
import { MinioProvider } from './providers/minio.ts';
|
||||||
import { CaddyProvider } from './providers/caddy.ts';
|
import { CaddyProvider } from './providers/caddy.ts';
|
||||||
|
import { ClickHouseProvider } from './providers/clickhouse.ts';
|
||||||
import { logger } from '../../logging.ts';
|
import { logger } from '../../logging.ts';
|
||||||
import { getErrorMessage } from '../../utils/error.ts';
|
import { getErrorMessage } from '../../utils/error.ts';
|
||||||
import { credentialEncryption } from '../encryption.ts';
|
import { credentialEncryption } from '../encryption.ts';
|
||||||
@@ -39,6 +40,7 @@ export class PlatformServicesManager {
|
|||||||
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
||||||
this.registerProvider(new MinioProvider(this.oneboxRef));
|
this.registerProvider(new MinioProvider(this.oneboxRef));
|
||||||
this.registerProvider(new CaddyProvider(this.oneboxRef));
|
this.registerProvider(new CaddyProvider(this.oneboxRef));
|
||||||
|
this.registerProvider(new ClickHouseProvider(this.oneboxRef));
|
||||||
|
|
||||||
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
|
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
|
||||||
}
|
}
|
||||||
@@ -275,6 +277,33 @@ export class PlatformServicesManager {
|
|||||||
logger.success(`S3 storage provisioned for service '${service.name}'`);
|
logger.success(`S3 storage provisioned for service '${service.name}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provision ClickHouse if requested
|
||||||
|
if (requirements.clickhouse) {
|
||||||
|
logger.info(`Provisioning ClickHouse for service '${service.name}'...`);
|
||||||
|
|
||||||
|
// Ensure ClickHouse is running
|
||||||
|
const clickhouseService = await this.ensureRunning('clickhouse');
|
||||||
|
const provider = this.providers.get('clickhouse')!;
|
||||||
|
|
||||||
|
// Provision database
|
||||||
|
const result = await provider.provisionResource(service);
|
||||||
|
|
||||||
|
// Store resource record
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||||
|
this.oneboxRef.database.createPlatformResource({
|
||||||
|
platformServiceId: clickhouseService.id!,
|
||||||
|
serviceId: service.id!,
|
||||||
|
resourceType: result.type,
|
||||||
|
resourceName: result.name,
|
||||||
|
credentialsEncrypted: encryptedCreds,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge env vars
|
||||||
|
Object.assign(allEnvVars, result.envVars);
|
||||||
|
logger.success(`ClickHouse provisioned for service '${service.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
return allEnvVars;
|
return allEnvVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
338
ts/classes/platform-services/providers/clickhouse.ts
Normal file
338
ts/classes/platform-services/providers/clickhouse.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* ClickHouse Platform Service Provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BasePlatformServiceProvider } from './base.ts';
|
||||||
|
import type {
|
||||||
|
IService,
|
||||||
|
IPlatformResource,
|
||||||
|
IPlatformServiceConfig,
|
||||||
|
IProvisionedResource,
|
||||||
|
IEnvVarMapping,
|
||||||
|
TPlatformServiceType,
|
||||||
|
TPlatformResourceType,
|
||||||
|
} from '../../../types.ts';
|
||||||
|
import { logger } from '../../../logging.ts';
|
||||||
|
import { getErrorMessage } from '../../../utils/error.ts';
|
||||||
|
import { credentialEncryption } from '../../encryption.ts';
|
||||||
|
import type { Onebox } from '../../onebox.ts';
|
||||||
|
|
||||||
|
export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||||
|
readonly type: TPlatformServiceType = 'clickhouse';
|
||||||
|
readonly displayName = 'ClickHouse';
|
||||||
|
readonly resourceTypes: TPlatformResourceType[] = ['database'];
|
||||||
|
|
||||||
|
constructor(oneboxRef: Onebox) {
|
||||||
|
super(oneboxRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultConfig(): IPlatformServiceConfig {
|
||||||
|
return {
|
||||||
|
image: 'clickhouse/clickhouse-server:latest',
|
||||||
|
port: 8123, // HTTP interface
|
||||||
|
volumes: ['/var/lib/onebox/clickhouse:/var/lib/clickhouse'],
|
||||||
|
environment: {
|
||||||
|
CLICKHOUSE_DB: 'default',
|
||||||
|
// Password will be generated and stored encrypted
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnvVarMappings(): IEnvVarMapping[] {
|
||||||
|
return [
|
||||||
|
{ envVar: 'CLICKHOUSE_HOST', credentialPath: 'host' },
|
||||||
|
{ envVar: 'CLICKHOUSE_PORT', credentialPath: 'port' },
|
||||||
|
{ envVar: 'CLICKHOUSE_HTTP_PORT', credentialPath: 'httpPort' },
|
||||||
|
{ envVar: 'CLICKHOUSE_DATABASE', credentialPath: 'database' },
|
||||||
|
{ envVar: 'CLICKHOUSE_USER', credentialPath: 'username' },
|
||||||
|
{ envVar: 'CLICKHOUSE_PASSWORD', credentialPath: 'password' },
|
||||||
|
{ envVar: 'CLICKHOUSE_URL', credentialPath: 'connectionUrl' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployContainer(): Promise<string> {
|
||||||
|
const config = this.getDefaultConfig();
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
const dataDir = '/var/lib/onebox/clickhouse';
|
||||||
|
|
||||||
|
logger.info(`Deploying ClickHouse platform service as ${containerName}...`);
|
||||||
|
|
||||||
|
// Check if we have existing data and stored credentials
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
let adminCredentials: { username: string; password: string };
|
||||||
|
let dataExists = false;
|
||||||
|
|
||||||
|
// Check if data directory has existing ClickHouse data
|
||||||
|
// ClickHouse creates 'metadata' directory on first startup
|
||||||
|
try {
|
||||||
|
const stat = await Deno.stat(`${dataDir}/metadata`);
|
||||||
|
dataExists = stat.isDirectory;
|
||||||
|
logger.info(`ClickHouse data directory exists with metadata folder`);
|
||||||
|
} catch {
|
||||||
|
// metadata directory doesn't exist, this is a fresh install
|
||||||
|
dataExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||||
|
// Reuse existing credentials from database
|
||||||
|
logger.info('Reusing existing ClickHouse credentials (data directory already initialized)');
|
||||||
|
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
} else {
|
||||||
|
// Generate new credentials for fresh deployment
|
||||||
|
logger.info('Generating new ClickHouse admin credentials');
|
||||||
|
adminCredentials = {
|
||||||
|
username: 'default',
|
||||||
|
password: credentialEncryption.generatePassword(32),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If data exists but we don't have credentials, we need to wipe the data
|
||||||
|
if (dataExists) {
|
||||||
|
logger.warn('ClickHouse data exists but no credentials in database - wiping data directory');
|
||||||
|
try {
|
||||||
|
await Deno.remove(dataDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to wipe ClickHouse data directory: ${getErrorMessage(e)}`);
|
||||||
|
throw new Error('Cannot deploy ClickHouse: data directory exists without credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
try {
|
||||||
|
await Deno.mkdir(dataDir, { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might already exist
|
||||||
|
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||||
|
logger.warn(`Could not create ClickHouse data directory: ${getErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container using Docker API
|
||||||
|
// ClickHouse uses environment variables for initial setup
|
||||||
|
const envVars = [
|
||||||
|
`CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1`,
|
||||||
|
`CLICKHOUSE_USER=${adminCredentials.username}`,
|
||||||
|
`CLICKHOUSE_PASSWORD=${adminCredentials.password}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerId = await this.oneboxRef.docker.createPlatformContainer({
|
||||||
|
name: containerName,
|
||||||
|
image: config.image,
|
||||||
|
port: config.port,
|
||||||
|
env: envVars,
|
||||||
|
volumes: config.volumes,
|
||||||
|
network: this.getNetworkName(),
|
||||||
|
exposePorts: [8123, 9000], // HTTP and native TCP ports
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store encrypted admin credentials (only update if new or changed)
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||||
|
if (platformService) {
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||||
|
containerId,
|
||||||
|
adminCredentialsEncrypted: encryptedCreds,
|
||||||
|
status: 'starting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`ClickHouse container created: ${containerId}`);
|
||||||
|
return containerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopContainer(containerId: string): Promise<void> {
|
||||||
|
logger.info(`Stopping ClickHouse container ${containerId}...`);
|
||||||
|
await this.oneboxRef.docker.stopContainer(containerId);
|
||||||
|
logger.success('ClickHouse container stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
logger.info('ClickHouse health check: starting...');
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService) {
|
||||||
|
logger.info('ClickHouse health check: platform service not found in database');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!platformService.adminCredentialsEncrypted) {
|
||||||
|
logger.info('ClickHouse health check: no admin credentials stored');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!platformService.containerId) {
|
||||||
|
logger.info('ClickHouse health check: no container ID in database record');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`ClickHouse health check: using container ID ${platformService.containerId.substring(0, 12)}...`);
|
||||||
|
|
||||||
|
// Use docker exec to run health check inside the container
|
||||||
|
// This avoids network issues with overlay networks
|
||||||
|
// Note: ClickHouse image has wget but not curl - use full path for reliability
|
||||||
|
const result = await this.oneboxRef.docker.execInContainer(
|
||||||
|
platformService.containerId,
|
||||||
|
['/usr/bin/wget', '-q', '-O', '-', 'http://localhost:8123/ping']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.exitCode === 0) {
|
||||||
|
logger.info('ClickHouse health check: success');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logger.info(`ClickHouse health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.info(`ClickHouse health check exception: ${getErrorMessage(error)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||||
|
throw new Error('ClickHouse platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
|
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
||||||
|
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
|
||||||
|
if (!hostPort) {
|
||||||
|
throw new Error('Could not get ClickHouse container host port');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate resource names and credentials
|
||||||
|
const dbName = this.generateResourceName(userService.name);
|
||||||
|
const username = this.generateResourceName(userService.name);
|
||||||
|
const password = credentialEncryption.generatePassword(32);
|
||||||
|
|
||||||
|
logger.info(`Provisioning ClickHouse database '${dbName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
|
// Connect to ClickHouse via localhost and the mapped host port
|
||||||
|
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
||||||
|
|
||||||
|
// Create database
|
||||||
|
await this.executeQuery(
|
||||||
|
baseUrl,
|
||||||
|
adminCreds.username,
|
||||||
|
adminCreds.password,
|
||||||
|
`CREATE DATABASE IF NOT EXISTS ${dbName}`
|
||||||
|
);
|
||||||
|
logger.info(`Created ClickHouse database '${dbName}'`);
|
||||||
|
|
||||||
|
// Create user with access to this database
|
||||||
|
await this.executeQuery(
|
||||||
|
baseUrl,
|
||||||
|
adminCreds.username,
|
||||||
|
adminCreds.password,
|
||||||
|
`CREATE USER IF NOT EXISTS ${username} IDENTIFIED BY '${password}'`
|
||||||
|
);
|
||||||
|
logger.info(`Created ClickHouse user '${username}'`);
|
||||||
|
|
||||||
|
// Grant permissions on the database
|
||||||
|
await this.executeQuery(
|
||||||
|
baseUrl,
|
||||||
|
adminCreds.username,
|
||||||
|
adminCreds.password,
|
||||||
|
`GRANT ALL ON ${dbName}.* TO ${username}`
|
||||||
|
);
|
||||||
|
logger.info(`Granted permissions to user '${username}' on database '${dbName}'`);
|
||||||
|
|
||||||
|
logger.success(`ClickHouse database '${dbName}' provisioned with user '${username}'`);
|
||||||
|
|
||||||
|
// Build the credentials and env vars
|
||||||
|
const credentials: Record<string, string> = {
|
||||||
|
host: containerName,
|
||||||
|
port: '9000', // Native TCP port
|
||||||
|
httpPort: '8123',
|
||||||
|
database: dbName,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
connectionUrl: `http://${username}:${password}@${containerName}:8123/?database=${dbName}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map credentials to env vars
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
for (const mapping of this.getEnvVarMappings()) {
|
||||||
|
if (credentials[mapping.credentialPath]) {
|
||||||
|
envVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'database',
|
||||||
|
name: dbName,
|
||||||
|
credentials,
|
||||||
|
envVars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||||
|
throw new Error('ClickHouse platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
|
||||||
|
// Get container host port for connection from host (overlay network IPs not accessible from host)
|
||||||
|
const hostPort = await this.oneboxRef.docker.getContainerHostPort(platformService.containerId, 8123);
|
||||||
|
if (!hostPort) {
|
||||||
|
throw new Error('Could not get ClickHouse container host port');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
|
||||||
|
|
||||||
|
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Drop the user
|
||||||
|
try {
|
||||||
|
await this.executeQuery(
|
||||||
|
baseUrl,
|
||||||
|
adminCreds.username,
|
||||||
|
adminCreds.password,
|
||||||
|
`DROP USER IF EXISTS ${credentials.username}`
|
||||||
|
);
|
||||||
|
logger.info(`Dropped ClickHouse user '${credentials.username}'`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Could not drop ClickHouse user: ${getErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the database
|
||||||
|
await this.executeQuery(
|
||||||
|
baseUrl,
|
||||||
|
adminCreds.username,
|
||||||
|
adminCreds.password,
|
||||||
|
`DROP DATABASE IF EXISTS ${resource.resourceName}`
|
||||||
|
);
|
||||||
|
logger.success(`ClickHouse database '${resource.resourceName}' dropped`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to deprovision ClickHouse database: ${getErrorMessage(e)}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a ClickHouse SQL query via HTTP interface
|
||||||
|
*/
|
||||||
|
private async executeQuery(
|
||||||
|
baseUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
query: string
|
||||||
|
): Promise<string> {
|
||||||
|
const url = `${baseUrl}/?user=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`ClickHouse query failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,10 +49,11 @@ export class OneboxServicesManager {
|
|||||||
|
|
||||||
// Build platform requirements
|
// Build platform requirements
|
||||||
const platformRequirements: IPlatformRequirements | undefined =
|
const platformRequirements: IPlatformRequirements | undefined =
|
||||||
(options.enableMongoDB || options.enableS3)
|
(options.enableMongoDB || options.enableS3 || options.enableClickHouse)
|
||||||
? {
|
? {
|
||||||
mongodb: options.enableMongoDB,
|
mongodb: options.enableMongoDB,
|
||||||
s3: options.enableS3,
|
s3: options.enableS3,
|
||||||
|
clickhouse: options.enableClickHouse,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -286,8 +286,8 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
|
|||||||
|
|
||||||
logger.info('Starting Onebox server...');
|
logger.info('Starting Onebox server...');
|
||||||
|
|
||||||
// Start HTTP server
|
// Start OpsServer (serves new UI + TypedRequest API)
|
||||||
await onebox.httpServer.start(port);
|
await onebox.opsServer.start(port);
|
||||||
|
|
||||||
// Start monitoring if requested
|
// Start monitoring if requested
|
||||||
if (monitor) {
|
if (monitor) {
|
||||||
@@ -308,7 +308,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
|
|||||||
if (monitor) {
|
if (monitor) {
|
||||||
onebox.daemon.stopMonitoring();
|
onebox.daemon.stopMonitoring();
|
||||||
}
|
}
|
||||||
await onebox.httpServer.stop();
|
await onebox.opsServer.stop();
|
||||||
await onebox.shutdown();
|
await onebox.shutdown();
|
||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import type {
|
|||||||
IDomain,
|
IDomain,
|
||||||
ICertificate,
|
ICertificate,
|
||||||
ICertRequirement,
|
ICertRequirement,
|
||||||
|
IBackup,
|
||||||
|
IBackupSchedule,
|
||||||
|
IBackupScheduleUpdate,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
import type { TBindValue } from './types.ts';
|
import type { TBindValue } from './types.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
AuthRepository,
|
AuthRepository,
|
||||||
MetricsRepository,
|
MetricsRepository,
|
||||||
PlatformRepository,
|
PlatformRepository,
|
||||||
|
BackupRepository,
|
||||||
} from './repositories/index.ts';
|
} from './repositories/index.ts';
|
||||||
|
|
||||||
export class OneboxDatabase {
|
export class OneboxDatabase {
|
||||||
@@ -44,6 +48,7 @@ export class OneboxDatabase {
|
|||||||
private authRepo!: AuthRepository;
|
private authRepo!: AuthRepository;
|
||||||
private metricsRepo!: MetricsRepository;
|
private metricsRepo!: MetricsRepository;
|
||||||
private platformRepo!: PlatformRepository;
|
private platformRepo!: PlatformRepository;
|
||||||
|
private backupRepo!: BackupRepository;
|
||||||
|
|
||||||
constructor(dbPath = './.nogit/onebox.db') {
|
constructor(dbPath = './.nogit/onebox.db') {
|
||||||
this.dbPath = dbPath;
|
this.dbPath = dbPath;
|
||||||
@@ -76,6 +81,7 @@ export class OneboxDatabase {
|
|||||||
this.authRepo = new AuthRepository(queryFn);
|
this.authRepo = new AuthRepository(queryFn);
|
||||||
this.metricsRepo = new MetricsRepository(queryFn);
|
this.metricsRepo = new MetricsRepository(queryFn);
|
||||||
this.platformRepo = new PlatformRepository(queryFn);
|
this.platformRepo = new PlatformRepository(queryFn);
|
||||||
|
this.backupRepo = new BackupRepository(queryFn);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -705,6 +711,214 @@ export class OneboxDatabase {
|
|||||||
this.setMigrationVersion(8);
|
this.setMigrationVersion(8);
|
||||||
logger.success('Migration 8 completed: Certificates table now stores PEM content');
|
logger.success('Migration 8 completed: Certificates table now stores PEM content');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 9: Backup system tables
|
||||||
|
const version9 = this.getMigrationVersion();
|
||||||
|
if (version9 < 9) {
|
||||||
|
logger.info('Running migration 9: Creating backup system tables...');
|
||||||
|
|
||||||
|
// Add include_image_in_backup column to services table
|
||||||
|
this.query(`ALTER TABLE services ADD COLUMN include_image_in_backup INTEGER DEFAULT 1`);
|
||||||
|
|
||||||
|
// Create backups table
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE backups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
includes_image INTEGER NOT NULL,
|
||||||
|
platform_resources TEXT NOT NULL DEFAULT '[]',
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(9);
|
||||||
|
logger.success('Migration 9 completed: Backup system tables created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 10: Backup schedules table and extend backups table
|
||||||
|
const version10 = this.getMigrationVersion();
|
||||||
|
if (version10 < 10) {
|
||||||
|
logger.info('Running migration 10: Creating backup schedules table...');
|
||||||
|
|
||||||
|
// Create backup_schedules table
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE backup_schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
cron_expression TEXT NOT NULL,
|
||||||
|
retention_tier TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_run_at REAL,
|
||||||
|
next_run_at REAL,
|
||||||
|
last_status TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
|
||||||
|
|
||||||
|
// Extend backups table with retention_tier and schedule_id columns
|
||||||
|
this.query('ALTER TABLE backups ADD COLUMN retention_tier TEXT');
|
||||||
|
this.query('ALTER TABLE backups ADD COLUMN schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL');
|
||||||
|
|
||||||
|
this.setMigrationVersion(10);
|
||||||
|
logger.success('Migration 10 completed: Backup schedules table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 11: Add scope columns for global/pattern backup schedules
|
||||||
|
const version11 = this.getMigrationVersion();
|
||||||
|
if (version11 < 11) {
|
||||||
|
logger.info('Running migration 11: Adding scope columns to backup_schedules...');
|
||||||
|
|
||||||
|
// Recreate backup_schedules table with nullable service_id/service_name and new scope columns
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE backup_schedules_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scope_type TEXT NOT NULL DEFAULT 'service',
|
||||||
|
scope_pattern TEXT,
|
||||||
|
service_id INTEGER,
|
||||||
|
service_name TEXT,
|
||||||
|
cron_expression TEXT NOT NULL,
|
||||||
|
retention_tier TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_run_at REAL,
|
||||||
|
next_run_at REAL,
|
||||||
|
last_status TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Copy existing schedules (all are service-specific)
|
||||||
|
this.query(`
|
||||||
|
INSERT INTO backup_schedules_new (
|
||||||
|
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
|
||||||
|
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, 'service', NULL, service_id, service_name, cron_expression,
|
||||||
|
retention_tier, enabled, last_run_at, next_run_at, last_status, last_error,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM backup_schedules
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.query('DROP TABLE backup_schedules');
|
||||||
|
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(11);
|
||||||
|
logger.success('Migration 11 completed: Scope columns added to backup_schedules');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 12: GFS retention policy - replace retention_tier with per-tier retention counts
|
||||||
|
const version12 = this.getMigrationVersion();
|
||||||
|
if (version12 < 12) {
|
||||||
|
logger.info('Running migration 12: Updating backup system for GFS retention policy...');
|
||||||
|
|
||||||
|
// Recreate backup_schedules table with new retention columns
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE backup_schedules_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scope_type TEXT NOT NULL DEFAULT 'service',
|
||||||
|
scope_pattern TEXT,
|
||||||
|
service_id INTEGER,
|
||||||
|
service_name TEXT,
|
||||||
|
cron_expression TEXT NOT NULL,
|
||||||
|
retention_hourly INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retention_daily INTEGER NOT NULL DEFAULT 7,
|
||||||
|
retention_weekly INTEGER NOT NULL DEFAULT 4,
|
||||||
|
retention_monthly INTEGER NOT NULL DEFAULT 12,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_run_at REAL,
|
||||||
|
next_run_at REAL,
|
||||||
|
last_status TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migrate existing data - convert old retention_tier to new format
|
||||||
|
// daily -> D:7, weekly -> W:4, monthly -> M:12, yearly -> M:12 (yearly becomes long monthly retention)
|
||||||
|
this.query(`
|
||||||
|
INSERT INTO backup_schedules_new (
|
||||||
|
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
|
||||||
|
retention_hourly, retention_daily, retention_weekly, retention_monthly,
|
||||||
|
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, scope_type, scope_pattern, service_id, service_name, cron_expression,
|
||||||
|
0, -- retention_hourly
|
||||||
|
CASE WHEN retention_tier = 'daily' THEN 7 ELSE 0 END,
|
||||||
|
CASE WHEN retention_tier IN ('daily', 'weekly') THEN 4 ELSE 0 END,
|
||||||
|
CASE WHEN retention_tier IN ('daily', 'weekly', 'monthly') THEN 12
|
||||||
|
WHEN retention_tier = 'yearly' THEN 24 ELSE 12 END,
|
||||||
|
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
|
||||||
|
FROM backup_schedules
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.query('DROP TABLE backup_schedules');
|
||||||
|
this.query('ALTER TABLE backup_schedules_new RENAME TO backup_schedules');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_service ON backup_schedules(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_enabled ON backup_schedules(enabled)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backup_schedules_scope ON backup_schedules(scope_type)');
|
||||||
|
|
||||||
|
// Recreate backups table without retention_tier column
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE backups_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
service_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
includes_image INTEGER NOT NULL,
|
||||||
|
platform_resources TEXT NOT NULL DEFAULT '[]',
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
schedule_id INTEGER REFERENCES backup_schedules(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.query(`
|
||||||
|
INSERT INTO backups_new (
|
||||||
|
id, service_id, service_name, filename, size_bytes, created_at,
|
||||||
|
includes_image, platform_resources, checksum, schedule_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, service_id, service_name, filename, size_bytes, created_at,
|
||||||
|
includes_image, platform_resources, checksum, schedule_id
|
||||||
|
FROM backups
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.query('DROP TABLE backups');
|
||||||
|
this.query('ALTER TABLE backups_new RENAME TO backups');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backups_service ON backups(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_backups_schedule ON backups(schedule_id)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(12);
|
||||||
|
logger.success('Migration 12 completed: GFS retention policy schema updated');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
||||||
if (error instanceof Error && error.stack) {
|
if (error instanceof Error && error.stack) {
|
||||||
@@ -1078,4 +1292,68 @@ export class OneboxDatabase {
|
|||||||
deletePlatformResourcesByService(serviceId: number): void {
|
deletePlatformResourcesByService(serviceId: number): void {
|
||||||
this.platformRepo.deletePlatformResourcesByService(serviceId);
|
this.platformRepo.deletePlatformResourcesByService(serviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Backups (delegated to repository) ============
|
||||||
|
|
||||||
|
createBackup(backup: Omit<IBackup, 'id'>): IBackup {
|
||||||
|
return this.backupRepo.create(backup);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupById(id: number): IBackup | null {
|
||||||
|
return this.backupRepo.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupsByService(serviceId: number): IBackup[] {
|
||||||
|
return this.backupRepo.getByService(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllBackups(): IBackup[] {
|
||||||
|
return this.backupRepo.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBackup(id: number): void {
|
||||||
|
this.backupRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBackupsByService(serviceId: number): void {
|
||||||
|
this.backupRepo.deleteByService(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupsBySchedule(scheduleId: number): IBackup[] {
|
||||||
|
return this.backupRepo.getBySchedule(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Backup Schedules (delegated to repository) ============
|
||||||
|
|
||||||
|
createBackupSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
|
||||||
|
return this.backupRepo.createSchedule(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupScheduleById(id: number): IBackupSchedule | null {
|
||||||
|
return this.backupRepo.getScheduleById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBackupSchedulesByService(serviceId: number): IBackupSchedule[] {
|
||||||
|
return this.backupRepo.getSchedulesByService(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnabledBackupSchedules(): IBackupSchedule[] {
|
||||||
|
return this.backupRepo.getEnabledSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllBackupSchedules(): IBackupSchedule[] {
|
||||||
|
return this.backupRepo.getAllSchedules();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBackupSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
|
||||||
|
this.backupRepo.updateSchedule(id, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBackupSchedule(id: number): void {
|
||||||
|
this.backupRepo.deleteSchedule(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBackupSchedulesByService(serviceId: number): void {
|
||||||
|
this.backupRepo.deleteSchedulesByService(serviceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
249
ts/database/repositories/backup.repository.ts
Normal file
249
ts/database/repositories/backup.repository.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Backup Repository
|
||||||
|
* Handles CRUD operations for backups and backup_schedules tables
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseRepository } from '../base.repository.ts';
|
||||||
|
import type {
|
||||||
|
IBackup,
|
||||||
|
IBackupSchedule,
|
||||||
|
IBackupScheduleUpdate,
|
||||||
|
TPlatformServiceType,
|
||||||
|
TBackupScheduleScope,
|
||||||
|
IRetentionPolicy,
|
||||||
|
} from '../../types.ts';
|
||||||
|
|
||||||
|
export class BackupRepository extends BaseRepository {
|
||||||
|
// ============ Backup CRUD ============
|
||||||
|
|
||||||
|
create(backup: Omit<IBackup, 'id'>): IBackup {
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO backups (
|
||||||
|
service_id, service_name, filename, size_bytes, created_at,
|
||||||
|
includes_image, platform_resources, checksum, schedule_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
backup.serviceId,
|
||||||
|
backup.serviceName,
|
||||||
|
backup.filename,
|
||||||
|
backup.sizeBytes,
|
||||||
|
backup.createdAt,
|
||||||
|
backup.includesImage ? 1 : 0,
|
||||||
|
JSON.stringify(backup.platformResources),
|
||||||
|
backup.checksum,
|
||||||
|
backup.scheduleId ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the created backup by looking for the most recent one with matching filename
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backups WHERE filename = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
[backup.filename]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.rowToBackup(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): IBackup | null {
|
||||||
|
const rows = this.query('SELECT * FROM backups WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToBackup(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getByService(serviceId: number): IBackup[] {
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backups WHERE service_id = ? ORDER BY created_at DESC',
|
||||||
|
[serviceId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => this.rowToBackup(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): IBackup[] {
|
||||||
|
const rows = this.query('SELECT * FROM backups ORDER BY created_at DESC');
|
||||||
|
return rows.map((row) => this.rowToBackup(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: number): void {
|
||||||
|
this.query('DELETE FROM backups WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteByService(serviceId: number): void {
|
||||||
|
this.query('DELETE FROM backups WHERE service_id = ?', [serviceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBySchedule(scheduleId: number): IBackup[] {
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backups WHERE schedule_id = ? ORDER BY created_at DESC',
|
||||||
|
[scheduleId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => this.rowToBackup(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToBackup(row: any): IBackup {
|
||||||
|
let platformResources: TPlatformServiceType[] = [];
|
||||||
|
const platformResourcesRaw = row.platform_resources;
|
||||||
|
if (platformResourcesRaw) {
|
||||||
|
try {
|
||||||
|
platformResources = JSON.parse(String(platformResourcesRaw));
|
||||||
|
} catch {
|
||||||
|
platformResources = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
serviceId: Number(row.service_id),
|
||||||
|
serviceName: String(row.service_name),
|
||||||
|
filename: String(row.filename),
|
||||||
|
sizeBytes: Number(row.size_bytes),
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
includesImage: Boolean(row.includes_image),
|
||||||
|
platformResources,
|
||||||
|
checksum: String(row.checksum),
|
||||||
|
scheduleId: row.schedule_id ? Number(row.schedule_id) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Backup Schedule CRUD ============
|
||||||
|
|
||||||
|
createSchedule(schedule: Omit<IBackupSchedule, 'id'>): IBackupSchedule {
|
||||||
|
const now = Date.now();
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO backup_schedules (
|
||||||
|
scope_type, scope_pattern, service_id, service_name, cron_expression,
|
||||||
|
retention_hourly, retention_daily, retention_weekly, retention_monthly,
|
||||||
|
enabled, last_run_at, next_run_at, last_status, last_error, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
schedule.scopeType,
|
||||||
|
schedule.scopePattern ?? null,
|
||||||
|
schedule.serviceId ?? null,
|
||||||
|
schedule.serviceName ?? null,
|
||||||
|
schedule.cronExpression,
|
||||||
|
schedule.retention.hourly,
|
||||||
|
schedule.retention.daily,
|
||||||
|
schedule.retention.weekly,
|
||||||
|
schedule.retention.monthly,
|
||||||
|
schedule.enabled ? 1 : 0,
|
||||||
|
schedule.lastRunAt,
|
||||||
|
schedule.nextRunAt,
|
||||||
|
schedule.lastStatus,
|
||||||
|
schedule.lastError,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the created schedule by looking for the most recent one with matching scope
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backup_schedules WHERE scope_type = ? AND cron_expression = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
[schedule.scopeType, schedule.cronExpression]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.rowToSchedule(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getScheduleById(id: number): IBackupSchedule | null {
|
||||||
|
const rows = this.query('SELECT * FROM backup_schedules WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToSchedule(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSchedulesByService(serviceId: number): IBackupSchedule[] {
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backup_schedules WHERE service_id = ? ORDER BY created_at DESC',
|
||||||
|
[serviceId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => this.rowToSchedule(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnabledSchedules(): IBackupSchedule[] {
|
||||||
|
const rows = this.query(
|
||||||
|
'SELECT * FROM backup_schedules WHERE enabled = 1 ORDER BY next_run_at ASC'
|
||||||
|
);
|
||||||
|
return rows.map((row) => this.rowToSchedule(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllSchedules(): IBackupSchedule[] {
|
||||||
|
const rows = this.query('SELECT * FROM backup_schedules ORDER BY created_at DESC');
|
||||||
|
return rows.map((row) => this.rowToSchedule(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSchedule(id: number, updates: IBackupScheduleUpdate & { lastRunAt?: number; nextRunAt?: number; lastStatus?: 'success' | 'failed' | null; lastError?: string | null }): void {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const params: (string | number | null)[] = [];
|
||||||
|
|
||||||
|
if (updates.cronExpression !== undefined) {
|
||||||
|
setClauses.push('cron_expression = ?');
|
||||||
|
params.push(updates.cronExpression);
|
||||||
|
}
|
||||||
|
if (updates.retention !== undefined) {
|
||||||
|
setClauses.push('retention_hourly = ?');
|
||||||
|
params.push(updates.retention.hourly);
|
||||||
|
setClauses.push('retention_daily = ?');
|
||||||
|
params.push(updates.retention.daily);
|
||||||
|
setClauses.push('retention_weekly = ?');
|
||||||
|
params.push(updates.retention.weekly);
|
||||||
|
setClauses.push('retention_monthly = ?');
|
||||||
|
params.push(updates.retention.monthly);
|
||||||
|
}
|
||||||
|
if (updates.enabled !== undefined) {
|
||||||
|
setClauses.push('enabled = ?');
|
||||||
|
params.push(updates.enabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (updates.lastRunAt !== undefined) {
|
||||||
|
setClauses.push('last_run_at = ?');
|
||||||
|
params.push(updates.lastRunAt);
|
||||||
|
}
|
||||||
|
if (updates.nextRunAt !== undefined) {
|
||||||
|
setClauses.push('next_run_at = ?');
|
||||||
|
params.push(updates.nextRunAt);
|
||||||
|
}
|
||||||
|
if (updates.lastStatus !== undefined) {
|
||||||
|
setClauses.push('last_status = ?');
|
||||||
|
params.push(updates.lastStatus);
|
||||||
|
}
|
||||||
|
if (updates.lastError !== undefined) {
|
||||||
|
setClauses.push('last_error = ?');
|
||||||
|
params.push(updates.lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.length === 0) return;
|
||||||
|
|
||||||
|
setClauses.push('updated_at = ?');
|
||||||
|
params.push(Date.now());
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
this.query(`UPDATE backup_schedules SET ${setClauses.join(', ')} WHERE id = ?`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSchedule(id: number): void {
|
||||||
|
this.query('DELETE FROM backup_schedules WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSchedulesByService(serviceId: number): void {
|
||||||
|
this.query('DELETE FROM backup_schedules WHERE service_id = ?', [serviceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToSchedule(row: any): IBackupSchedule {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
scopeType: (String(row.scope_type) || 'service') as TBackupScheduleScope,
|
||||||
|
scopePattern: row.scope_pattern ? String(row.scope_pattern) : undefined,
|
||||||
|
serviceId: row.service_id ? Number(row.service_id) : undefined,
|
||||||
|
serviceName: row.service_name ? String(row.service_name) : undefined,
|
||||||
|
cronExpression: String(row.cron_expression),
|
||||||
|
retention: {
|
||||||
|
hourly: Number(row.retention_hourly ?? 0),
|
||||||
|
daily: Number(row.retention_daily ?? 7),
|
||||||
|
weekly: Number(row.retention_weekly ?? 4),
|
||||||
|
monthly: Number(row.retention_monthly ?? 12),
|
||||||
|
} as IRetentionPolicy,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
lastRunAt: row.last_run_at ? Number(row.last_run_at) : null,
|
||||||
|
nextRunAt: row.next_run_at ? Number(row.next_run_at) : null,
|
||||||
|
lastStatus: row.last_status ? (String(row.last_status) as 'success' | 'failed') : null,
|
||||||
|
lastError: row.last_error ? String(row.last_error) : null,
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
updatedAt: Number(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export { CertificateRepository } from './certificate.repository.ts';
|
|||||||
export { AuthRepository } from './auth.repository.ts';
|
export { AuthRepository } from './auth.repository.ts';
|
||||||
export { MetricsRepository } from './metrics.repository.ts';
|
export { MetricsRepository } from './metrics.repository.ts';
|
||||||
export { PlatformRepository } from './platform.repository.ts';
|
export { PlatformRepository } from './platform.repository.ts';
|
||||||
|
export { BackupRepository } from './backup.repository.ts';
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ export class ServiceRepository extends BaseRepository {
|
|||||||
fields.push('platform_requirements = ?');
|
fields.push('platform_requirements = ?');
|
||||||
values.push(JSON.stringify(updates.platformRequirements));
|
values.push(JSON.stringify(updates.platformRequirements));
|
||||||
}
|
}
|
||||||
|
if (updates.includeImageInBackup !== undefined) {
|
||||||
|
fields.push('include_image_in_backup = ?');
|
||||||
|
values.push(updates.includeImageInBackup ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
fields.push('updated_at = ?');
|
fields.push('updated_at = ?');
|
||||||
values.push(Date.now());
|
values.push(Date.now());
|
||||||
@@ -172,6 +176,9 @@ export class ServiceRepository extends BaseRepository {
|
|||||||
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
||||||
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
||||||
platformRequirements,
|
platformRequirements,
|
||||||
|
includeImageInBackup: row.include_image_in_backup !== undefined
|
||||||
|
? Boolean(row.include_image_in_backup)
|
||||||
|
: true, // Default to true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
ts/opsserver/classes.opsserver.ts
Normal file
76
ts/opsserver/classes.opsserver.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
ts/opsserver/handlers/admin.handler.ts
Normal file
175
ts/opsserver/handlers/admin.handler.ts
Normal 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' },
|
||||||
|
);
|
||||||
|
}
|
||||||
100
ts/opsserver/handlers/backups.handler.ts
Normal file
100
ts/opsserver/handlers/backups.handler.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
ts/opsserver/handlers/dns.handler.ts
Normal file
65
ts/opsserver/handlers/dns.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
ts/opsserver/handlers/domains.handler.ts
Normal file
101
ts/opsserver/handlers/domains.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ts/opsserver/handlers/index.ts
Normal file
13
ts/opsserver/handlers/index.ts
Normal 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';
|
||||||
219
ts/opsserver/handlers/logs.handler.ts
Normal file
219
ts/opsserver/handlers/logs.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
ts/opsserver/handlers/network.handler.ts
Normal file
123
ts/opsserver/handlers/network.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
ts/opsserver/handlers/platform.handler.ts
Normal file
169
ts/opsserver/handlers/platform.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
ts/opsserver/handlers/registry.handler.ts
Normal file
147
ts/opsserver/handlers/registry.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ts/opsserver/handlers/schedules.handler.ts
Normal file
93
ts/opsserver/handlers/schedules.handler.ts
Normal 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! };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
ts/opsserver/handlers/services.handler.ts
Normal file
244
ts/opsserver/handlers/services.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ts/opsserver/handlers/settings.handler.ts
Normal file
86
ts/opsserver/handlers/settings.handler.ts
Normal 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 } };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
ts/opsserver/handlers/ssl.handler.ts
Normal file
64
ts/opsserver/handlers/ssl.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
ts/opsserver/handlers/status.handler.ts
Normal file
26
ts/opsserver/handlers/status.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ts/opsserver/helpers/guards.ts
Normal file
29
ts/opsserver/helpers/guards.ts
Normal 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
1
ts/opsserver/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './classes.opsserver.ts';
|
||||||
@@ -41,6 +41,10 @@ export { smartregistry };
|
|||||||
import * as smarts3 from '@push.rocks/smarts3';
|
import * as smarts3 from '@push.rocks/smarts3';
|
||||||
export { smarts3 };
|
export { smarts3 };
|
||||||
|
|
||||||
|
// Task scheduling and cron jobs
|
||||||
|
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||||
|
export { taskbuffer };
|
||||||
|
|
||||||
// Crypto utilities (for password hashing, encryption)
|
// Crypto utilities (for password hashing, encryption)
|
||||||
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
||||||
export { bcrypt };
|
export { bcrypt };
|
||||||
@@ -57,3 +61,13 @@ export { crypto };
|
|||||||
import * as nodeHttps from 'node:https';
|
import * as nodeHttps from 'node:https';
|
||||||
import * as nodeHttp from 'node:http';
|
import * as nodeHttp from 'node:http';
|
||||||
export { nodeHttps, nodeHttp };
|
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 };
|
||||||
|
|||||||
131
ts/types.ts
131
ts/types.ts
@@ -23,6 +23,8 @@ export interface IService {
|
|||||||
imageDigest?: string;
|
imageDigest?: string;
|
||||||
// Platform service requirements
|
// Platform service requirements
|
||||||
platformRequirements?: IPlatformRequirements;
|
platformRequirements?: IPlatformRequirements;
|
||||||
|
// Backup settings
|
||||||
|
includeImageInBackup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry types
|
// Registry types
|
||||||
@@ -73,7 +75,7 @@ export interface ITokenCreatedResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Platform service types
|
// Platform service types
|
||||||
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy';
|
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse';
|
||||||
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||||
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ export interface IPlatformResource {
|
|||||||
export interface IPlatformRequirements {
|
export interface IPlatformRequirements {
|
||||||
mongodb?: boolean;
|
mongodb?: boolean;
|
||||||
s3?: boolean;
|
s3?: boolean;
|
||||||
|
clickhouse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProvisionedResource {
|
export interface IProvisionedResource {
|
||||||
@@ -287,6 +290,7 @@ export interface IServiceDeployOptions {
|
|||||||
// Platform service requirements
|
// Platform service requirements
|
||||||
enableMongoDB?: boolean;
|
enableMongoDB?: boolean;
|
||||||
enableS3?: boolean;
|
enableS3?: boolean;
|
||||||
|
enableClickHouse?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP API request/response types
|
// HTTP API request/response types
|
||||||
@@ -315,3 +319,128 @@ export interface ICliArgs {
|
|||||||
_: string[];
|
_: string[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup types
|
||||||
|
export type TBackupRestoreMode = 'restore' | 'import' | 'clone';
|
||||||
|
|
||||||
|
// Retention policy for GFS (Grandfather-Father-Son) time-window based retention
|
||||||
|
export interface IRetentionPolicy {
|
||||||
|
hourly: number; // 0 = disabled, else keep up to N backups from last 24h
|
||||||
|
daily: number; // Keep 1 backup per day for last N days
|
||||||
|
weekly: number; // Keep 1 backup per week for last N weeks
|
||||||
|
monthly: number; // Keep 1 backup per month for last N months
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default retention presets
|
||||||
|
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; // Denormalized for display
|
||||||
|
filename: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
createdAt: number;
|
||||||
|
includesImage: boolean;
|
||||||
|
platformResources: TPlatformServiceType[]; // Which platform types were backed up
|
||||||
|
checksum: string;
|
||||||
|
// Scheduled backup fields
|
||||||
|
scheduleId?: number; // Links backup to its schedule for retention
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupManifest {
|
||||||
|
version: string;
|
||||||
|
createdAt: number;
|
||||||
|
oneboxVersion: string;
|
||||||
|
serviceName: string;
|
||||||
|
includesImage: boolean;
|
||||||
|
platformResources: TPlatformServiceType[];
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupServiceConfig {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
registry?: string;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
port: number;
|
||||||
|
domain?: string;
|
||||||
|
useOneboxRegistry?: boolean;
|
||||||
|
registryRepository?: string;
|
||||||
|
registryImageTag?: string;
|
||||||
|
autoUpdateOnPush?: boolean;
|
||||||
|
platformRequirements?: IPlatformRequirements;
|
||||||
|
includeImageInBackup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupPlatformResource {
|
||||||
|
resourceType: TPlatformResourceType;
|
||||||
|
resourceName: string;
|
||||||
|
platformServiceType: TPlatformServiceType;
|
||||||
|
credentials: Record<string, string>; // Decrypted for backup, re-encrypted on restore
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupResult {
|
||||||
|
backup: IBackup;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRestoreOptions {
|
||||||
|
mode: TBackupRestoreMode;
|
||||||
|
newServiceName?: string; // Required for 'import' and 'clone' modes
|
||||||
|
skipPlatformData?: boolean; // Restore config only, skip DB/bucket data
|
||||||
|
overwriteExisting?: boolean; // For 'restore' mode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRestoreResult {
|
||||||
|
service: IService;
|
||||||
|
platformResourcesRestored: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup scheduling types (GFS retention scheme)
|
||||||
|
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
|
||||||
|
|
||||||
|
export interface IBackupSchedule {
|
||||||
|
id?: number;
|
||||||
|
scopeType: TBackupScheduleScope;
|
||||||
|
scopePattern?: string; // Glob pattern for 'pattern' scope type
|
||||||
|
serviceId?: number; // Only for 'service' scope type
|
||||||
|
serviceName?: string; // Only for 'service' scope type
|
||||||
|
cronExpression: string;
|
||||||
|
retention: IRetentionPolicy; // Per-tier retention counts
|
||||||
|
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; // Required for 'pattern' scope type
|
||||||
|
serviceName?: string; // Required for 'service' scope type
|
||||||
|
cronExpression: string;
|
||||||
|
retention: IRetentionPolicy;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBackupScheduleUpdate {
|
||||||
|
cronExpression?: string;
|
||||||
|
retention?: IRetentionPolicy;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup creation options (for scheduled backups)
|
||||||
|
export interface IBackupCreateOptions {
|
||||||
|
scheduleId?: number;
|
||||||
|
}
|
||||||
|
|||||||
11
ts_bundled/bundle.ts
Normal file
11
ts_bundled/bundle.ts
Normal file
File diff suppressed because one or more lines are too long
16
ts_interfaces/data/auth.ts
Normal file
16
ts_interfaces/data/auth.ts
Normal 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';
|
||||||
|
}
|
||||||
89
ts_interfaces/data/backup.ts
Normal file
89
ts_interfaces/data/backup.ts
Normal 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;
|
||||||
|
}
|
||||||
59
ts_interfaces/data/domain.ts
Normal file
59
ts_interfaces/data/domain.ts
Normal 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;
|
||||||
|
}
|
||||||
9
ts_interfaces/data/index.ts
Normal file
9
ts_interfaces/data/index.ts
Normal 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';
|
||||||
64
ts_interfaces/data/network.ts
Normal file
64
ts_interfaces/data/network.ts
Normal 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;
|
||||||
|
}
|
||||||
37
ts_interfaces/data/platform.ts
Normal file
37
ts_interfaces/data/platform.ts
Normal 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;
|
||||||
|
}
|
||||||
35
ts_interfaces/data/registry.ts
Normal file
35
ts_interfaces/data/registry.ts
Normal 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;
|
||||||
|
}
|
||||||
82
ts_interfaces/data/service.ts
Normal file
82
ts_interfaces/data/service.ts
Normal 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';
|
||||||
|
}
|
||||||
14
ts_interfaces/data/settings.ts
Normal file
14
ts_interfaces/data/settings.ts
Normal 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;
|
||||||
|
}
|
||||||
32
ts_interfaces/data/system.ts
Normal file
32
ts_interfaces/data/system.ts
Normal 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
9
ts_interfaces/index.ts
Normal 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
6
ts_interfaces/plugins.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// @apiglobal scope
|
||||||
|
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||||
|
|
||||||
|
export {
|
||||||
|
typedrequestInterfaces,
|
||||||
|
};
|
||||||
58
ts_interfaces/requests/admin.ts
Normal file
58
ts_interfaces/requests/admin.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
86
ts_interfaces/requests/backup-schedules.ts
Normal file
86
ts_interfaces/requests/backup-schedules.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
73
ts_interfaces/requests/backups.ts
Normal file
73
ts_interfaces/requests/backups.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
58
ts_interfaces/requests/dns.ts
Normal file
58
ts_interfaces/requests/dns.ts
Normal 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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
42
ts_interfaces/requests/domains.ts
Normal file
42
ts_interfaces/requests/domains.ts
Normal 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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
13
ts_interfaces/requests/index.ts
Normal file
13
ts_interfaces/requests/index.ts
Normal 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';
|
||||||
60
ts_interfaces/requests/logs.ts
Normal file
60
ts_interfaces/requests/logs.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
41
ts_interfaces/requests/network.ts
Normal file
41
ts_interfaces/requests/network.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
71
ts_interfaces/requests/platform-services.ts
Normal file
71
ts_interfaces/requests/platform-services.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
57
ts_interfaces/requests/registry.ts
Normal file
57
ts_interfaces/requests/registry.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
214
ts_interfaces/requests/services.ts
Normal file
214
ts_interfaces/requests/services.ts
Normal 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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
56
ts_interfaces/requests/settings.ts
Normal file
56
ts_interfaces/requests/settings.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
57
ts_interfaces/requests/ssl.ts
Normal file
57
ts_interfaces/requests/ssl.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
15
ts_interfaces/requests/status.ts
Normal file
15
ts_interfaces/requests/status.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
8
ts_web/00_commitinfo_data.ts
Normal file
8
ts_web/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: '@serve.zone/onebox',
|
||||||
|
version: '1.11.0',
|
||||||
|
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
|
||||||
|
}
|
||||||
919
ts_web/appstate.ts
Normal file
919
ts_web/appstate.ts
Normal 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
13
ts_web/elements/index.ts
Normal 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';
|
||||||
207
ts_web/elements/ob-app-shell.ts
Normal file
207
ts_web/elements/ob-app-shell.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
ts_web/elements/ob-view-dashboard.ts
Normal file
164
ts_web/elements/ob-view-dashboard.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
ts_web/elements/ob-view-network.ts
Normal file
197
ts_web/elements/ob-view-network.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
ts_web/elements/ob-view-registries.ts
Normal file
84
ts_web/elements/ob-view-registries.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
321
ts_web/elements/ob-view-services.ts
Normal file
321
ts_web/elements/ob-view-services.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ts_web/elements/ob-view-settings.ts
Normal file
93
ts_web/elements/ob-view-settings.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ts_web/elements/ob-view-tokens.ts
Normal file
86
ts_web/elements/ob-view-tokens.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ts_web/elements/shared/css.ts
Normal file
10
ts_web/elements/shared/css.ts
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
2
ts_web/elements/shared/index.ts
Normal file
2
ts_web/elements/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './css.js';
|
||||||
|
export * from './ob-sectionheading.js';
|
||||||
37
ts_web/elements/shared/ob-sectionheading.ts
Normal file
37
ts_web/elements/shared/ob-sectionheading.ts
Normal 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
7
ts_web/index.ts
Normal 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
14
ts_web/plugins.ts
Normal 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;
|
||||||
@@ -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
42
ui/.gitignore
vendored
@@ -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
|
|
||||||
59
ui/README.md
59
ui/README.md
@@ -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.
|
|
||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
9197
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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 />
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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])),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user