feat(opsserver): introduce OpsServer (TypedRequest API) and new lightweight web UI; replace legacy Angular UI and add typed interfaces

This commit is contained in:
2026-02-24 18:15:44 +00:00
parent 84c47cd7f5
commit ba05cc84fe
143 changed files with 46631 additions and 20632 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## 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

View File

@@ -22,7 +22,12 @@
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0",
"@push.rocks/smartregistry": "npm:@push.rocks/smartregistry@^2.2.0",
"@push.rocks/smarts3": "npm:@push.rocks/smarts3@^5.1.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0"
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^3.1.0",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.2.6",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.3.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.1"
},
"compilerOptions": {
"lib": [

36196
dist_serve/bundle.js Normal file

File diff suppressed because one or more lines are too long

33
dist_serve/index.html Normal file
View File

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

33
html/index.html Normal file
View File

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

35
npmextra.json Normal file
View File

@@ -0,0 +1,35 @@
{
"@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",
"watchPatterns": ["./ts_web/**/*"],
"triggerReload": true
}
],
"watchers": [
{
"name": "backend",
"watch": "./ts/**/*",
"command": "deno run --allow-all mod.ts server",
"restart": true,
"debounce": 500,
"runOnStart": true
}
]
}
}

View File

@@ -9,7 +9,9 @@
},
"scripts": {
"postinstall": "node scripts/install-binary.js",
"watch": "concurrently --kill-others --names \"BACKEND,UI\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor\" \"cd ui && pnpm run watch\""
"watch": "concurrently --kill-others --names \"BACKEND,UI\" --prefix-colors \"cyan,magenta\" \"deno run --allow-all --unstable-ffi --watch mod.ts server --ephemeral --monitor\" \"tswatch\"",
"build": "tsbundle",
"bundle": "tsbundle"
},
"keywords": [
"docker",
@@ -51,8 +53,15 @@
"arm64"
],
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"dependencies": {},
"dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19",
"@design.estate/dees-catalog": "^3.43.0",
"@design.estate/dees-element": "^2.1.6",
"@serve.zone/catalog": "^2.1.0"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.8.4",
"@git.zone/tswatch": "^2.3.13",
"concurrently": "^9.1.2"
}
}

5119
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -286,8 +286,8 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
logger.info('Starting Onebox server...');
// Start HTTP server
await onebox.httpServer.start(port);
// Start OpsServer (serves new UI + TypedRequest API)
await onebox.opsServer.start(port);
// Start monitoring if requested
if (monitor) {
@@ -308,7 +308,7 @@ async function handleServerCommand(onebox: Onebox, args: string[]) {
if (monitor) {
onebox.daemon.stopMonitoring();
}
await onebox.httpServer.stop();
await onebox.opsServer.stop();
await onebox.shutdown();
Deno.exit(0);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

11
ts_bundled/bundle.ts Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

9
ts_interfaces/index.ts Normal file
View File

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

6
ts_interfaces/plugins.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

919
ts_web/appstate.ts Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,219 @@
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-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 {
return html`
<ob-sectionheading>Services</ob-sectionheading>
<sz-services-list-view
.services=${this.servicesState.services}
@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,
});
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 {
return html`
<ob-sectionheading>Service Details</ob-sectionheading>
<sz-service-detail-view
.service=${this.servicesState.currentService}
.logs=${this.servicesState.currentServiceLogs}
.stats=${this.servicesState.currentServiceStats}
@back=${() => {
this.currentView = 'list';
}}
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
></sz-service-detail-view>
`;
}
private renderBackupsView(): TemplateResult {
return html`
<ob-sectionheading>Backups</ob-sectionheading>
<sz-services-backups-view
.schedules=${this.backupsState.schedules}
.backups=${this.backupsState.backups}
@create-schedule=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.createScheduleAction, {
config: e.detail,
});
}}
@run-now=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.triggerScheduleAction, {
scheduleId: e.detail.scheduleId,
});
}}
@delete-backup=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.deleteBackupAction, {
backupId: e.detail.backupId,
});
}}
></sz-services-backups-view>
`;
}
private renderPlatformDetailView(): TemplateResult {
const platformService = this.servicesState.platformServices.find(
(ps) => ps.type === this.selectedPlatformType,
);
return html`
<ob-sectionheading>Platform Service</ob-sectionheading>
<sz-platform-service-detail-view
.service=${platformService
? {
id: platformService.type,
name: platformService.displayName,
type: platformService.type,
status: platformService.status,
version: '',
host: 'localhost',
port: 0,
config: {},
}
: null}
.logs=${[]}
@start=${() => {
appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, {
serviceType: this.selectedPlatformType as any,
});
}}
@stop=${() => {
appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, {
serviceType: this.selectedPlatformType as any,
});
}}
></sz-platform-service-detail-view>
`;
}
private async handleServiceAction(e: CustomEvent) {
const action = e.detail.action;
const name = e.detail.service?.name || e.detail.name || this.selectedServiceName;
switch (action) {
case 'start':
await appstate.servicesStatePart.dispatchAction(appstate.startServiceAction, { name });
break;
case 'stop':
await appstate.servicesStatePart.dispatchAction(appstate.stopServiceAction, { name });
break;
case 'restart':
await appstate.servicesStatePart.dispatchAction(appstate.restartServiceAction, { name });
break;
case 'delete':
await appstate.servicesStatePart.dispatchAction(appstate.deleteServiceAction, { name });
this.currentView = 'list';
break;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

7
ts_web/index.ts Normal file
View File

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

14
ts_web/plugins.ts Normal file
View File

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

View File

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

42
ui/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

9197
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,114 +0,0 @@
import { Injectable, signal, computed, effect, inject } from '@angular/core';
import { IWebSocketMessage, IStatsUpdateMessage, IContainerStats } from '../types/api.types';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class WebSocketService {
private auth = inject(AuthService);
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
isConnected = signal(false);
lastMessage = signal<IWebSocketMessage | null>(null);
// Computed signals for specific message types
serviceUpdates = computed(() => {
const msg = this.lastMessage();
return msg?.type === 'service_update' ? msg : null;
});
serviceStatus = computed(() => {
const msg = this.lastMessage();
return msg?.type === 'service_status' ? msg : null;
});
systemStatus = computed(() => {
const msg = this.lastMessage();
return msg?.type === 'system_status' ? msg : null;
});
statsUpdate = computed(() => {
const msg = this.lastMessage();
return msg?.type === 'stats_update' ? (msg as unknown as IStatsUpdateMessage) : null;
});
constructor() {
// Auto-connect when authenticated
effect(() => {
if (this.auth.isAuthenticated()) {
this.connect();
} else {
this.disconnect();
}
});
}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) return;
if (typeof window === 'undefined') return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const url = `${protocol}//${host}/api/ws`;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.isConnected.set(true);
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
};
this.ws.onmessage = (event) => {
try {
const message: IWebSocketMessage = JSON.parse(event.data);
this.lastMessage.set(message);
} catch {
console.error('Failed to parse WebSocket message');
}
};
this.ws.onclose = () => {
this.isConnected.set(false);
this.ws = null;
this.attemptReconnect();
};
this.ws.onerror = () => {
this.isConnected.set(false);
};
} catch {
this.isConnected.set(false);
}
}
private attemptReconnect(): void {
if (!this.auth.isAuthenticated()) return;
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
// Exponential backoff
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
}
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnected.set(false);
}
send(message: any): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
}

View File

@@ -1,436 +0,0 @@
export interface IApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface IUser {
username: string;
role: 'admin' | 'user';
}
export interface ILoginResponse {
token: string;
user: IUser;
}
// Platform Service Types (defined early for use in ISystemStatus)
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 IService {
id?: number;
name: string;
image: string;
registry?: string;
envVars: Record<string, string>;
port: number;
domain?: string;
containerID?: string;
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
createdAt: number;
updatedAt: number;
useOneboxRegistry?: boolean;
registryRepository?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
platformRequirements?: IPlatformRequirements;
}
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; // ClickHouse analytics database
}
export interface IServiceUpdate {
image?: string;
registry?: string;
port?: number;
domain?: string;
envVars?: Record<string, string>;
}
export interface ISystemStatus {
docker: {
running: boolean;
version: any;
};
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 }>;
};
}
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;
certPath: string;
keyPath: string;
fullChainPath: 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;
}
export interface IRegistry {
id?: number;
url: string;
username: string;
createdAt: number;
}
export interface IRegistryCreate {
url: string;
username: string;
password: string;
}
// Registry Token Types
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;
}
export interface ISetting {
key: string;
value: string;
updatedAt: number;
}
export interface ISettings {
cloudflareToken: string;
cloudflareZoneId: string;
autoRenewCerts: boolean;
renewalThreshold: number;
acmeEmail: string;
httpPort: number;
httpsPort: number;
forceHttps: boolean;
}
export interface IWebSocketMessage {
type: 'connected' | 'service_update' | 'service_status' | 'system_status' | 'stats_update';
action?: 'created' | 'updated' | 'deleted' | 'started' | 'stopped';
serviceName?: string;
status?: string;
stats?: IContainerStats;
data?: any;
message?: string;
timestamp: number;
}
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface IToast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
// Platform Service Interfaces
export interface IPlatformService {
type: TPlatformServiceType;
displayName: string;
resourceTypes: TPlatformResourceType[];
status: TPlatformServiceStatus;
containerId?: string;
isCore?: boolean; // true for core services like Caddy (cannot be stopped)
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;
}
// Network Types
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 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;
}
// Container stats (live)
export interface IContainerStats {
cpuPercent: number;
memoryUsed: number;
memoryLimit: number;
memoryPercent: number;
networkRx: number;
networkTx: number;
}
// Historical metrics
export interface IMetric {
id?: number;
serviceId: number;
timestamp: number;
cpuPercent: number;
memoryUsed: number;
memoryLimit: number;
networkRxBytes: number;
networkTxBytes: number;
}
// Stats update WebSocket message
export interface IStatsUpdateMessage {
type: 'stats_update';
serviceName: string;
stats: IContainerStats;
timestamp: number;
}
// Traffic stats from Caddy access logs
export interface ITrafficStats {
requestCount: number;
errorCount: number;
avgResponseTime: number; // milliseconds
totalBytes: number;
statusCounts: Record<string, number>; // '2xx', '3xx', '4xx', '5xx'
requestsPerMinute: number;
errorRate: number; // percentage
}
// Backup Types
export interface IBackup {
id?: number;
serviceId: number;
serviceName: string;
filename: string;
sizeBytes: number;
createdAt: number;
includesImage: boolean;
platformResources: TPlatformServiceType[];
checksum: string;
}
export type TRestoreMode = 'restore' | 'import' | 'clone';
export interface IRestoreOptions {
mode: TRestoreMode;
newServiceName?: string;
overwriteExisting?: boolean;
skipPlatformData?: boolean;
}
export interface IRestoreResult {
service: IService;
platformResourcesRestored: number;
warnings: string[];
}
export interface IBackupPasswordStatus {
isConfigured: boolean;
}
// Backup Schedule Types
export type TBackupScheduleScope = 'all' | 'pattern' | 'service';
// 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 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;
}
// Updated IBackup with schedule fields
export interface IBackupWithSchedule extends IBackup {
scheduleId?: number;
}

View File

@@ -1,99 +0,0 @@
import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
interface ICertificateHealth {
valid: number;
expiringSoon: number;
expired: number;
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
}
@Component({
selector: 'app-certificates-card',
standalone: true,
host: { class: 'block h-full' },
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
],
template: `
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Certificates</ui-card-title>
<ui-card-description>SSL/TLS certificate status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-3">
<!-- Status summary -->
<div class="space-y-2">
@if (health.valid > 0) {
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm">{{ health.valid }} valid</span>
</div>
}
@if (health.expiringSoon > 0) {
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="text-sm text-warning">{{ health.expiringSoon }} expiring soon</span>
</div>
}
@if (health.expired > 0) {
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-sm text-destructive">{{ health.expired }} expired</span>
</div>
}
@if (health.valid === 0 && health.expiringSoon === 0 && health.expired === 0) {
<div class="text-sm text-muted-foreground">No certificates</div>
}
</div>
<!-- Expiring domains list -->
@if (health.expiringDomains.length > 0) {
<div class="border-t pt-2 space-y-1">
@for (item of health.expiringDomains; track item.domain) {
<a [routerLink]="['/network']"
class="flex items-center justify-between text-sm py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
<span class="truncate text-muted-foreground">{{ item.domain }}</span>
<span
class="ml-2 whitespace-nowrap"
[class.text-warning]="item.daysRemaining > 7"
[class.text-destructive]="item.daysRemaining <= 7">
{{ item.daysRemaining }}d
</span>
</a>
}
</div>
}
</ui-card-content>
</ui-card>
`,
})
export class CertificatesCardComponent {
@Input() health: ICertificateHealth = {
valid: 0,
expiringSoon: 0,
expired: 0,
expiringDomains: [],
};
}

View File

@@ -1,272 +0,0 @@
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { ToastService } from '../../core/services/toast.service';
import { ISystemStatus } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
import { TrafficCardComponent } from './traffic-card.component';
import { PlatformServicesCardComponent } from './platform-services-card.component';
import { CertificatesCardComponent } from './certificates-card.component';
import { ResourceUsageCardComponent } from './resource-usage-card.component';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
SkeletonComponent,
TrafficCardComponent,
PlatformServicesCardComponent,
CertificatesCardComponent,
ResourceUsageCardComponent,
],
template: `
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
<p class="text-muted-foreground">System overview and quick actions</p>
</div>
<button uiButton variant="outline" (click)="loadStatus()" [disabled]="loading()">
@if (loading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
}
Refresh
</button>
</div>
@if (loading() && !status()) {
<!-- Loading skeleton -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@for (_ of [1,2,3,4]; track $index) {
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-skeleton class="h-4 w-24" />
</ui-card-header>
<ui-card-content>
<ui-skeleton class="h-8 w-16" />
</ui-card-content>
</ui-card>
}
</div>
} @else if (status()) {
<!-- Row 1: Key Stats Grid -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Total Services</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ status()!.services.total }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Running</ui-card-title>
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold text-success">{{ status()!.services.running }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Stopped</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ status()!.services.stopped }}</div>
</ui-card-content>
</ui-card>
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<ui-card-title class="text-sm font-medium">Docker</ui-card-title>
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</ui-card-header>
<ui-card-content>
<ui-badge [variant]="status()!.docker.running ? 'success' : 'destructive'">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</ui-badge>
</ui-card-content>
</ui-card>
</div>
<!-- Row 2: Resource Usage (full width) -->
<app-resource-usage-card />
<!-- Row 3: Traffic & Platform Services (2-column) -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Traffic Overview -->
<app-traffic-card />
<!-- Platform Services Status -->
<app-platform-services-card [services]="status()!.platformServices" />
</div>
<!-- Row 4: Certificates & System Status (3-column) -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Certificates Health -->
<app-certificates-card [health]="status()!.certificateHealth" />
<!-- Reverse Proxy -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Reverse Proxy</ui-card-title>
<ui-card-description>HTTP/HTTPS proxy status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">HTTP ({{ status()!.reverseProxy.http.port }})</span>
<ui-badge [variant]="status()!.reverseProxy.http.running ? 'success' : 'secondary'">
{{ status()!.reverseProxy.http.running ? 'Active' : 'Inactive' }}
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">HTTPS ({{ status()!.reverseProxy.https.port }})</span>
<ui-badge [variant]="status()!.reverseProxy.https.running ? 'success' : 'secondary'">
{{ status()!.reverseProxy.https.running ? 'Active' : 'Inactive' }}
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Routes</span>
<span class="text-sm font-medium">{{ status()!.reverseProxy.routes }}</span>
</div>
</ui-card-content>
</ui-card>
<!-- DNS & SSL Combined -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>DNS & SSL</ui-card-title>
<ui-card-description>Configuration status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">Cloudflare DNS</span>
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">ACME (Let's Encrypt)</span>
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
</ui-card-content>
</ui-card>
</div>
<!-- Row 5: Quick Actions -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Quick Actions</ui-card-title>
<ui-card-description>Common tasks and shortcuts</ui-card-description>
</ui-card-header>
<ui-card-content class="flex flex-wrap gap-4">
<a routerLink="/services/create">
<button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Deploy Service
</button>
</a>
<a routerLink="/services">
<button uiButton variant="outline">View All Services</button>
</a>
<a routerLink="/platform-services">
<button uiButton variant="outline">Platform Services</button>
</a>
<a routerLink="/network">
<button uiButton variant="outline">Manage Domains</button>
</a>
</ui-card-content>
</ui-card>
}
</div>
`,
})
export class DashboardComponent implements OnInit, OnDestroy {
private api = inject(ApiService);
private ws = inject(WebSocketService);
private toast = inject(ToastService);
status = signal<ISystemStatus | null>(null);
loading = signal(false);
private refreshInterval: any;
constructor() {
// React to WebSocket updates
effect(() => {
const update = this.ws.serviceUpdates();
const systemStatus = this.ws.systemStatus();
if (update || systemStatus) {
this.loadStatus();
}
});
}
ngOnInit(): void {
this.loadStatus();
// Auto-refresh every 30 seconds
this.refreshInterval = setInterval(() => this.loadStatus(), 30000);
}
ngOnDestroy(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
async loadStatus(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getStatus();
if (response.success && response.data) {
this.status.set(response.data);
} else {
this.toast.error(response.error || 'Failed to load status');
}
} catch (err) {
this.toast.error('Failed to load status');
} finally {
this.loading.set(false);
}
}
}

View File

@@ -1,111 +0,0 @@
import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TPlatformServiceType, TPlatformServiceStatus } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
interface IPlatformServiceSummary {
type: TPlatformServiceType;
displayName: string;
status: TPlatformServiceStatus;
resourceCount: number;
}
@Component({
selector: 'app-platform-services-card',
standalone: true,
host: { class: 'block h-full' },
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
],
template: `
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Platform Services</ui-card-title>
<ui-card-description>Infrastructure status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
@for (service of services; track service.type) {
<a [routerLink]="['/platform-services', service.type]"
class="flex items-center justify-between py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
<div class="flex items-center gap-2">
<!-- Status indicator -->
<span
class="h-2 w-2 rounded-full"
[class.bg-success]="service.status === 'running'"
[class.bg-muted-foreground]="service.status === 'not-deployed' || service.status === 'stopped'"
[class.bg-warning]="service.status === 'starting' || service.status === 'stopping'"
[class.bg-destructive]="service.status === 'failed'">
</span>
<span class="text-sm">{{ service.displayName }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
@if (service.status === 'running') {
@if (service.resourceCount > 0) {
<span>{{ service.resourceCount }} {{ service.resourceCount === 1 ? getResourceLabel(service.type) : getResourceLabelPlural(service.type) }}</span>
} @else {
<span>Running</span>
}
} @else {
<span class="capitalize">{{ formatStatus(service.status) }}</span>
}
</div>
</a>
} @empty {
<div class="text-sm text-muted-foreground">No platform services</div>
}
</ui-card-content>
</ui-card>
`,
})
export class PlatformServicesCardComponent {
@Input() services: IPlatformServiceSummary[] = [];
formatStatus(status: string): string {
return status.replace('-', ' ');
}
getResourceLabel(type: TPlatformServiceType): string {
switch (type) {
case 'mongodb':
case 'postgresql':
case 'clickhouse':
return 'DB';
case 'minio':
return 'bucket';
case 'redis':
return 'cache';
case 'rabbitmq':
return 'queue';
default:
return 'resource';
}
}
getResourceLabelPlural(type: TPlatformServiceType): string {
switch (type) {
case 'mongodb':
case 'postgresql':
case 'clickhouse':
return 'DBs';
case 'minio':
return 'buckets';
case 'redis':
return 'caches';
case 'rabbitmq':
return 'queues';
default:
return 'resources';
}
}
}

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