feat(images): improve image operations UI and record archive metadata

This commit is contained in:
2026-05-26 09:54:26 +00:00
parent 05560c9db9
commit 56a62e7008
9 changed files with 398 additions and 548 deletions
+11
View File
@@ -2,6 +2,17 @@
## Pending ## Pending
### Features
- improve Cloudly image operations UI
- Adds operational image list metadata, image detail drilldown, version tables, and service usage context
- Uses table details actions as the safe double-click behavior while keeping mutating actions row/context only
- Records uploaded image archive size and digest metadata after image storage completes
- improve image operations UI and record archive metadata (images)
- Add image list metadata, detail drilldown, version tables, and service usage context.
- Record uploaded image archive size and SHA-256 digest after storage completes.
- Add deployment detail modals and safe double-click Details actions in deployment and service views.
- Initialize default location metadata when creating images.
## 2026-05-25 - 6.0.0 ## 2026-05-25 - 6.0.0
+1 -1
View File
@@ -31,7 +31,7 @@
"@git.zone/tstest": "^3.6.6", "@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5", "@git.zone/tswatch": "^3.3.5",
"@push.rocks/smartnetwork": "^4.7.1", "@push.rocks/smartnetwork": "^4.7.1",
"@types/node": "^25.8.0" "@types/node": "^25.9.0"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "3.3.1", "@api.global/typedrequest": "3.3.1",
+27 -484
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -12,15 +12,18 @@ export class Image extends plugins.smartdata.SmartDataDbDoc<
) { ) {
const image = new Image(); const image = new Image();
image.id = await this.getNewId(); image.id = await this.getNewId();
console.log(imageDataArg);
Object.assign(image, { Object.assign(image, {
data: { data: {
name: imageDataArg.name, name: imageDataArg.name,
description: imageDataArg.description, description: imageDataArg.description,
location: imageDataArg.location || {
internal: true,
externalRegistryId: '',
externalImageTag: '',
},
versions: [], versions: [],
}, },
}); });
console.log((Image as any).saveableProperties);
await image.save(); await image.save();
return image; return image;
} }
+15 -1
View File
@@ -116,12 +116,15 @@ export class ImageManager {
await refImage.save(); await refImage.save();
const imagePushStream = reqArg.imageStream; const imagePushStream = reqArg.imageStream;
(async () => { (async () => {
const archiveHash = plugins.crypto.createHash('sha256');
let archiveSize = 0;
const smartWebDuplex = new plugins.smartstream.webstream.WebDuplexStream< const smartWebDuplex = new plugins.smartstream.webstream.WebDuplexStream<
Uint8Array, Uint8Array,
Uint8Array Uint8Array
>({ >({
writeFunction: async (chunkArg, toolsArg) => { writeFunction: async (chunkArg, toolsArg) => {
console.log(chunkArg); archiveSize += chunkArg.byteLength;
archiveHash.update(chunkArg);
return chunkArg; return chunkArg;
}, },
}); });
@@ -130,6 +133,17 @@ export class ImageManager {
storagePath, storagePath,
plugins.smartstream.SmartDuplex.fromWebReadableStream(smartWebDuplex.readable), plugins.smartstream.SmartDuplex.fromWebReadableStream(smartWebDuplex.readable),
); );
refImage.data.versions = refImage.data.versions.map((versionArg) => {
if (versionArg.versionString !== imageVersion) {
return versionArg;
}
return {
...versionArg,
size: archiveSize,
digest: `sha256:${archiveHash.digest('hex')}`,
};
});
await refImage.save();
})().catch((error) => { })().catch((error) => {
console.error(`failed to store image ${refImage.id}:${imageVersion}`, error); console.error(`failed to store image ${refImage.id}:${imageVersion}`, error);
}); });
+1 -1
View File
@@ -21,7 +21,7 @@ export interface IDataState {
secretBundles?: plugins.interfaces.data.ISecretBundle[]; secretBundles?: plugins.interfaces.data.ISecretBundle[];
clusters?: plugins.interfaces.data.ICluster[]; clusters?: plugins.interfaces.data.ICluster[];
externalRegistries?: plugins.interfaces.data.IExternalRegistry[]; externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
images?: any[]; images?: plugins.interfaces.data.IImage[];
services?: plugins.interfaces.data.IService[]; services?: plugins.interfaces.data.IService[];
deployments?: plugins.interfaces.data.IDeployment[]; deployments?: plugins.interfaces.data.IDeployment[];
domains?: plugins.interfaces.data.IDomain[]; domains?: plugins.interfaces.data.IDomain[];
@@ -8,6 +8,7 @@ import {
state, state,
css, css,
cssManager, cssManager,
type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as appstate from '../../../appstate.js'; import * as appstate from '../../../appstate.js';
@@ -42,6 +43,10 @@ export class CloudlyViewDeployments extends DeesElement {
.health-unknown { background: #f5f5f5; color: #666; } .health-unknown { background: #f5f5f5; color: #666; }
.resource-usage { display: flex; gap: 12px; font-size: 0.9em; color: #888; } .resource-usage { display: flex; gap: 12px; font-size: 0.9em; color: #888; }
.resource-item { display: flex; align-items: center; gap: 4px; } .resource-item { display: flex; align-items: center; gap: 4px; }
.kv-list { display: grid; gap: 8px; min-width: 520px; }
.kv-row { display: grid; grid-template-columns: 150px 1fr; gap: 10px; font-size: 13px; }
.kv-key { color: var(--ci-shade-4, #71717a); }
.kv-value { color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; }
`, `,
]; ];
@@ -105,6 +110,14 @@ export class CloudlyViewDeployments extends DeesElement {
}; };
}} }}
.dataActions=${[ .dataActions=${[
{
name: 'Details',
iconName: 'lucide:Eye',
type: ['contextmenu', 'inRow', 'doubleClick'],
actionFunc: async (actionDataArg: any) => {
await this.showDeploymentDetailsModal(actionDataArg.item as plugins.interfaces.data.IDeployment);
},
},
{ {
name: 'Deploy Service', name: 'Deploy Service',
iconName: 'plus', iconName: 'plus',
@@ -212,6 +225,49 @@ export class CloudlyViewDeployments extends DeesElement {
></dees-table> ></dees-table>
`; `;
} }
private formatDate(timestampArg?: number): string {
return timestampArg ? new Date(timestampArg).toLocaleString() : '-';
}
private formatResourceUsage(deploymentArg: plugins.interfaces.data.IDeployment): string {
if (!deploymentArg.resourceUsage) {
return '-';
}
return `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}% CPU / ${deploymentArg.resourceUsage.memoryUsedMB} MB`;
}
private renderDeploymentDetails(deploymentArg: plugins.interfaces.data.IDeployment): TemplateResult {
return html`
<div class="kv-list">
<div class="kv-row"><span class="kv-key">Deployment ID</span><span class="kv-value">${deploymentArg.id}</span></div>
<div class="kv-row"><span class="kv-key">Service</span><span class="kv-value">${this.getServiceName(deploymentArg.serviceId)}</span></div>
<div class="kv-row"><span class="kv-key">Status</span><span class="kv-value">${deploymentArg.status}</span></div>
<div class="kv-row"><span class="kv-key">Health</span><span class="kv-value">${deploymentArg.healthStatus || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Node</span><span class="kv-value">${deploymentArg.nodeName || deploymentArg.nodeId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Slot</span><span class="kv-value">${deploymentArg.slot || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Desired State</span><span class="kv-value">${deploymentArg.desiredState || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Container ID</span><span class="kv-value">${deploymentArg.containerId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Task ID</span><span class="kv-value">${deploymentArg.taskId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Docker Service ID</span><span class="kv-value">${deploymentArg.dockerServiceId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Version</span><span class="kv-value">${deploymentArg.version || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Image</span><span class="kv-value">${deploymentArg.usedImageId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Resources</span><span class="kv-value">${this.formatResourceUsage(deploymentArg)}</span></div>
<div class="kv-row"><span class="kv-key">Deployed At</span><span class="kv-value">${this.formatDate(deploymentArg.deployedAt)}</span></div>
<div class="kv-row"><span class="kv-key">Updated At</span><span class="kv-value">${this.formatDate(deploymentArg.updatedAt)}</span></div>
</div>
`;
}
private async showDeploymentDetailsModal(deploymentArg: plugins.interfaces.data.IDeployment) {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Deployment Details',
content: this.renderDeploymentDetails(deploymentArg),
menuOptions: [
{ name: 'Close', action: async (modalArg: any) => { await modalArg.destroy(); } },
],
});
}
} }
declare global { declare global {
+233 -56
View File
@@ -1,7 +1,15 @@
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 { DeesElement, customElement, html, state, css, cssManager } from '@design.estate/dees-element'; import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import * as appstate from '../../../appstate.js'; import * as appstate from '../../../appstate.js';
@@ -10,39 +18,84 @@ export class CloudlyViewImages extends DeesElement {
@state() @state()
private accessor data: appstate.IDataState = {} as any; private accessor data: appstate.IDataState = {} as any;
@state()
private accessor currentView: 'list' | 'detail' = 'list';
@state()
private accessor selectedImage: plugins.interfaces.data.IImage | null = null;
constructor() { constructor() {
super(); super();
appstate.dataState const subscription = appstate.dataState
.select((stateArg) => stateArg) .select((stateArg) => stateArg)
.subscribe((dataArg) => { .subscribe((dataArg) => {
this.data = dataArg; this.data = dataArg;
}); });
this.rxSubscriptions.push(subscription);
} }
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css``, css`
.detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.detail-title { margin: 0; font-size: 26px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); }
.detail-subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; overflow-wrap: anywhere; }
.back-button { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 7px; padding: 9px 13px; font-size: 13px; cursor: pointer; background: var(--ci-shade-1, #09090b); color: var(--ci-shade-7, #e4e4e7); }
.summary-card, .detail-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
.spaced-card { margin-top: 14px; }
.details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
.section-title { font-size: 14px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 10px; }
.kv-list { display: grid; gap: 8px; }
.kv-row { display: grid; grid-template-columns: 150px 1fr; gap: 10px; font-size: 13px; }
.kv-key { color: var(--ci-shade-4, #71717a); }
.kv-value { color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; }
.image-name { font-weight: 700; color: var(--ci-shade-7, #e4e4e7); }
.empty-state { color: var(--ci-shade-4, #71717a); font-size: 13px; padding: 12px 0; }
.source-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.source-upload { background: rgba(59, 130, 246, 0.16); color: #60a5fa; }
.source-registry { background: rgba(34, 197, 94, 0.16); color: #22c55e; }
.source-unknown { background: rgba(161, 161, 170, 0.16); color: #a1a1aa; }
dees-statsgrid { margin-bottom: 18px; }
@media (max-width: 900px) { .details-grid { grid-template-columns: 1fr; } .detail-header { flex-direction: column; } }
`,
]; ];
public render() { public render(): TemplateResult {
if (this.currentView === 'detail') {
return this.renderDetailView();
}
return this.renderListView();
}
private renderListView(): TemplateResult {
return html` return html`
<cloudly-sectionheading>Images</cloudly-sectionheading> <cloudly-sectionheading>Images</cloudly-sectionheading>
<dees-table <dees-table
heading1="Images" heading1="Images"
heading2="an image is needed for running a service" heading2="an image is needed for running a service"
.data=${this.data.images} .data=${this.data.images || []}
.displayFunction=${(image: plugins.interfaces.data.IImage) => { .displayFunction=${(image: plugins.interfaces.data.IImage) => {
return { id: image.id, name: image.data.name, description: image.data.description, versions: image.data.versions.length }; const latestVersion = this.getLatestImageVersion(image);
return {
Name: html`<span class="image-name">${image.data.name}</span>`,
Description: image.data.description,
Location: this.getLocationLabel(image),
Versions: image.data.versions?.length || 0,
'Total Size': this.formatBytes(this.getImageTotalSize(image)),
Latest: latestVersion?.versionString || '-',
'Last Push': this.formatDate(image.data.lastPushEvent?.pushedAt),
'Used By': this.getServicesUsingImage(image).length,
};
}} }}
.dataActions=${[ .dataActions=${[
{ {
name: 'create Image', name: 'Create Image',
type: ['header', 'footer'], type: ['header', 'footer'],
iconName: 'plus', iconName: 'plus',
actionFunc: async () => { actionFunc: async () => {
plugins.deesCatalog.DeesModal.createAndShow({ await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'create new Image', heading: 'Create Image',
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .label=${'name'} .key=${'data.name'} .value=${''}></dees-input-text> <dees-input-text .label=${'name'} .key=${'data.name'} .value=${''}></dees-input-text>
@@ -62,60 +115,19 @@ export class CloudlyViewImages extends DeesElement {
}, },
}, },
{ {
name: 'edit', name: 'Details',
type: ['contextmenu', 'inRow', 'doubleClick'], type: ['contextmenu', 'inRow', 'doubleClick'],
iconName: 'lucide:SquarePen', iconName: 'lucide:Eye',
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => { actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.IImage>) => {
const environmentsArray: Array<plugins.interfaces.data.ISecretGroup['data']['environments'][any] & { environment: string; }> = []; this.openImageDetail(dataArg.item);
for (const environmentName of Object.keys(dataArg.item.data.environments)) {
environmentsArray.push({ environment: environmentName, ...dataArg.item.data.environments[environmentName] });
}
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Edit Secret',
content: html`
<dees-form>
<dees-input-text .key=${'id'} .disabled=${true} .label=${'ID'} .value=${dataArg.item.id}></dees-input-text>
<dees-input-text .key=${'data.name'} .disabled=${false} .label=${'name'} .value=${dataArg.item.data.name}></dees-input-text>
<dees-input-text .key=${'data.description'} .disabled=${false} .label=${'description'} .value=${dataArg.item.data.description}></dees-input-text>
<dees-input-text .key=${'data.key'} .disabled=${false} .label=${'key'} .value=${dataArg.item.data.key}></dees-input-text>
<dees-table .key=${'environments'} .heading1=${'Environments'} .heading2=${'double-click to edit values'}
.data=${environmentsArray.map((itemArg) => ({ environment: itemArg.environment, value: itemArg.value }))}
.editableFields=${['environment', 'value']}
.dataActions=${[{ name: 'delete', iconName: 'trash', type: ['inRow'], actionFunc: async (actionDataArg: any) => { actionDataArg.table.data.splice(actionDataArg.table.data.indexOf(actionDataArg.item), 1); } }] as plugins.deesCatalog.ITableAction[]}>
</dees-table>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: undefined, action: async (modalArg: any) => { await modalArg.destroy(); } },
{ name: 'Save', iconName: undefined, action: async (modalArg: any) => { const data = await modalArg.shadowRoot.querySelector('dees-form').collectFormData(); console.log(data); } },
],
});
}, },
}, },
{ {
name: 'history', name: 'Delete',
iconName: 'lucide:History',
type: ['contextmenu', 'inRow'],
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
const historyArray: Array<{ environment: string; value: string; }> = [];
for (const environment of Object.keys(dataArg.item.data.environments)) {
for (const historyItem of dataArg.item.data.environments[environment].history) {
historyArray.push({ environment, value: historyItem.value });
}
}
await plugins.deesCatalog.DeesModal.createAndShow({
heading: `history for ${dataArg.item.data.key}`,
content: html`<dees-table .data=${historyArray} .dataActions=${[{ name: 'delete', iconName: 'trash', type: ['contextmenu', 'inRow'], actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg<(typeof historyArray)[0]>) => { console.log('delete', itemArg); }, }] as plugins.deesCatalog.ITableAction[]}></dees-table>`,
menuOptions: [ { name: 'close', action: async (modalArg: any) => { await modalArg.destroy(); } } ],
});
},
},
{
name: 'delete',
iconName: 'trash', iconName: 'trash',
type: ['contextmenu', 'inRow'], type: ['contextmenu', 'inRow'],
actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.IImage>) => { actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.IImage>) => {
plugins.deesCatalog.DeesModal.createAndShow({ await plugins.deesCatalog.DeesModal.createAndShow({
heading: `Delete Image "${itemArg.item.data.name}"`, heading: `Delete Image "${itemArg.item.data.name}"`,
content: html` content: html`
<div style="text-align:center">Do you really want to delete the image?</div> <div style="text-align:center">Do you really want to delete the image?</div>
@@ -132,6 +144,171 @@ export class CloudlyViewImages extends DeesElement {
></dees-table> ></dees-table>
`; `;
} }
private renderDetailView(): TemplateResult {
const image = this.getActiveImage();
if (!image) {
return html`
<cloudly-sectionheading>Image Details</cloudly-sectionheading>
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Images</button>
`;
}
const versions = this.getSortedImageVersions(image);
const latestVersion = this.getLatestImageVersion(image);
const lastPushEvent = image.data.lastPushEvent;
const location = image.data.location;
const servicesUsingImage = this.getServicesUsingImage(image);
return html`
<cloudly-sectionheading>Image Details</cloudly-sectionheading>
<div class="detail-header">
<div>
<h2 class="detail-title">${image.data.name}</h2>
<div class="detail-subtitle">${image.data.description || 'No description configured'}</div>
</div>
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Images</button>
</div>
<dees-statsgrid .tiles=${this.getImageStatsTiles(image)} .minTileWidth=${220} .gap=${12}></dees-statsgrid>
<div class="detail-card">
<div class="section-title">Versions</div>
<dees-table
.heading1=${'Image Versions'}
.heading2=${versions.length ? 'Stored image versions and registry metadata' : 'No versions recorded'}
.data=${versions}
.displayFunction=${(versionArg: plugins.interfaces.data.IImage['data']['versions'][number]) => ({
Version: versionArg.versionString,
Source: this.renderSourceBadge(versionArg.source),
Size: this.formatBytes(versionArg.size),
Digest: versionArg.digest || '-',
Repository: versionArg.registryRepository || '-',
Tag: versionArg.registryTag || '-',
Storage: versionArg.storagePath || '-',
Created: this.formatDate(versionArg.createdAt),
})}
></dees-table>
</div>
<div class="detail-card spaced-card">
<div class="section-title">Services Using This Image</div>
${servicesUsingImage.length ? html`
<dees-table
.heading1=${'Service Usage'}
.heading2=${'Services currently configured with this image ID'}
.data=${servicesUsingImage}
.displayFunction=${(serviceArg: plugins.interfaces.data.IService) => ({
Name: serviceArg.data.name,
Version: serviceArg.data.imageVersion || '-',
Category: serviceArg.data.serviceCategory || 'workload',
Strategy: serviceArg.data.deploymentStrategy || 'custom',
Domains: serviceArg.data.domains?.map((domainArg) => domainArg.name).join(', ') || '-',
Deployments: serviceArg.data.deploymentIds?.length || 0,
})}
></dees-table>
` : html`<div class="empty-state">No services currently reference this image.</div>`}
</div>
<div class="details-grid">
<div class="detail-card">
<div class="section-title">Registry Source</div>
<div class="kv-list">
<div class="kv-row"><span class="kv-key">Image ID</span><span class="kv-value">${image.id}</span></div>
<div class="kv-row"><span class="kv-key">Name</span><span class="kv-value">${image.data.name}</span></div>
<div class="kv-row"><span class="kv-key">Description</span><span class="kv-value">${image.data.description || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Internal</span><span class="kv-value">${location?.internal === false ? 'no' : 'yes'}</span></div>
<div class="kv-row"><span class="kv-key">External Registry</span><span class="kv-value">${location?.externalRegistryId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">External Tag</span><span class="kv-value">${location?.externalImageTag || '-'}</span></div>
<div class="kv-row"><span class="kv-key">External Ref</span><span class="kv-value">${location?.externalImageRef || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Latest Created</span><span class="kv-value">${this.formatDate(latestVersion?.createdAt)}</span></div>
</div>
</div>
<div class="detail-card">
<div class="section-title">Last Push</div>
<div class="kv-list">
<div class="kv-row"><span class="kv-key">Repository</span><span class="kv-value">${lastPushEvent?.repository || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Tag</span><span class="kv-value">${lastPushEvent?.tag || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Digest</span><span class="kv-value">${lastPushEvent?.digest || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Image URL</span><span class="kv-value">${lastPushEvent?.imageUrl || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Pushed At</span><span class="kv-value">${this.formatDate(lastPushEvent?.pushedAt)}</span></div>
<div class="kv-row"><span class="kv-key">Actor</span><span class="kv-value">${lastPushEvent?.actorUserId || '-'}</span></div>
</div>
</div>
</div>
`;
}
private getActiveImage(): plugins.interfaces.data.IImage | null {
if (!this.selectedImage) {
return null;
}
return this.data.images?.find((imageArg) => imageArg.id === this.selectedImage!.id) || this.selectedImage;
}
private openImageDetail(imageArg: plugins.interfaces.data.IImage) {
this.selectedImage = imageArg;
this.currentView = 'detail';
}
private getImageTotalSize(imageArg: plugins.interfaces.data.IImage): number {
return (imageArg.data.versions || []).reduce((sumArg, versionArg) => sumArg + (versionArg.size || 0), 0);
}
private getSortedImageVersions(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IImage['data']['versions'] {
return [...(imageArg.data.versions || [])].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
}
private getLatestImageVersion(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IImage['data']['versions'][number] | undefined {
return this.getSortedImageVersions(imageArg)[0];
}
private getServicesUsingImage(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IService[] {
return (this.data.services || []).filter((serviceArg) => serviceArg.data.imageId === imageArg.id);
}
private getImageStatsTiles(imageArg: plugins.interfaces.data.IImage) {
const latestVersion = this.getLatestImageVersion(imageArg);
const totalSize = this.getImageTotalSize(imageArg);
const servicesUsingImage = this.getServicesUsingImage(imageArg);
return [
{ id: 'versions', title: 'Versions', value: imageArg.data.versions?.length || 0, type: 'number' as const, icon: 'lucide:Tags', description: 'Recorded image versions' },
{ id: 'size', title: 'Total Size', value: this.formatBytes(totalSize), type: 'text' as const, icon: 'lucide:HardDrive', description: 'Stored archive size' },
{ id: 'latest', title: 'Latest Version', value: latestVersion?.versionString || '-', type: 'text' as const, icon: 'lucide:GitBranch', description: this.formatDate(latestVersion?.createdAt) },
{ id: 'usage', title: 'Used By', value: servicesUsingImage.length, type: 'number' as const, icon: 'lucide:Layers', description: 'Configured services' },
];
}
private getLocationLabel(imageArg: plugins.interfaces.data.IImage): string {
const location = imageArg.data.location;
if (!location || location.internal) {
return 'Internal registry';
}
return location.externalImageRef || location.externalImageTag || 'External registry';
}
private formatBytes(sizeArg?: number): string {
if (!sizeArg) {
return sizeArg === 0 ? '0 B' : '-';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = sizeArg;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size = size / 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
private formatDate(timestampArg?: number): string {
return timestampArg ? new Date(timestampArg).toLocaleString() : '-';
}
private renderSourceBadge(sourceArg?: 'upload' | 'registry'): TemplateResult {
const source = sourceArg || 'unknown';
return html`<span class="source-badge source-${source}">${source}</span>`;
}
} }
declare global { declare global {
+49 -3
View File
@@ -59,7 +59,6 @@ export class CloudlyViewServices extends DeesElement {
.category-distributed { background: #9c27b0; color: white; } .category-distributed { background: #9c27b0; color: white; }
.category-workload { background: #4caf50; color: white; } .category-workload { background: #4caf50; color: white; }
.strategy-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.85em; background: #444; color: #ccc; margin-left: 4px; } .strategy-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.85em; background: #444; color: #ccc; margin-left: 4px; }
.link-button { border: none; background: transparent; color: var(--ci-color-primary, #60a5fa); cursor: pointer; padding: 0; font: inherit; }
.detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; } .detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.detail-title { margin: 0; font-size: 26px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); } .detail-title { margin: 0; font-size: 26px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); }
.detail-subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; } .detail-subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; }
@@ -126,7 +125,7 @@ export class CloudlyViewServices extends DeesElement {
.data=${this.data.services || []} .data=${this.data.services || []}
.displayFunction=${(itemArg: plugins.interfaces.data.IService) => { .displayFunction=${(itemArg: plugins.interfaces.data.IService) => {
return { return {
Name: html`<button class="link-button" @click=${() => this.openServiceDetail(itemArg)}>${itemArg.data.name}</button>`, Name: itemArg.data.name,
Description: itemArg.data.description, Description: itemArg.data.description,
Category: this.getCategoryBadgeHtml(itemArg.data.serviceCategory || 'workload'), Category: this.getCategoryBadgeHtml(itemArg.data.serviceCategory || 'workload'),
'Deployment Strategy': html` 'Deployment Strategy': html`
@@ -144,7 +143,7 @@ export class CloudlyViewServices extends DeesElement {
{ {
name: 'Details', name: 'Details',
iconName: 'eye', iconName: 'eye',
type: ['contextmenu', 'inRow'], type: ['contextmenu', 'inRow', 'doubleClick'],
actionFunc: async (actionDataArg: any) => { actionFunc: async (actionDataArg: any) => {
await this.openServiceDetail(actionDataArg.item as plugins.interfaces.data.IService); await this.openServiceDetail(actionDataArg.item as plugins.interfaces.data.IService);
}, },
@@ -366,6 +365,14 @@ export class CloudlyViewServices extends DeesElement {
Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-', Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-',
})} })}
.dataActions=${[ .dataActions=${[
{
name: 'Details',
iconName: 'lucide:Eye',
type: ['contextmenu', 'inRow', 'doubleClick'],
actionFunc: async (actionDataArg: any) => {
await this.showDeploymentDetailsModal(actionDataArg.item);
},
},
{ {
name: 'Open IDE', name: 'Open IDE',
iconName: 'terminal', iconName: 'terminal',
@@ -444,6 +451,45 @@ export class CloudlyViewServices extends DeesElement {
return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`; return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`;
} }
private formatDate(timestampArg?: number): string {
return timestampArg ? new Date(timestampArg).toLocaleString() : '-';
}
private formatResourceUsage(deploymentArg: plugins.interfaces.data.IDeployment): string {
if (!deploymentArg.resourceUsage) {
return '-';
}
return `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}% CPU / ${deploymentArg.resourceUsage.memoryUsedMB} MB`;
}
private async showDeploymentDetailsModal(deploymentArg: plugins.interfaces.data.IDeployment) {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Deployment Details',
content: html`
<div class="kv-list" style="min-width: 520px;">
<div class="kv-row"><span class="kv-key">Deployment ID</span><span class="kv-value">${deploymentArg.id}</span></div>
<div class="kv-row"><span class="kv-key">Service</span><span class="kv-value">${deploymentArg.serviceName || this.selectedService?.data.name || deploymentArg.serviceId}</span></div>
<div class="kv-row"><span class="kv-key">Status</span><span class="kv-value">${deploymentArg.status}</span></div>
<div class="kv-row"><span class="kv-key">Health</span><span class="kv-value">${deploymentArg.healthStatus || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Node</span><span class="kv-value">${deploymentArg.nodeName || deploymentArg.nodeId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Slot</span><span class="kv-value">${deploymentArg.slot || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Desired State</span><span class="kv-value">${deploymentArg.desiredState || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Container ID</span><span class="kv-value">${deploymentArg.containerId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Task ID</span><span class="kv-value">${deploymentArg.taskId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Docker Service ID</span><span class="kv-value">${deploymentArg.dockerServiceId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Version</span><span class="kv-value">${deploymentArg.version || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Image</span><span class="kv-value">${deploymentArg.usedImageId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Resources</span><span class="kv-value">${this.formatResourceUsage(deploymentArg)}</span></div>
<div class="kv-row"><span class="kv-key">Deployed At</span><span class="kv-value">${this.formatDate(deploymentArg.deployedAt)}</span></div>
<div class="kv-row"><span class="kv-key">Updated At</span><span class="kv-value">${this.formatDate(deploymentArg.updatedAt)}</span></div>
</div>
`,
menuOptions: [
{ name: 'Close', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
private async openServiceDetail(serviceArg: plugins.interfaces.data.IService) { private async openServiceDetail(serviceArg: plugins.interfaces.data.IService) {
this.selectedService = serviceArg; this.selectedService = serviceArg;
this.serviceDeployments = []; this.serviceDeployments = [];