import * as plugins from '../../../plugins.js'; import * as shared from '../../shared/index.js'; import { DeesElement, customElement, html, state, css, cssManager, type TemplateResult, } from '@design.estate/dees-element'; import * as appstate from '../../../appstate.js'; @customElement('cloudly-view-images') export class CloudlyViewImages extends DeesElement { @state() 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() { super(); const subscription = appstate.dataState .select((stateArg) => stateArg) .subscribe((dataArg) => { this.data = dataArg; }); this.rxSubscriptions.push(subscription); } public static styles = [ cssManager.defaultStyles, shared.viewHostCss, 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(): TemplateResult { if (this.currentView === 'detail') { return this.renderDetailView(); } return this.renderListView(); } private renderListView(): TemplateResult { return html` Images { const latestVersion = this.getLatestImageVersion(image); return { Name: html`${image.data.name}`, 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=${[ { name: 'Create Image', type: ['header', 'footer'], iconName: 'plus', actionFunc: async () => { await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Create Image', content: html` `, menuOptions: [ { name: 'cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'save', action: async (modalArg: any) => { const deesForm = modalArg.shadowRoot.querySelector('dees-form'); const formData = await deesForm.collectFormData(); await appstate.dataState.dispatchAction(appstate.createImageAction, { imageName: formData['data.name'] as string, description: formData['data.description'] as string }); await modalArg.destroy(); } }, ], }); }, }, { name: 'Details', type: ['contextmenu', 'inRow', 'doubleClick'], iconName: 'lucide:Eye', actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg) => { this.openImageDetail(dataArg.item); }, }, { name: 'Delete', iconName: 'trash', type: ['contextmenu', 'inRow'], actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg) => { await plugins.deesCatalog.DeesModal.createAndShow({ heading: `Delete Image "${itemArg.item.data.name}"`, content: html`
Do you really want to delete the image?
${itemArg.item.id}
`, menuOptions: [ { name: 'cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'delete', action: async (modalArg: any) => { await appstate.dataState.dispatchAction(appstate.deleteImageAction, { imageId: itemArg.item.id, }); await modalArg.destroy(); } }, ], }); }, }, ] as plugins.deesCatalog.ITableAction[]} >
`; } private renderDetailView(): TemplateResult { const image = this.getActiveImage(); if (!image) { return html` Image Details `; } 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` Image Details

${image.data.name}

${image.data.description || 'No description configured'}
Versions
({ 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), })} >
Services Using This Image
${servicesUsingImage.length ? html` ({ 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, })} > ` : html`
No services currently reference this image.
`}
Registry Source
Image ID${image.id}
Name${image.data.name}
Description${image.data.description || '-'}
Internal${location?.internal === false ? 'no' : 'yes'}
External Registry${location?.externalRegistryId || '-'}
External Tag${location?.externalImageTag || '-'}
External Ref${location?.externalImageRef || '-'}
Latest Created${this.formatDate(latestVersion?.createdAt)}
Last Push
Repository${lastPushEvent?.repository || '-'}
Tag${lastPushEvent?.tag || '-'}
Digest${lastPushEvent?.digest || '-'}
Image URL${lastPushEvent?.imageUrl || '-'}
Pushed At${this.formatDate(lastPushEvent?.pushedAt)}
Actor${lastPushEvent?.actorUserId || '-'}
`; } 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`${source}`; } } declare global { interface HTMLElementTagNameMap { 'cloudly-view-images': CloudlyViewImages; } }