import * as plugins from '../plugins.js'; import * as shared from './shared/index.js'; import * as appstate from '../appstate.js'; import * as interfaces from '../../ts_interfaces/index.js'; import { DeesElement, customElement, html, state, css, cssManager, type TemplateResult, } from '@design.estate/dees-element'; // ============================================================================ // Data transformation helpers // Maps backend data shapes to @serve.zone/catalog component interfaces // ============================================================================ function formatBytes(bytes: number): string { if (!bytes || bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const k = 1024; const i = Math.floor(Math.log(bytes) / Math.log(k)); const value = bytes / Math.pow(k, i); return `${value.toFixed(1)} ${units[i]}`; } function parseImageString(image: string): { repository: string; tag: string } { const lastColon = image.lastIndexOf(':'); const lastSlash = image.lastIndexOf('/'); if (lastColon > lastSlash && lastColon > 0) { return { repository: image.substring(0, lastColon), tag: image.substring(lastColon + 1), }; } return { repository: image, tag: 'latest' }; } function mapStatus(status: string): 'running' | 'stopped' | 'starting' | 'error' { switch (status) { case 'running': return 'running'; case 'starting': return 'starting'; case 'failed': return 'error'; case 'stopped': case 'stopping': default: return 'stopped'; } } function toServiceDetail(service: interfaces.data.IService) { const parsed = parseImageString(service.image); return { name: service.name, status: mapStatus(service.status), image: service.image, port: service.port, domain: service.domain || null, containerId: service.containerID || '', created: service.createdAt ? new Date(service.createdAt).toLocaleString() : '-', updated: service.updatedAt ? new Date(service.updatedAt).toLocaleString() : '-', registry: service.useOneboxRegistry ? 'Onebox Registry' : (service.registry || 'Docker Hub'), repository: service.registryRepository || parsed.repository, tag: service.registryImageTag || parsed.tag, }; } function toServiceStats(stats: interfaces.data.IContainerStats) { return { cpu: stats.cpuPercent, memory: formatBytes(stats.memoryUsed), memoryLimit: formatBytes(stats.memoryLimit), networkIn: formatBytes(stats.networkRx), networkOut: formatBytes(stats.networkTx), }; } function parseLogs(logs: any): Array<{ timestamp: string; message: string }> { if (Array.isArray(logs)) { return logs.map((entry: any) => ({ timestamp: entry.timestamp ? String(entry.timestamp) : '', message: entry.message || String(entry), })); } if (typeof logs === 'string' && logs.trim()) { return logs.split('\n').filter((line: string) => line.trim()).map((line: string) => { const match = line.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/); if (match) { return { timestamp: match[1], message: match[2] }; } return { timestamp: '', message: line }; }); } return []; } const defaultStats = { cpu: 0, memory: '0 B', memoryLimit: '0 B', networkIn: '0 B', networkOut: '0 B' }; @customElement('ob-view-services') export class ObViewServices extends DeesElement { @state() accessor servicesState: appstate.IServicesState = { services: [], currentService: null, currentServiceLogs: [], currentServiceStats: null, platformServices: [], currentPlatformService: null, currentPlatformServiceStats: null, currentPlatformServiceLogs: [], }; @state() accessor backupsState: appstate.IBackupsState = { backups: [], schedules: [], }; @state() accessor currentView: 'list' | 'create' | 'detail' | 'backups' | 'platform-detail' = 'list'; @state() accessor selectedServiceName: string = ''; @state() accessor selectedPlatformType: string = ''; constructor() { super(); const servicesSub = appstate.servicesStatePart .select((s) => s) .subscribe((newState) => { this.servicesState = newState; }); this.rxSubscriptions.push(servicesSub); const backupsSub = appstate.backupsStatePart .select((s) => s) .subscribe((newState) => { this.backupsState = newState; }); this.rxSubscriptions.push(backupsSub); } public static styles = [ cssManager.defaultStyles, shared.viewHostCss, css` .page-actions { display: flex; justify-content: flex-end; margin-bottom: 16px; } .deploy-button { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; background: ${cssManager.bdTheme('#18181b', '#fafafa')}; color: ${cssManager.bdTheme('#fafafa', '#18181b')}; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 200ms ease; } .deploy-button:hover { opacity: 0.9; } .deploy-button svg { width: 16px; height: 16px; } `, ]; async connectedCallback() { super.connectedCallback(); await Promise.all([ appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null), appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null), ]); // If a platform service was selected from the dashboard, navigate to its detail const state = appstate.servicesStatePart.getState(); if (state.currentPlatformService) { const type = state.currentPlatformService.type; // Clear the selection so it doesn't persist on next visit appstate.servicesStatePart.setState({ ...appstate.servicesStatePart.getState(), currentPlatformService: null, }); this.navigateToPlatformDetail(type); } } public render(): TemplateResult { switch (this.currentView) { case 'create': return this.renderCreateView(); case 'detail': return this.renderDetailView(); case 'backups': return this.renderBackupsView(); case 'platform-detail': return this.renderPlatformDetailView(); default: return this.renderListView(); } } private renderListView(): TemplateResult { const mappedServices = this.servicesState.services.map((s) => ({ name: s.name, image: s.image, domain: s.domain || null, status: mapStatus(s.status), })); const displayStatus = (status: string) => { switch (status) { case 'running': return 'Running'; case 'stopped': return 'Stopped'; case 'starting': return 'Starting...'; case 'stopping': return 'Stopping...'; case 'failed': return 'Failed'; case 'not-deployed': return 'Not Deployed'; default: return status; } }; const mappedPlatformServices = this.servicesState.platformServices.map((ps) => ({ name: ps.displayName, status: displayStatus(ps.status), running: ps.status === 'running', type: ps.type, })); return html` Services
{ this.selectedServiceName = e.detail.name || e.detail.service?.name; appstate.servicesStatePart.dispatchAction(appstate.fetchServiceAction, { name: this.selectedServiceName, }); appstate.servicesStatePart.dispatchAction(appstate.fetchServiceLogsAction, { name: this.selectedServiceName, }); appstate.servicesStatePart.dispatchAction(appstate.fetchServiceStatsAction, { name: this.selectedServiceName, }); this.currentView = 'detail'; }} @service-action=${(e: CustomEvent) => this.handleServiceAction(e)} > Platform Services
{ const type = e.detail.type || this.servicesState.platformServices.find( (ps) => ps.displayName === e.detail.name, )?.type; if (type) { this.navigateToPlatformDetail(type); } }} >
`; } private renderCreateView(): TemplateResult { return html` Create Service { const formConfig = e.detail; const serviceConfig: interfaces.data.IServiceCreate = { name: formConfig.name, image: formConfig.image, port: formConfig.ports?.[0]?.containerPort ? parseInt(formConfig.ports[0].containerPort, 10) : 80, envVars: formConfig.envVars?.reduce( (acc: Record, ev: { key: string; value: string }) => { if (ev.key) acc[ev.key] = ev.value; return acc; }, {} as Record, ), enableMongoDB: formConfig.enableMongoDB || false, enableS3: formConfig.enableS3 || false, enableClickHouse: formConfig.enableClickHouse || false, }; await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, { config: serviceConfig, }); this.currentView = 'list'; }} @cancel=${() => { this.currentView = 'list'; }} > `; } private renderDetailView(): TemplateResult { const service = this.servicesState.currentService; const transformedService = service ? toServiceDetail(service) : null; const transformedStats = this.servicesState.currentServiceStats ? toServiceStats(this.servicesState.currentServiceStats) : defaultStats; const transformedLogs = parseLogs(this.servicesState.currentServiceLogs); return html` Service Details { this.currentView = 'list'; }} @service-action=${(e: CustomEvent) => this.handleServiceAction(e)} > `; } private renderBackupsView(): TemplateResult { return html` Backups { appstate.backupsStatePart.dispatchAction(appstate.createScheduleAction, { config: e.detail, }); }} @run-now=${(e: CustomEvent) => { appstate.backupsStatePart.dispatchAction(appstate.triggerScheduleAction, { scheduleId: e.detail.scheduleId, }); }} @delete-backup=${(e: CustomEvent) => { appstate.backupsStatePart.dispatchAction(appstate.deleteBackupAction, { backupId: e.detail.backupId, }); }} > `; } private navigateToPlatformDetail(type: string): void { // Reset to list first to force fresh DOM for dees-chart-log this.currentView = 'list'; this.selectedPlatformType = type; // Clear previous stats/logs before fetching new ones appstate.servicesStatePart.setState({ ...appstate.servicesStatePart.getState(), currentPlatformServiceStats: null, currentPlatformServiceLogs: [], }); // Fetch stats and logs for this platform service const serviceType = type as interfaces.data.TPlatformServiceType; appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, { serviceType }); appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceLogsAction, { serviceType }); // Switch to detail view on next microtask (ensures fresh DOM) requestAnimationFrame(() => { this.currentView = 'platform-detail'; }); } private renderPlatformDetailView(): TemplateResult { const platformService = this.servicesState.platformServices.find( (ps) => ps.type === this.selectedPlatformType, ); const stats = this.servicesState.currentPlatformServiceStats; const metrics = { cpu: stats ? Math.round(stats.cpuPercent) : 0, memory: stats ? Math.round(stats.memoryPercent) : 0, storage: 0, connections: undefined as number | undefined, }; // Real service info per platform type const serviceInfo: Record }> = { mongodb: { host: 'onebox-mongodb', port: 27017, version: '4.4', config: { engine: 'WiredTiger', authEnabled: true } }, 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 } }, caddy: { host: 'onebox-caddy', port: 80, version: '2-alpine', config: { httpsPort: 443, adminApi: 2019 } }, }; const info = platformService ? serviceInfo[platformService.type] || { host: 'unknown', port: 0, version: '', config: {} } : { host: '', port: 0, version: '', config: {} }; // Map backend status to catalog-compatible status const mapPlatformStatus = (status: string): 'running' | 'stopped' | 'error' => { switch (status) { case 'running': return 'running'; case 'failed': return 'error'; case 'starting': case 'stopping': case 'stopped': case 'not-deployed': default: return 'stopped'; } }; return html` Platform Service
({ timestamp: new Date(log.timestamp).toISOString(), level: log.level, message: log.message, }))} @back=${() => { this.currentView = 'list'; }} @start=${async () => { await appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, { serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType, }); // Refresh stats after starting appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, { serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType, }); }} @stop=${async () => { await appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, { serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType, }); }} @restart=${async () => { await appstate.servicesStatePart.dispatchAction(appstate.stopPlatformServiceAction, { serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType, }); await appstate.servicesStatePart.dispatchAction(appstate.startPlatformServiceAction, { serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType, }); appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServiceStatsAction, { serviceType: this.selectedPlatformType as interfaces.data.TPlatformServiceType, }); }} > `; } private async handleServiceAction(e: CustomEvent) { const action = e.detail.action; const name = e.detail.service?.name || e.detail.name || this.selectedServiceName; switch (action) { case 'start': await appstate.servicesStatePart.dispatchAction(appstate.startServiceAction, { name }); break; case 'stop': await appstate.servicesStatePart.dispatchAction(appstate.stopServiceAction, { name }); break; case 'restart': await appstate.servicesStatePart.dispatchAction(appstate.restartServiceAction, { name }); break; case 'delete': await appstate.servicesStatePart.dispatchAction(appstate.deleteServiceAction, { name }); this.currentView = 'list'; break; } } }