Files
onebox/ts_web/elements/ob-view-services.ts

507 lines
18 KiB
TypeScript

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`
<ob-sectionheading>Services</ob-sectionheading>
<div class="page-actions">
<button class="deploy-button" @click=${() => { this.currentView = 'create'; }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Deploy Service
</button>
</div>
<sz-services-list-view
.services=${mappedServices}
@service-click=${(e: CustomEvent) => {
this.selectedServiceName = e.detail.name || e.detail.service?.name;
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceAction, {
name: this.selectedServiceName,
});
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceLogsAction, {
name: this.selectedServiceName,
});
appstate.servicesStatePart.dispatchAction(appstate.fetchServiceStatsAction, {
name: this.selectedServiceName,
});
this.currentView = 'detail';
}}
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
></sz-services-list-view>
<ob-sectionheading style="margin-top: 32px;">Platform Services</ob-sectionheading>
<div style="max-width: 500px;">
<sz-platform-services-card
.services=${mappedPlatformServices}
@service-click=${(e: CustomEvent) => {
const type = e.detail.type || this.servicesState.platformServices.find(
(ps) => ps.displayName === e.detail.name,
)?.type;
if (type) {
this.navigateToPlatformDetail(type);
}
}}
></sz-platform-services-card>
</div>
`;
}
private renderCreateView(): TemplateResult {
return html`
<ob-sectionheading>Create Service</ob-sectionheading>
<sz-service-create-view
.registries=${[]}
@create-service=${async (e: CustomEvent) => {
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<string, string>, ev: { key: string; value: string }) => {
if (ev.key) acc[ev.key] = ev.value;
return acc;
},
{} as Record<string, string>,
),
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';
}}
></sz-service-create-view>
`;
}
private renderDetailView(): TemplateResult {
const service = this.servicesState.currentService;
const transformedService = service ? toServiceDetail(service) : null;
const transformedStats = this.servicesState.currentServiceStats
? toServiceStats(this.servicesState.currentServiceStats)
: defaultStats;
const transformedLogs = parseLogs(this.servicesState.currentServiceLogs);
return html`
<ob-sectionheading>Service Details</ob-sectionheading>
<sz-service-detail-view
.service=${transformedService}
.logs=${transformedLogs}
.stats=${transformedStats}
@back=${() => {
this.currentView = 'list';
}}
@service-action=${(e: CustomEvent) => this.handleServiceAction(e)}
></sz-service-detail-view>
`;
}
private renderBackupsView(): TemplateResult {
return html`
<ob-sectionheading>Backups</ob-sectionheading>
<sz-services-backups-view
.schedules=${this.backupsState.schedules}
.backups=${this.backupsState.backups}
@create-schedule=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.createScheduleAction, {
config: e.detail,
});
}}
@run-now=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.triggerScheduleAction, {
scheduleId: e.detail.scheduleId,
});
}}
@delete-backup=${(e: CustomEvent) => {
appstate.backupsStatePart.dispatchAction(appstate.deleteBackupAction, {
backupId: e.detail.backupId,
});
}}
></sz-services-backups-view>
`;
}
private 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<string, { host: string; port: number; version: string; config: Record<string, any> }> = {
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`
<ob-sectionheading>Platform Service</ob-sectionheading>
<div class="page-actions" style="justify-content: flex-start;">
<button class="deploy-button" style="background: transparent; border: 1px solid var(--ci-shade-2, #27272a); color: inherit;" @click=${() => { this.currentView = 'list'; }}>
&larr; Back to Services
</button>
</div>
<sz-platform-service-detail-view
.service=${platformService
? {
id: platformService.type,
name: platformService.displayName,
type: platformService.type,
status: mapPlatformStatus(platformService.status),
version: info.version,
host: info.host,
port: info.port,
config: info.config,
metrics,
}
: null}
.logs=${this.servicesState.currentPlatformServiceLogs.map((log) => ({
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,
});
}}
></sz-platform-service-detail-view>
`;
}
private async handleServiceAction(e: CustomEvent) {
const action = e.detail.action;
const name = e.detail.service?.name || e.detail.name || this.selectedServiceName;
switch (action) {
case 'start':
await appstate.servicesStatePart.dispatchAction(appstate.startServiceAction, { name });
break;
case 'stop':
await appstate.servicesStatePart.dispatchAction(appstate.stopServiceAction, { name });
break;
case 'restart':
await appstate.servicesStatePart.dispatchAction(appstate.restartServiceAction, { name });
break;
case 'delete':
await appstate.servicesStatePart.dispatchAction(appstate.deleteServiceAction, { name });
this.currentView = 'list';
break;
}
}
}