Compare commits

..

2 Commits

Author SHA1 Message Date
22a7e76645 v1.23.0
All checks were successful
Release / build-and-release (push) Successful in 3m24s
2026-03-21 19:36:25 +00:00
22f34e7de5 feat(appstore): add remote app store templates with service upgrades and Redis/MariaDB platform support 2026-03-21 19:36:25 +00:00
36 changed files with 2498 additions and 256 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-03-21 - 1.23.0 - feat(appstore)
add remote app store templates with service upgrades and Redis/MariaDB platform support
- introduces an App Store manager, API handlers, shared request types, and web UI flow for browsing remote templates and deploying services from template metadata
- tracks app template id and version on services, adds upgrade discovery and migration-based service upgrades, and includes a database migration for template version columns
- adds Redis and MariaDB platform service providers with provisioning plus backup and restore support, and exposes their requirements through service creation and app template config
## 2026-03-18 - 1.22.2 - fix(web-ui) ## 2026-03-18 - 1.22.2 - fix(web-ui)
stabilize app store service creation flow and add Ghost sqlite defaults stabilize app store service creation flow and add Ghost sqlite defaults

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/onebox", "name": "@serve.zone/onebox",
"version": "1.22.2", "version": "1.23.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts", "main": "mod.ts",
"type": "module", "type": "module",
@@ -58,7 +58,7 @@
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@design.estate/dees-catalog": "^3.43.3", "@design.estate/dees-catalog": "^3.43.3",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.1.6",
"@serve.zone/catalog": "^2.8.0" "@serve.zone/catalog": "^2.9.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.9.0", "@git.zone/tsbundle": "^2.9.0",

10
pnpm-lock.yaml generated
View File

@@ -21,8 +21,8 @@ importers:
specifier: ^2.1.6 specifier: ^2.1.6
version: 2.2.3 version: 2.2.3
'@serve.zone/catalog': '@serve.zone/catalog':
specifier: ^2.8.0 specifier: ^2.9.0
version: 2.8.0(@tiptap/pm@2.27.2) version: 2.9.0(@tiptap/pm@2.27.2)
devDependencies: devDependencies:
'@git.zone/tsbundle': '@git.zone/tsbundle':
specifier: ^2.9.0 specifier: ^2.9.0
@@ -839,8 +839,8 @@ packages:
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@serve.zone/catalog@2.8.0': '@serve.zone/catalog@2.9.0':
resolution: {integrity: sha512-p0ES14JwUoJE88DBtLSHcCfFPVa0vKhvHnQLaAY3OC15kfheNKidi1SwTFyMh43jj0ZNi4Lecc3W02wG6sasHw==} resolution: {integrity: sha512-7FgwS44pD/DFVj29jS0Kwwyn1i5h8cf4/yWMBEY8+8GO70ab3QctbcKMu+BVa1G3gIrpLqhpmxLFDoeL/zDnQA==}
'@tempfix/idb@8.0.3': '@tempfix/idb@8.0.3':
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==} resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
@@ -3477,7 +3477,7 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
'@serve.zone/catalog@2.8.0(@tiptap/pm@2.27.2)': '@serve.zone/catalog@2.9.0(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.48.5(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.1 '@design.estate/dees-domtools': 2.5.1

View File

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

View File

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

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

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

View File

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

View File

@@ -20,6 +20,7 @@ import { CloudflareDomainSync } from './cloudflare-sync.ts';
import { CertRequirementManager } from './cert-requirement-manager.ts'; import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts'; import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts'; import { PlatformServicesManager } from './platform-services/index.ts';
import { AppStoreManager } from './appstore.ts';
import { CaddyLogReceiver } from './caddy-log-receiver.ts'; import { CaddyLogReceiver } from './caddy-log-receiver.ts';
import { BackupManager } from './backup-manager.ts'; import { BackupManager } from './backup-manager.ts';
import { BackupScheduler } from './backup-scheduler.ts'; import { BackupScheduler } from './backup-scheduler.ts';
@@ -40,6 +41,7 @@ export class Onebox {
public certRequirementManager: CertRequirementManager; public certRequirementManager: CertRequirementManager;
public registry: RegistryManager; public registry: RegistryManager;
public platformServices: PlatformServicesManager; public platformServices: PlatformServicesManager;
public appStore: AppStoreManager;
public caddyLogReceiver: CaddyLogReceiver; public caddyLogReceiver: CaddyLogReceiver;
public backupManager: BackupManager; public backupManager: BackupManager;
public backupScheduler: BackupScheduler; public backupScheduler: BackupScheduler;
@@ -74,6 +76,9 @@ export class Onebox {
// Initialize platform services manager // Initialize platform services manager
this.platformServices = new PlatformServicesManager(this); this.platformServices = new PlatformServicesManager(this);
// Initialize App Store manager
this.appStore = new AppStoreManager(this);
// Initialize Caddy log receiver // Initialize Caddy log receiver
this.caddyLogReceiver = new CaddyLogReceiver(9999); this.caddyLogReceiver = new CaddyLogReceiver(9999);
@@ -173,6 +178,14 @@ export class Onebox {
logger.warn(`Error: ${getErrorMessage(error)}`); logger.warn(`Error: ${getErrorMessage(error)}`);
} }
// Initialize App Store (non-critical)
try {
await this.appStore.init();
} catch (error) {
logger.warn('App Store initialization failed - app templates will be unavailable until reconnected');
logger.warn(`Error: ${getErrorMessage(error)}`);
}
// Login to all registries // Login to all registries
await this.registries.loginToAllRegistries(); await this.registries.loginToAllRegistries();

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,11 +50,13 @@ export class OneboxServicesManager {
// Build platform requirements // Build platform requirements
const platformRequirements: IPlatformRequirements | undefined = const platformRequirements: IPlatformRequirements | undefined =
(options.enableMongoDB || options.enableS3 || options.enableClickHouse) (options.enableMongoDB || options.enableS3 || options.enableClickHouse || options.enableRedis || options.enableMariaDB)
? { ? {
mongodb: options.enableMongoDB, mongodb: options.enableMongoDB,
s3: options.enableS3, s3: options.enableS3,
clickhouse: options.enableClickHouse, clickhouse: options.enableClickHouse,
redis: options.enableRedis,
mariadb: options.enableMariaDB,
} }
: undefined; : undefined;
@@ -76,6 +78,9 @@ export class OneboxServicesManager {
autoUpdateOnPush: options.autoUpdateOnPush, autoUpdateOnPush: options.autoUpdateOnPush,
// Platform requirements // Platform requirements
platformRequirements, platformRequirements,
// App Store template tracking
appTemplateId: options.appTemplateId,
appTemplateVersion: options.appTemplateVersion,
}); });
// Provision platform resources if needed // Provision platform resources if needed

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,3 +12,4 @@ export * from './schedules.handler.ts';
export * from './settings.handler.ts'; export * from './settings.handler.ts';
export * from './logs.handler.ts'; export * from './logs.handler.ts';
export * from './workspace.handler.ts'; export * from './workspace.handler.ts';
export * from './appstore.handler.ts';

View File

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

View File

@@ -25,6 +25,9 @@ export interface IService {
platformRequirements?: IPlatformRequirements; platformRequirements?: IPlatformRequirements;
// Backup settings // Backup settings
includeImageInBackup?: boolean; includeImageInBackup?: boolean;
// App Store template tracking
appTemplateId?: string;
appTemplateVersion?: string;
} }
// Registry types // Registry types
@@ -75,7 +78,7 @@ export interface ITokenCreatedResponse {
} }
// Platform service types // Platform service types
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse'; export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq' | 'caddy' | 'clickhouse' | 'mariadb';
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
@@ -113,6 +116,8 @@ export interface IPlatformRequirements {
mongodb?: boolean; mongodb?: boolean;
s3?: boolean; s3?: boolean;
clickhouse?: boolean; clickhouse?: boolean;
redis?: boolean;
mariadb?: boolean;
} }
export interface IProvisionedResource { export interface IProvisionedResource {
@@ -291,6 +296,11 @@ export interface IServiceDeployOptions {
enableMongoDB?: boolean; enableMongoDB?: boolean;
enableS3?: boolean; enableS3?: boolean;
enableClickHouse?: boolean; enableClickHouse?: boolean;
enableRedis?: boolean;
enableMariaDB?: boolean;
// App Store template tracking
appTemplateId?: string;
appTemplateVersion?: string;
} }
// HTTP API request/response types // HTTP API request/response types

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -12,3 +12,4 @@ export * from './backup-schedules.ts';
export * from './settings.ts'; export * from './settings.ts';
export * from './logs.ts'; export * from './logs.ts';
export * from './workspace.ts'; export * from './workspace.ts';
export * from './appstore.ts';

View File

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

View File

@@ -54,6 +54,11 @@ export interface ISettingsState {
backupPasswordConfigured: boolean; backupPasswordConfigured: boolean;
} }
export interface IAppStoreState {
apps: interfaces.requests.ICatalogApp[];
upgradeableServices: interfaces.requests.IUpgradeableService[];
}
export interface IUiState { export interface IUiState {
activeView: string; activeView: string;
autoRefresh: boolean; autoRefresh: boolean;
@@ -137,6 +142,15 @@ export const settingsStatePart = await appState.getStatePart<ISettingsState>(
'soft', 'soft',
); );
export const appStoreStatePart = await appState.getStatePart<IAppStoreState>(
'appStore',
{
apps: [],
upgradeableServices: [],
},
'soft',
);
export const uiStatePart = await appState.getStatePart<IUiState>( export const uiStatePart = await appState.getStatePart<IUiState>(
'ui', 'ui',
{ {
@@ -914,7 +928,8 @@ export const setBackupPasswordAction = settingsStatePart.createAction<{ password
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>( export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
async (statePartArg, dataArg) => { async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), activeView: dataArg.view }; const normalizedView = dataArg.view.toLowerCase().replace(/\s+/g, '-');
return { ...statePartArg.getState(), activeView: normalizedView };
}, },
); );
@@ -1055,6 +1070,68 @@ async function disconnectSocket() {
} }
} }
// ============================================================================
// App Store Actions
// ============================================================================
export const fetchAppTemplatesAction = appStoreStatePart.createAction(
async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAppTemplates
>('/typedrequest', 'getAppTemplates');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), apps: response.apps };
} catch (err) {
console.error('Failed to fetch app templates:', err);
return statePartArg.getState();
}
},
);
export const fetchUpgradeableServicesAction = appStoreStatePart.createAction(
async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetUpgradeableServices
>('/typedrequest', 'getUpgradeableServices');
const response = await typedRequest.fire({ identity: context.identity! });
return { ...statePartArg.getState(), upgradeableServices: response.services };
} catch (err) {
console.error('Failed to fetch upgradeable services:', err);
return statePartArg.getState();
}
},
);
export const upgradeServiceAction = appStoreStatePart.createAction<{
serviceName: string;
targetVersion: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpgradeService
>('/typedrequest', 'upgradeService');
await typedRequest.fire({
identity: context.identity!,
serviceName: dataArg.serviceName,
targetVersion: dataArg.targetVersion,
});
// Re-fetch upgradeable services and services list
const upgradeReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetUpgradeableServices
>('/typedrequest', 'getUpgradeableServices');
const upgradeResp = await upgradeReq.fire({ identity: context.identity! });
return { ...statePartArg.getState(), upgradeableServices: upgradeResp.services };
} catch (err) {
console.error('Failed to upgrade service:', err);
return statePartArg.getState();
}
});
// Connect socket when logged in, disconnect when logged out // Connect socket when logged in, disconnect when logged out
loginStatePart.select((s) => s).subscribe((loginState) => { loginStatePart.select((s) => s).subscribe((loginState) => {
if (loginState.isLoggedIn) { if (loginState.isLoggedIn) {

View File

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

View File

@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as interfaces from '../../ts_interfaces/index.js'; import * as interfaces from '../../ts_interfaces/index.js';
import { appRouter } from '../router.js';
import { import {
DeesElement, DeesElement,
customElement, customElement,
@@ -12,213 +13,600 @@ import {
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
// App template definitions — curated Docker apps
const appTemplates = [
{
id: 'nginx',
name: 'Nginx',
description: 'High-performance web server and reverse proxy. Lightweight, fast, and battle-tested.',
category: 'Web Server',
iconName: 'globe',
image: 'nginx:alpine',
port: 80,
},
{
id: 'wordpress',
name: 'WordPress',
description: 'The world\'s most popular content management system. Powers over 40% of the web.',
category: 'CMS',
iconName: 'file-text',
image: 'wordpress:latest',
port: 80,
enableMongoDB: false,
envVars: [
{ key: 'WORDPRESS_DB_HOST', value: '', description: 'Database host', required: true },
{ key: 'WORDPRESS_DB_USER', value: 'wordpress', description: 'Database user' },
{ key: 'WORDPRESS_DB_PASSWORD', value: '', description: 'Database password', required: true },
{ key: 'WORDPRESS_DB_NAME', value: 'wordpress', description: 'Database name' },
],
},
{
id: 'ghost',
name: 'Ghost',
description: 'Modern publishing platform for creating professional blogs and newsletters.',
category: 'CMS',
iconName: 'book-open',
image: 'ghost:latest',
port: 2368,
envVars: [
{ key: 'database__client', value: 'sqlite3', description: 'Database client (sqlite3 for standalone)' },
{ key: 'database__connection__filename', value: '/var/lib/ghost/content/data/ghost.db', description: 'SQLite database path' },
{ key: 'url', value: 'http://localhost:2368', description: 'Public URL of the blog' },
],
},
{
id: 'gitea',
name: 'Gitea',
description: 'Lightweight self-hosted Git service. Easy to install and maintain.',
category: 'Dev Tools',
iconName: 'git-branch',
image: 'gitea/gitea:latest',
port: 3000,
},
{
id: 'nextcloud',
name: 'Nextcloud',
description: 'Self-hosted file sync and share platform. Your own private cloud.',
category: 'Storage',
iconName: 'package',
image: 'nextcloud:latest',
port: 80,
},
{
id: 'grafana',
name: 'Grafana',
description: 'Open-source observability platform for metrics, logs, and traces visualization.',
category: 'Monitoring',
iconName: 'monitor',
image: 'grafana/grafana:latest',
port: 3000,
envVars: [
{ key: 'GF_SECURITY_ADMIN_PASSWORD', value: 'admin', description: 'Admin password' },
],
},
{
id: 'uptime-kuma',
name: 'Uptime Kuma',
description: 'Self-hosted monitoring tool. Beautiful UI for tracking uptime of services.',
category: 'Monitoring',
iconName: 'monitor',
image: 'louislam/uptime-kuma:latest',
port: 3001,
},
{
id: 'plausible',
name: 'Plausible Analytics',
description: 'Privacy-friendly web analytics. No cookies, GDPR compliant by design.',
category: 'Analytics',
iconName: 'monitor',
image: 'plausible/analytics:latest',
port: 8000,
enableClickHouse: true,
},
{
id: 'vaultwarden',
name: 'Vaultwarden',
description: 'Lightweight Bitwarden-compatible password manager server.',
category: 'Security',
iconName: 'shield',
image: 'vaultwarden/server:latest',
port: 80,
},
{
id: 'n8n',
name: 'N8N',
description: 'Workflow automation tool. Connect anything to everything with a visual editor.',
category: 'Automation',
iconName: 'server',
image: 'n8nio/n8n:latest',
port: 5678,
},
{
id: 'mattermost',
name: 'Mattermost',
description: 'Open-source Slack alternative for team communication and collaboration.',
category: 'Communication',
iconName: 'mail',
image: 'mattermost/mattermost-team-edition:latest',
port: 8065,
},
{
id: 'portainer',
name: 'Portainer',
description: 'Docker management UI. Monitor and manage containers from a web interface.',
category: 'Dev Tools',
iconName: 'package',
image: 'portainer/portainer-ce:latest',
port: 9000,
},
{
id: 'redis',
name: 'Redis',
description: 'In-memory data store used as database, cache, and message broker.',
category: 'Database',
iconName: 'database',
image: 'redis:alpine',
port: 6379,
},
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Advanced open-source relational database. Reliable and feature-rich.',
category: 'Database',
iconName: 'database',
image: 'postgres:16-alpine',
port: 5432,
envVars: [
{ key: 'POSTGRES_PASSWORD', value: '', description: 'Superuser password', required: true },
{ key: 'POSTGRES_USER', value: 'postgres', description: 'Superuser name' },
{ key: 'POSTGRES_DB', value: 'postgres', description: 'Default database name' },
],
},
{
id: 'mariadb',
name: 'MariaDB',
description: 'Community-developed fork of MySQL. Drop-in replacement with enhanced features.',
category: 'Database',
iconName: 'database',
image: 'mariadb:latest',
port: 3306,
envVars: [
{ key: 'MARIADB_ROOT_PASSWORD', value: '', description: 'Root password', required: true },
],
},
{
id: 'adminer',
name: 'Adminer',
description: 'Database management tool in a single PHP file. Supports MySQL, PostgreSQL, SQLite.',
category: 'Dev Tools',
iconName: 'database',
image: 'adminer:latest',
port: 8080,
},
];
@customElement('ob-view-appstore') @customElement('ob-view-appstore')
export class ObViewAppStore extends DeesElement { export class ObViewAppStore extends DeesElement {
@state()
accessor appStoreState: appstate.IAppStoreState = {
apps: [],
upgradeableServices: [],
};
@state()
accessor currentView: 'grid' | 'detail' = 'grid';
@state()
accessor selectedApp: interfaces.requests.ICatalogApp | null = null;
@state()
accessor selectedAppMeta: interfaces.requests.IAppMeta | null = null;
@state()
accessor selectedAppConfig: interfaces.requests.IAppVersionConfig | null = null;
@state()
accessor selectedVersion: string = '';
@state()
accessor editableEnvVars: Array<{ key: string; value: string; description: string; required?: boolean; platformInjected?: boolean }> = [];
@state()
accessor serviceName: string = '';
@state()
accessor loading: boolean = false;
@state()
accessor deployMode: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css``, css`
.detail-card {
background: var(--ci-shade-1, #09090b);
border: 1px solid var(--ci-shade-2, #27272a);
border-radius: 8px;
padding: 24px;
margin-bottom: 16px;
}
.detail-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.detail-icon {
width: 64px;
height: 64px;
border-radius: 12px;
background: var(--ci-shade-2, #27272a);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
color: var(--ci-shade-5, #a1a1aa);
flex-shrink: 0;
}
.detail-title {
font-size: 24px;
font-weight: 700;
color: var(--ci-shade-7, #e4e4e7);
margin: 0 0 4px 0;
}
.detail-category {
display: inline-block;
padding: 2px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
background: var(--ci-shade-2, #27272a);
color: var(--ci-shade-5, #a1a1aa);
margin-bottom: 8px;
}
.detail-description {
font-size: 14px;
color: var(--ci-shade-5, #a1a1aa);
line-height: 1.6;
margin: 0;
}
.detail-meta {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 13px;
color: var(--ci-shade-4, #71717a);
}
.detail-meta a {
color: var(--ci-shade-5, #a1a1aa);
text-decoration: none;
}
.detail-meta a:hover {
text-decoration: underline;
}
.section-label {
font-size: 13px;
font-weight: 600;
color: var(--ci-shade-5, #a1a1aa);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
margin-right: 6px;
margin-bottom: 6px;
}
.version-row {
display: flex;
align-items: center;
gap: 16px;
}
.version-select {
background: var(--ci-shade-2, #27272a);
border: 1px solid var(--ci-shade-3, #3f3f46);
border-radius: 6px;
padding: 8px 12px;
color: var(--ci-shade-7, #e4e4e7);
font-size: 14px;
cursor: pointer;
}
.image-tag {
font-family: monospace;
font-size: 13px;
color: var(--ci-shade-5, #a1a1aa);
background: var(--ci-shade-2, #27272a);
padding: 4px 8px;
border-radius: 4px;
}
.env-table {
width: 100%;
border-collapse: collapse;
}
.env-table th {
text-align: left;
font-size: 12px;
font-weight: 500;
color: var(--ci-shade-4, #71717a);
padding: 8px 8px 8px 0;
border-bottom: 1px solid var(--ci-shade-2, #27272a);
}
.env-table td {
padding: 6px 8px 6px 0;
vertical-align: middle;
}
.env-input {
width: 100%;
background: var(--ci-shade-2, #27272a);
border: 1px solid var(--ci-shade-3, #3f3f46);
border-radius: 4px;
padding: 6px 8px;
color: var(--ci-shade-7, #e4e4e7);
font-size: 13px;
font-family: monospace;
box-sizing: border-box;
}
.env-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.env-key {
font-family: monospace;
font-size: 13px;
color: var(--ci-shade-6, #d4d4d8);
white-space: nowrap;
}
.env-desc {
font-size: 12px;
color: var(--ci-shade-4, #71717a);
}
.env-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
}
.env-badge.required {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.env-badge.auto {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.name-input {
background: var(--ci-shade-2, #27272a);
border: 1px solid var(--ci-shade-3, #3f3f46);
border-radius: 6px;
padding: 10px 14px;
color: var(--ci-shade-7, #e4e4e7);
font-size: 14px;
width: 300px;
box-sizing: border-box;
}
.actions-row {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 200ms ease;
}
.btn:hover { opacity: 0.9; }
.btn-primary {
background: var(--ci-shade-7, #e4e4e7);
color: var(--ci-shade-0, #09090b);
}
.btn-secondary {
background: transparent;
border: 1px solid var(--ci-shade-2, #27272a);
color: var(--ci-shade-6, #d4d4d8);
}
.loading-spinner {
padding: 32px;
text-align: center;
color: var(--ci-shade-4, #71717a);
}
`,
]; ];
constructor() {
super();
const sub = appstate.appStoreStatePart
.select((s) => s)
.subscribe((newState) => {
this.appStoreState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
await appstate.appStoreStatePart.dispatchAction(appstate.fetchAppTemplatesAction, null);
} }
public render(): TemplateResult { public render(): TemplateResult {
switch (this.currentView) {
case 'detail':
return this.renderDetailView();
default:
return this.renderGridView();
}
}
private renderGridView(): TemplateResult {
const appTemplates = this.appStoreState.apps.map((app) => ({
id: app.id,
name: app.name,
description: app.description,
category: app.category,
iconName: app.iconName,
iconUrl: app.iconUrl,
image: '',
port: 0,
}));
return html` return html`
<ob-sectionheading>App Store</ob-sectionheading> <ob-sectionheading>App Store</ob-sectionheading>
<sz-app-store-view ${appTemplates.length === 0
.apps=${appTemplates} ? html`<div class="loading-spinner">Loading app templates...</div>`
@deploy-app=${(e: CustomEvent) => this.handleDeployApp(e)} : html`
></sz-app-store-view> <sz-app-store-view
.apps=${appTemplates}
@view-app=${(e: CustomEvent) => this.handleViewDetails(e)}
@deploy-app=${(e: CustomEvent) => this.handleAppClick(e)}
></sz-app-store-view>
`}
`; `;
} }
private handleDeployApp(e: CustomEvent) { private renderDetailView(): TemplateResult {
if (this.loading) {
return html`
<ob-sectionheading>App Store</ob-sectionheading>
<div class="loading-spinner">Loading app details...</div>
`;
}
const app = this.selectedApp;
const meta = this.selectedAppMeta;
const config = this.selectedAppConfig;
if (!app || !config) {
return html`
<ob-sectionheading>App Store</ob-sectionheading>
<div class="loading-spinner">App not found.</div>
`;
}
const platformReqs = config.platformRequirements || {};
const hasPlatformReqs = Object.values(platformReqs).some(Boolean);
const platformLabels: Record<string, string> = {
mongodb: 'MongoDB',
s3: 'S3 (MinIO)',
clickhouse: 'ClickHouse',
redis: 'Redis',
mariadb: 'MariaDB',
};
return html`
<ob-sectionheading>App Store</ob-sectionheading>
<button class="btn btn-secondary" style="margin-bottom: 16px;" @click=${() => { this.currentView = 'grid'; }}>
&larr; Back to App Store
</button>
<!-- Header -->
<div class="detail-card">
<div class="detail-header">
<div class="detail-icon">${(app.name || '?')[0].toUpperCase()}</div>
<div style="flex: 1;">
<h2 class="detail-title">${app.name}</h2>
<span class="detail-category">${app.category}</span>
<p class="detail-description">${app.description}</p>
<div class="detail-meta">
${meta?.maintainer ? html`<span>Maintainer: <strong>${meta.maintainer}</strong></span>` : ''}
${meta?.links ? Object.entries(meta.links).map(([label, url]) =>
html`<a href="${url}" target="_blank" rel="noopener">${label}</a>`
) : ''}
${app.tags?.length ? html`<span>Tags: ${app.tags.join(', ')}</span>` : ''}
</div>
</div>
</div>
</div>
<!-- Platform Services -->
${hasPlatformReqs ? html`
<div class="detail-card">
<div class="section-label">Platform Services</div>
<div>
${Object.entries(platformReqs)
.filter(([_, enabled]) => enabled)
.map(([key]) => html`<span class="badge">${platformLabels[key] || key}</span>`)}
</div>
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 8px;">
These platform services will be automatically provisioned when you deploy.
</div>
</div>
` : ''}
<!-- Version & Image -->
<div class="detail-card">
<div class="section-label">Version</div>
<div class="version-row">
<select class="version-select" @change=${(e: Event) => this.handleVersionChange((e.target as HTMLSelectElement).value)}>
${(meta?.versions || [this.selectedVersion]).map((v) =>
html`<option value="${v}" ?selected=${v === this.selectedVersion}>${v}${v === app.latestVersion ? ' (latest)' : ''}</option>`
)}
</select>
<span class="image-tag">${config.image}</span>
${config.minOneboxVersion ? html`<span style="font-size: 12px; color: var(--ci-shade-4, #71717a);">Requires onebox &ge; ${config.minOneboxVersion}</span>` : ''}
</div>
</div>
<!-- Environment Variables -->
${this.editableEnvVars.length > 0 ? html`
<div class="detail-card">
<div class="section-label">Environment Variables</div>
<table class="env-table">
<thead>
<tr>
<th style="width: 30%;">Variable</th>
<th style="width: 40%;">Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${this.editableEnvVars.map((ev, index) => html`
<tr>
<td>
<span class="env-key">${ev.key}</span>
${ev.required ? html`<span class="env-badge required">required</span>` : ''}
${ev.platformInjected ? html`<span class="env-badge auto">auto</span>` : ''}
</td>
<td>
<input
class="env-input"
type="text"
.value=${ev.value}
?disabled=${ev.platformInjected || !this.deployMode}
placeholder=${ev.platformInjected ? 'Auto-injected by platform' : 'Enter value...'}
@input=${(e: Event) => this.handleEnvVarChange(index, (e.target as HTMLInputElement).value)}
/>
</td>
<td><span class="env-desc">${ev.description || ''}</span></td>
</tr>
`)}
</tbody>
</table>
</div>
` : ''}
<!-- Deploy section (only in deploy mode) or action button (view mode) -->
${this.deployMode ? html`
<div class="detail-card">
<div class="section-label">Service Name</div>
<input
class="name-input"
type="text"
.value=${this.serviceName}
placeholder="e.g. my-ghost-blog"
@input=${(e: Event) => { this.serviceName = (e.target as HTMLInputElement).value; }}
/>
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 6px;">
Lowercase letters, numbers, and hyphens only.
</div>
<div class="actions-row">
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>Cancel</button>
<button class="btn btn-primary" @click=${() => this.handleDeploy()}>
Deploy v${this.selectedVersion}
</button>
</div>
</div>
` : html`
<div class="actions-row" style="margin-top: 8px;">
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>
&larr; Back
</button>
<button class="btn btn-primary" @click=${() => { this.deployMode = true; }}>
Deploy this App
</button>
</div>
`}
`;
}
private async handleViewDetails(e: CustomEvent) {
const app = e.detail?.app; const app = e.detail?.app;
if (!app) return; if (!app) return;
// Store the template and navigate on next microtask to avoid const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id);
// destroying the current view while the event handler is still on the call stack if (!catalogApp) return;
setTimeout(() => {
// Set both pendingAppTemplate and activeView atomically this.deployMode = false;
appstate.uiStatePart.setState({ this.selectedApp = catalogApp;
...appstate.uiStatePart.getState(), this.selectedVersion = catalogApp.latestVersion;
pendingAppTemplate: app, this.serviceName = catalogApp.id;
activeView: 'services', this.loading = true;
this.currentView = 'detail';
await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion);
this.loading = false;
}
private async handleAppClick(e: CustomEvent) {
const app = e.detail?.app;
if (!app) return;
const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id);
if (!catalogApp) return;
this.deployMode = true;
this.selectedApp = catalogApp;
this.selectedVersion = catalogApp.latestVersion;
this.serviceName = catalogApp.id;
this.loading = true;
this.currentView = 'detail';
await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion);
this.loading = false;
}
private async handleVersionChange(version: string) {
if (!this.selectedApp || version === this.selectedVersion) return;
this.selectedVersion = version;
this.loading = true;
await this.fetchVersionConfig(this.selectedApp.id, version);
this.loading = false;
}
private async fetchVersionConfig(appId: string, version: string) {
try {
const identity = appstate.loginStatePart.getState().identity;
if (!identity) return;
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAppConfig
>('/typedrequest', 'getAppConfig');
const response = await typedRequest.fire({ identity, appId, version });
this.selectedAppMeta = response.appMeta;
this.selectedAppConfig = response.config;
// Build editable env vars
this.editableEnvVars = (response.config.envVars || []).map((ev) => ({
key: ev.key,
value: ev.value || '',
description: ev.description || '',
required: ev.required,
platformInjected: ev.value?.includes('${') || false,
}));
} catch (err) {
console.error('Failed to fetch app config:', err);
}
}
private handleEnvVarChange(index: number, value: string) {
const updated = [...this.editableEnvVars];
updated[index] = { ...updated[index], value };
this.editableEnvVars = updated;
}
private async handleDeploy() {
const app = this.selectedApp;
const config = this.selectedAppConfig;
if (!app || !config) return;
const envVars: Record<string, string> = {};
for (const ev of this.editableEnvVars) {
if (ev.key && ev.value && !ev.platformInjected) {
envVars[ev.key] = ev.value;
}
}
const platformReqs = config.platformRequirements || {};
const serviceConfig: interfaces.data.IServiceCreate = {
name: this.serviceName || app.id,
image: config.image,
port: config.port || 80,
envVars,
enableMongoDB: platformReqs.mongodb || false,
enableS3: platformReqs.s3 || false,
enableClickHouse: platformReqs.clickhouse || false,
enableRedis: platformReqs.redis || false,
enableMariaDB: platformReqs.mariadb || false,
appTemplateId: app.id,
appTemplateVersion: this.selectedVersion,
};
try {
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
config: serviceConfig,
}); });
}, 0); setTimeout(() => {
appRouter.navigateToView('services');
}, 0);
} catch (err) {
console.error('Failed to deploy from App Store:', err);
}
} }
} }

View File

@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import { import {
DeesElement, DeesElement,
customElement, customElement,
@@ -114,11 +115,13 @@ export class ObViewDashboard extends DeesElement {
networkOut: status?.docker?.networkOut || 0, networkOut: status?.docker?.networkOut || 0,
topConsumers: [], topConsumers: [],
}, },
platformServices: platformServices.map((ps) => ({ platformServices: platformServices
name: ps.displayName, .filter((ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore)
status: ps.status === 'running' ? 'running' : 'stopped', .map((ps) => ({
running: ps.status === 'running', name: ps.displayName,
})), status: ps.status === 'running' ? 'Running' : ps.status === 'starting' ? 'Starting...' : ps.status === 'stopping' ? 'Stopping...' : 'Stopped',
running: ps.status === 'running',
})),
traffic: { traffic: {
requests: 0, requests: 0,
errors: 0, errors: 0,
@@ -159,9 +162,9 @@ export class ObViewDashboard extends DeesElement {
private handleQuickAction(e: CustomEvent) { private handleQuickAction(e: CustomEvent) {
const action = e.detail?.action || e.detail?.label; const action = e.detail?.action || e.detail?.label;
if (action === 'Deploy Service') { if (action === 'Deploy Service') {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' }); appRouter.navigateToView('services');
} else if (action === 'Add Domain') { } else if (action === 'Add Domain') {
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'network' }); appRouter.navigateToView('network');
} }
} }
@@ -178,7 +181,7 @@ export class ObViewDashboard extends DeesElement {
...appstate.servicesStatePart.getState(), ...appstate.servicesStatePart.getState(),
currentPlatformService: ps, currentPlatformService: ps,
}); });
appstate.uiStatePart.dispatchAction(appstate.setActiveViewAction, { view: 'services' }); appRouter.navigateToView('services');
} }
} }
} }

View File

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

View File

@@ -142,6 +142,12 @@ export class ObViewServices extends DeesElement {
@state() @state()
accessor pendingTemplate: any = null; accessor pendingTemplate: any = null;
@state()
accessor appStoreState: appstate.IAppStoreState = {
apps: [],
upgradeableServices: [],
};
constructor() { constructor() {
super(); super();
@@ -159,7 +165,12 @@ export class ObViewServices extends DeesElement {
}); });
this.rxSubscriptions.push(backupsSub); this.rxSubscriptions.push(backupsSub);
// No subscription needed — pendingAppTemplate is checked in render() const appStoreSub = appstate.appStoreStatePart
.select((s) => s)
.subscribe((newState) => {
this.appStoreState = newState;
});
this.rxSubscriptions.push(appStoreSub);
} }
public static styles = [ public static styles = [
@@ -215,6 +226,7 @@ export class ObViewServices extends DeesElement {
await Promise.all([ await Promise.all([
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null), appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null), appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableServicesAction, null),
]); ]);
// If a platform service was selected from the dashboard, navigate to its detail // If a platform service was selected from the dashboard, navigate to its detail
@@ -230,20 +242,6 @@ export class ObViewServices extends DeesElement {
} }
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
// Check for pending app template from the App Store after each update
const uiState = appstate.uiStatePart.getState();
if (uiState.pendingAppTemplate && !this.pendingTemplate) {
this.pendingTemplate = uiState.pendingAppTemplate;
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
pendingAppTemplate: undefined,
});
this.currentView = 'create';
}
}
public render(): TemplateResult { public render(): TemplateResult {
switch (this.currentView) { switch (this.currentView) {
case 'create': case 'create':
@@ -277,7 +275,14 @@ export class ObViewServices extends DeesElement {
default: return status; default: return status;
} }
}; };
const mappedPlatformServices = this.servicesState.platformServices.map((ps) => ({ // Split platform services into active (running or core) and inactive (not in use)
const activePlatformServices = this.servicesState.platformServices.filter(
(ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore,
);
const inactivePlatformServices = this.servicesState.platformServices.filter(
(ps) => !ps.isCore && (ps.status === 'not-deployed' || ps.status === 'stopped' || ps.status === 'failed'),
);
const mappedActivePlatformServices = activePlatformServices.map((ps) => ({
name: ps.displayName, name: ps.displayName,
status: displayStatus(ps.status), status: displayStatus(ps.status),
running: ps.status === 'running', running: ps.status === 'running',
@@ -313,17 +318,45 @@ export class ObViewServices extends DeesElement {
></sz-services-list-view> ></sz-services-list-view>
<ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading> <ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading>
<div style="max-width: 500px;"> <div style="max-width: 500px;">
<sz-platform-services-card ${mappedActivePlatformServices.length > 0 ? html`
.services=${mappedPlatformServices} <sz-platform-services-card
@service-click=${(e: CustomEvent) => { .services=${mappedActivePlatformServices}
const type = e.detail.type || this.servicesState.platformServices.find( @service-click=${(e: CustomEvent) => {
(ps) => ps.displayName === e.detail.name, const type = e.detail.type || this.servicesState.platformServices.find(
)?.type; (ps) => ps.displayName === e.detail.name,
if (type) { )?.type;
this.navigateToPlatformDetail(type); if (type) {
} this.navigateToPlatformDetail(type);
}} }
></sz-platform-services-card> }}
></sz-platform-services-card>
` : ''}
${inactivePlatformServices.length > 0 ? html`
<div style="
background: var(--ci-shade-1, #09090b);
border: 1px solid var(--ci-shade-2, #27272a);
border-radius: 8px;
padding: 20px;
margin-top: ${mappedActivePlatformServices.length > 0 ? '12px' : '0'};
opacity: 0.5;
">
<div style="font-size: 13px; color: var(--ci-shade-4, #71717a); margin-bottom: 12px;">Available — not in use</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
${inactivePlatformServices.map((ps) => html`
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; cursor: pointer; transition: opacity 200ms ease;"
@click=${() => this.navigateToPlatformDetail(ps.type)}
>
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 8px; height: 8px; border-radius: 50%; background: var(--ci-shade-3, #3f3f46); flex-shrink: 0;"></div>
<span style="font-size: 14px; font-weight: 500; color: var(--ci-shade-4, #71717a);">${ps.displayName}</span>
</div>
<span style="font-size: 13px; color: var(--ci-shade-3, #3f3f46);">${displayStatus(ps.status)}</span>
</div>
`)}
</div>
</div>
` : ''}
</div> </div>
`; `;
} }
@@ -344,6 +377,8 @@ export class ObViewServices extends DeesElement {
enableMongoDB: template.enableMongoDB || false, enableMongoDB: template.enableMongoDB || false,
enableS3: template.enableS3 || false, enableS3: template.enableS3 || false,
enableClickHouse: template.enableClickHouse || false, enableClickHouse: template.enableClickHouse || false,
enableRedis: template.enableRedis || false,
enableMariaDB: template.enableMariaDB || false,
}; };
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, { await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
config: serviceConfig, config: serviceConfig,
@@ -368,12 +403,14 @@ export class ObViewServices extends DeesElement {
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Service Name:</span> <strong>${t.id}</strong></div> <div><span style="color: var(--ci-shade-5, #a1a1aa);">Service Name:</span> <strong>${t.id}</strong></div>
<div><span style="color: var(--ci-shade-5, #a1a1aa);">Category:</span> <strong>${t.category}</strong></div> <div><span style="color: var(--ci-shade-5, #a1a1aa);">Category:</span> <strong>${t.category}</strong></div>
</div> </div>
${t.enableMongoDB || t.enableS3 || t.enableClickHouse ? html` ${t.enableMongoDB || t.enableS3 || t.enableClickHouse || t.enableRedis || t.enableMariaDB ? html`
<div style="margin-top: 12px; font-size: 13px; color: var(--ci-shade-5, #a1a1aa);"> <div style="margin-top: 12px; font-size: 13px; color: var(--ci-shade-5, #a1a1aa);">
Platform Services: Platform Services:
${t.enableMongoDB ? html`<span style="margin-right: 8px;">MongoDB</span>` : ''} ${t.enableMongoDB ? html`<span style="margin-right: 8px;">MongoDB</span>` : ''}
${t.enableS3 ? html`<span style="margin-right: 8px;">S3</span>` : ''} ${t.enableS3 ? html`<span style="margin-right: 8px;">S3</span>` : ''}
${t.enableClickHouse ? html`<span>ClickHouse</span>` : ''} ${t.enableClickHouse ? html`<span style="margin-right: 8px;">ClickHouse</span>` : ''}
${t.enableRedis ? html`<span style="margin-right: 8px;">Redis</span>` : ''}
${t.enableMariaDB ? html`<span style="margin-right: 8px;">MariaDB</span>` : ''}
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -407,6 +444,8 @@ export class ObViewServices extends DeesElement {
enableMongoDB: formConfig.enableMongoDB || false, enableMongoDB: formConfig.enableMongoDB || false,
enableS3: formConfig.enableS3 || false, enableS3: formConfig.enableS3 || false,
enableClickHouse: formConfig.enableClickHouse || false, enableClickHouse: formConfig.enableClickHouse || false,
enableRedis: formConfig.enableRedis || false,
enableMariaDB: formConfig.enableMariaDB || false,
}; };
await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, { await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, {
config: serviceConfig, config: serviceConfig,
@@ -428,8 +467,49 @@ export class ObViewServices extends DeesElement {
: defaultStats; : defaultStats;
const transformedLogs = parseLogs(this.servicesState.currentServiceLogs); const transformedLogs = parseLogs(this.servicesState.currentServiceLogs);
// Check if this service has an available upgrade
const upgradeInfo = service
? this.appStoreState.upgradeableServices.find((u) => u.serviceName === service.name)
: null;
return html` return html`
<ob-sectionheading>Service Details</ob-sectionheading> <ob-sectionheading>Service Details</ob-sectionheading>
${upgradeInfo ? html`
<div style="
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
">
<div>
<div style="font-size: 14px; font-weight: 600; color: var(--ci-shade-7, #e4e4e7);">
Update available: v${upgradeInfo.currentVersion} &rarr; v${upgradeInfo.latestVersion}
</div>
<div style="font-size: 12px; color: var(--ci-shade-4, #71717a); margin-top: 4px;">
${upgradeInfo.hasMigration ? 'Migration script available' : 'Config-only upgrade'}
</div>
</div>
<button
class="deploy-button"
style="padding: 8px 16px; font-size: 13px;"
@click=${async () => {
await appstate.appStoreStatePart.dispatchAction(appstate.upgradeServiceAction, {
serviceName: upgradeInfo.serviceName,
targetVersion: upgradeInfo.latestVersion,
});
// Refresh service data
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceAction, {
name: upgradeInfo.serviceName,
});
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null);
}}
>Upgrade</button>
</div>
` : ''}
<sz-service-detail-view <sz-service-detail-view
.service=${transformedService} .service=${transformedService}
.logs=${transformedLogs} .logs=${transformedLogs}
@@ -530,6 +610,8 @@ export class ObViewServices extends DeesElement {
minio: { host: 'onebox-minio', port: 9000, version: 'latest', config: { consolePort: 9001, region: 'us-east-1' } }, minio: { host: 'onebox-minio', port: 9000, version: 'latest', config: { consolePort: 9001, region: 'us-east-1' } },
clickhouse: { host: 'onebox-clickhouse', port: 8123, version: 'latest', config: { nativePort: 9000, httpPort: 8123 } }, clickhouse: { host: 'onebox-clickhouse', port: 8123, version: 'latest', config: { nativePort: 9000, httpPort: 8123 } },
caddy: { host: 'onebox-caddy', port: 80, version: '2-alpine', config: { httpsPort: 443, adminApi: 2019 } }, caddy: { host: 'onebox-caddy', port: 80, version: '2-alpine', config: { httpsPort: 443, adminApi: 2019 } },
mariadb: { host: 'onebox-mariadb', port: 3306, version: '11', config: { engine: 'InnoDB', authEnabled: true } },
redis: { host: 'onebox-redis', port: 6379, version: '7-alpine', config: { appendonly: true, maxDatabases: 16 } },
}; };
const info = platformService const info = platformService
? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} } ? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} }

View File

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

110
ts_web/router.ts Normal file
View File

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