feat(appstore): add service volumes and published ports

This commit is contained in:
2026-05-24 07:28:18 +00:00
parent e6ebac76b4
commit 5228eeaa23
26 changed files with 1790 additions and 348 deletions
+113 -18
View File
@@ -288,6 +288,34 @@ export class ObViewAppStore extends DeesElement {
text-align: center;
color: var(--ci-shade-4, #71717a);
}
.footprint-list {
display: grid;
gap: 8px;
}
.footprint-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--ci-shade-2, #27272a);
border-radius: 6px;
font-size: 13px;
color: var(--ci-shade-6, #d4d4d8);
}
.footprint-meta {
color: var(--ci-shade-4, #71717a);
font-family: monospace;
}
.exposure-warning {
margin-top: 10px;
color: #fbbf24;
font-size: 12px;
line-height: 1.5;
}
`,
];
@@ -410,6 +438,8 @@ export class ObViewAppStore extends DeesElement {
</div>
` : ''}
${this.renderDeploymentFootprint(config)}
<!-- Version & Image -->
<div class="detail-card">
<div class="section-label">Version</div>
@@ -489,6 +519,8 @@ export class ObViewAppStore extends DeesElement {
Onebox routes this domain to the deployed app. Required when the app uses SERVICE_DOMAIN.
</div>
${this.renderDeployConfirmation(config)}
<div class="actions-row">
<button class="btn btn-secondary" @click=${() => { this.currentView = 'grid'; }}>Cancel</button>
<button class="btn btn-primary" @click=${() => this.handleDeploy()}>
@@ -509,6 +541,73 @@ export class ObViewAppStore extends DeesElement {
`;
}
private renderDeploymentFootprint(config: interfaces.requests.IAppVersionConfig): TemplateResult | '' {
const volumes = this.getConfigVolumes(config);
const publishedPorts = config.publishedPorts || [];
if (volumes.length === 0 && publishedPorts.length === 0) {
return '';
}
return html`
<div class="detail-card">
<div class="section-label">Deployment Footprint</div>
<div class="footprint-list">
${volumes.map((volume) => html`
<div class="footprint-item">
<span>Volume mount</span>
<span class="footprint-meta">
${volume.source || volume.name || 'managed volume'}:${volume.mountPath}${volume.readOnly ? ':ro' : ''}
</span>
</div>
`)}
${publishedPorts.map((port) => html`
<div class="footprint-item">
<span>Published host port</span>
<span class="footprint-meta">${this.formatPublishedPort(port)}</span>
</div>
`)}
</div>
${publishedPorts.length > 0 ? html`
<div class="exposure-warning">
This app publishes raw host ports outside the HTTP proxy. Confirm firewall and network policy before deploying.
</div>
` : ''}
</div>
`;
}
private renderDeployConfirmation(config: interfaces.requests.IAppVersionConfig): TemplateResult | '' {
const volumes = this.getConfigVolumes(config);
const publishedPorts = config.publishedPorts || [];
if (volumes.length === 0 && publishedPorts.length === 0) return '';
return html`
<div class="exposure-warning">
Deploying this app will create ${volumes.length} persistent volume(s)
${publishedPorts.length > 0 ? html`and expose ${publishedPorts.length} host port declaration(s)` : ''}.
</div>
`;
}
private getConfigVolumes(config: interfaces.requests.IAppVersionConfig): interfaces.data.IServiceVolume[] {
return (config.volumes || []).map((volume) => {
if (typeof volume === 'string') {
return { mountPath: volume };
}
return volume;
}).filter((volume) => Boolean(volume.mountPath));
}
private formatPublishedPort(port: interfaces.data.IServicePublishedPort): string {
const protocol = port.protocol || 'tcp';
const target = port.targetPortEnd ? `${port.targetPort}-${port.targetPortEnd}` : String(port.targetPort);
const publishedStart = port.publishedPort || port.targetPort;
const publishedEnd = port.publishedPortEnd || (port.targetPortEnd ? publishedStart + (port.targetPortEnd - port.targetPort) : undefined);
const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart);
return `${port.hostIp || '0.0.0.0'}:${published}/${protocol} -> ${target}/${protocol}`;
}
private async handleViewDetails(e: CustomEvent) {
const app = e.detail?.app;
if (!app) return;
@@ -625,25 +724,21 @@ export class ObViewAppStore extends DeesElement {
}
}
const platformReqs = config.platformRequirements || {};
const serviceConfig: interfaces.data.IServiceCreate = {
name: this.serviceName || app.id,
image: config.image,
port: config.port || 80,
domain: this.serviceDomain || undefined,
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,
const identity = appstate.loginStatePart.getState().identity;
if (!identity) return;
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_InstallAppTemplate
>('/typedrequest', 'installAppTemplate');
await typedRequest.fire({
identity,
install: {
appId: app.id,
version: this.selectedVersion,
serviceName: this.serviceName || app.id,
domain: this.serviceDomain || undefined,
envVars,
},
});
setTimeout(() => {
appRouter.navigateToView('services');
+146 -58
View File
@@ -12,6 +12,20 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
const byteUnits = ['B', 'KB', 'MB', 'GB', 'TB'];
function getByteUnitIndex(bytes: number): number {
if (!bytes || bytes === 0) return 0;
return Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), byteUnits.length - 1);
}
function formatBytes(bytes: number, forcedUnitIndex?: number): string {
if ((!bytes || bytes === 0) && forcedUnitIndex === undefined) return '0 B';
const unitIndex = forcedUnitIndex ?? getByteUnitIndex(bytes);
const value = bytes / Math.pow(1024, unitIndex);
return `${value.toFixed(1)} ${byteUnits[unitIndex]}`;
}
@customElement('ob-view-dashboard')
export class ObViewDashboard extends DeesElement {
@state()
@@ -69,7 +83,42 @@ export class ObViewDashboard extends DeesElement {
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css``,
css`
.dashboard {
display: flex;
flex-direction: column;
gap: 24px;
}
.section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
margin: 0 0 12px;
}
.services-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: stretch;
}
.services-grid > * {
height: 100%;
}
@media (min-width: 768px) {
.services-grid {
grid-template-columns: 1fr 1fr;
}
}
`,
];
async connectedCallback() {
@@ -79,6 +128,7 @@ export class ObViewDashboard extends DeesElement {
appstate.servicesStatePart.dispatchAction(appstate.fetchServicesAction, null),
appstate.servicesStatePart.dispatchAction(appstate.fetchPlatformServicesAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchNetworkStatsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchTrafficStatsAction, null),
appstate.networkStatePart.dispatchAction(appstate.fetchCertificatesAction, null),
]);
}
@@ -88,10 +138,15 @@ export class ObViewDashboard extends DeesElement {
const services = this.servicesState.services;
const platformServices = this.servicesState.platformServices;
const networkStats = this.networkState.stats;
const trafficStats = this.networkState.trafficStats;
const certificates = this.networkState.certificates;
const statusCounts = trafficStats?.statusCounts || {};
const runningServices = services.filter((s) => s.status === 'running').length;
const stoppedServices = services.filter((s) => s.status === 'stopped').length;
const memoryUnitIndex = getByteUnitIndex(
status?.docker?.memoryTotal || status?.docker?.memoryUsage || 0,
);
const validCerts = certificates.filter((c) => c.isValid).length;
const expiringCerts = certificates.filter(
@@ -99,65 +154,98 @@ export class ObViewDashboard extends DeesElement {
).length;
const expiredCerts = certificates.filter((c) => !c.isValid).length;
const dashboardData = {
cluster: {
totalServices: services.length,
running: runningServices,
stopped: stoppedServices,
dockerStatus: status?.docker?.running ? 'running' as const : 'stopped' as const,
},
resourceUsage: {
cpu: status?.docker?.cpuUsage || 0,
memoryUsed: formatBytes(status?.docker?.memoryUsage || 0, memoryUnitIndex),
memoryTotal: formatBytes(status?.docker?.memoryTotal || 0, memoryUnitIndex),
networkIn: formatBytes(status?.docker?.networkIn || 0),
networkOut: formatBytes(status?.docker?.networkOut || 0),
topConsumers: [],
},
platformServices: platformServices
.filter((ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore)
.map((ps) => ({
name: ps.displayName,
status: ps.status === 'running' ? 'Running' : ps.status === 'starting' ? 'Starting...' : ps.status === 'stopping' ? 'Stopping...' : 'Stopped',
running: ps.status === 'running',
})),
traffic: {
requests: trafficStats?.requestCount || 0,
errors: trafficStats?.errorCount || 0,
errorPercent: trafficStats?.errorRate || 0,
avgResponse: trafficStats?.avgResponseTime || 0,
reqPerMin: trafficStats?.requestsPerMinute || 0,
status2xx: statusCounts['2xx'] || 0,
status3xx: statusCounts['3xx'] || 0,
status4xx: statusCounts['4xx'] || 0,
status5xx: statusCounts['5xx'] || 0,
},
proxy: {
httpPort: String(networkStats?.proxy?.httpPort || 80),
httpsPort: String(networkStats?.proxy?.httpsPort || 443),
httpActive: networkStats?.proxy?.running || false,
httpsActive: networkStats?.proxy?.running || false,
routeCount: String(networkStats?.proxy?.routes || 0),
},
certificates: {
valid: validCerts,
expiring: expiringCerts,
expired: expiredCerts,
},
dnsConfigured: status?.dns?.configured || false,
acmeConfigured: status?.ssl?.configured || false,
quickActions: [
{ label: 'Deploy Service', icon: 'lucide:Plus', primary: true },
{ label: 'Add Domain', icon: 'lucide:Globe' },
{ label: 'View Logs', icon: 'lucide:FileText' },
],
};
return html`
<ob-sectionheading>Dashboard</ob-sectionheading>
<sz-dashboard-view
.data=${{
cluster: {
totalServices: services.length,
running: runningServices,
stopped: stoppedServices,
dockerStatus: status?.docker?.running ? 'running' : 'stopped',
},
resourceUsage: {
cpu: status?.docker?.cpuUsage || 0,
memoryUsed: status?.docker?.memoryUsage || 0,
memoryTotal: status?.docker?.memoryTotal || 0,
networkIn: status?.docker?.networkIn || 0,
networkOut: status?.docker?.networkOut || 0,
topConsumers: [],
},
platformServices: platformServices
.filter((ps) => ps.status === 'running' || ps.status === 'starting' || ps.status === 'stopping' || ps.isCore)
.map((ps) => ({
name: ps.displayName,
status: ps.status === 'running' ? 'Running' : ps.status === 'starting' ? 'Starting...' : ps.status === 'stopping' ? 'Stopping...' : 'Stopped',
running: ps.status === 'running',
})),
traffic: {
requests: 0,
errors: 0,
errorPercent: 0,
avgResponse: 0,
reqPerMin: 0,
status2xx: 0,
status3xx: 0,
status4xx: 0,
status5xx: 0,
},
proxy: {
httpPort: networkStats?.proxy?.httpPort || 80,
httpsPort: networkStats?.proxy?.httpsPort || 443,
httpActive: networkStats?.proxy?.running || false,
httpsActive: networkStats?.proxy?.running || false,
routeCount: networkStats?.proxy?.routes || 0,
},
certificates: {
valid: validCerts,
expiring: expiringCerts,
expired: expiredCerts,
},
dnsConfigured: true,
acmeConfigured: true,
quickActions: [
{ label: 'Deploy Service', icon: 'lucide:Plus', primary: true },
{ label: 'Add Domain', icon: 'lucide:Globe' },
{ label: 'View Logs', icon: 'lucide:FileText' },
],
}}
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
@service-click=${(e: CustomEvent) => this.handlePlatformServiceClick(e)}
></sz-dashboard-view>
<div class="dashboard">
<section class="section">
<h2 class="section-title">Cluster Overview</h2>
<sz-status-grid-cluster .stats=${dashboardData.cluster}></sz-status-grid-cluster>
</section>
<section class="section">
<h2 class="section-title">Services & Resources</h2>
<div class="services-grid">
<sz-resource-usage-card .data=${dashboardData.resourceUsage}></sz-resource-usage-card>
<sz-platform-services-card
.services=${dashboardData.platformServices}
@service-click=${(e: CustomEvent) => this.handlePlatformServiceClick(e)}
></sz-platform-services-card>
</div>
</section>
<section class="section">
<h2 class="section-title">Network & Traffic</h2>
<sz-status-grid-network
.traffic=${dashboardData.traffic}
.proxy=${dashboardData.proxy}
.certificates=${dashboardData.certificates}
></sz-status-grid-network>
</section>
<section class="section">
<h2 class="section-title">Infrastructure</h2>
<sz-status-grid-infra
?dnsConfigured=${dashboardData.dnsConfigured}
?acmeConfigured=${dashboardData.acmeConfigured}
.actions=${dashboardData.quickActions}
@action-click=${(e: CustomEvent) => this.handleQuickAction(e)}
></sz-status-grid-infra>
</section>
</div>
`;
}