2025-09-14 17:28:21 +00:00
|
|
|
import * as plugins from '../../../plugins.js';
|
|
|
|
|
import * as shared from '../../shared/index.js';
|
|
|
|
|
|
2026-05-26 09:54:26 +00:00
|
|
|
import {
|
|
|
|
|
DeesElement,
|
|
|
|
|
customElement,
|
|
|
|
|
html,
|
|
|
|
|
state,
|
|
|
|
|
css,
|
|
|
|
|
cssManager,
|
|
|
|
|
type TemplateResult,
|
|
|
|
|
} from '@design.estate/dees-element';
|
2025-09-14 17:28:21 +00:00
|
|
|
|
|
|
|
|
import * as appstate from '../../../appstate.js';
|
|
|
|
|
|
|
|
|
|
@customElement('cloudly-view-images')
|
|
|
|
|
export class CloudlyViewImages extends DeesElement {
|
|
|
|
|
@state()
|
2026-05-08 13:56:20 +00:00
|
|
|
private accessor data: appstate.IDataState = {} as any;
|
2025-09-14 17:28:21 +00:00
|
|
|
|
2026-05-26 09:54:26 +00:00
|
|
|
@state()
|
|
|
|
|
private accessor currentView: 'list' | 'detail' = 'list';
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
private accessor selectedImage: plugins.interfaces.data.IImage | null = null;
|
|
|
|
|
|
2025-09-14 17:28:21 +00:00
|
|
|
constructor() {
|
|
|
|
|
super();
|
2026-05-26 09:54:26 +00:00
|
|
|
const subscription = appstate.dataState
|
2025-09-14 17:28:21 +00:00
|
|
|
.select((stateArg) => stateArg)
|
|
|
|
|
.subscribe((dataArg) => {
|
|
|
|
|
this.data = dataArg;
|
|
|
|
|
});
|
2026-05-26 09:54:26 +00:00
|
|
|
this.rxSubscriptions.push(subscription);
|
2025-09-14 17:28:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static styles = [
|
|
|
|
|
cssManager.defaultStyles,
|
|
|
|
|
shared.viewHostCss,
|
2026-05-26 09:54:26 +00:00
|
|
|
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; } }
|
|
|
|
|
`,
|
2025-09-14 17:28:21 +00:00
|
|
|
];
|
|
|
|
|
|
2026-05-26 09:54:26 +00:00
|
|
|
public render(): TemplateResult {
|
|
|
|
|
if (this.currentView === 'detail') {
|
|
|
|
|
return this.renderDetailView();
|
|
|
|
|
}
|
|
|
|
|
return this.renderListView();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderListView(): TemplateResult {
|
2025-09-14 17:28:21 +00:00
|
|
|
return html`
|
|
|
|
|
<cloudly-sectionheading>Images</cloudly-sectionheading>
|
|
|
|
|
<dees-table
|
|
|
|
|
heading1="Images"
|
|
|
|
|
heading2="an image is needed for running a service"
|
2026-05-26 09:54:26 +00:00
|
|
|
.data=${this.data.images || []}
|
2025-09-14 17:28:21 +00:00
|
|
|
.displayFunction=${(image: plugins.interfaces.data.IImage) => {
|
2026-05-26 09:54:26 +00:00
|
|
|
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,
|
|
|
|
|
};
|
2025-09-14 17:28:21 +00:00
|
|
|
}}
|
|
|
|
|
.dataActions=${[
|
|
|
|
|
{
|
2026-05-26 09:54:26 +00:00
|
|
|
name: 'Create Image',
|
2025-09-14 17:28:21 +00:00
|
|
|
type: ['header', 'footer'],
|
|
|
|
|
iconName: 'plus',
|
|
|
|
|
actionFunc: async () => {
|
2026-05-26 09:54:26 +00:00
|
|
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
|
|
|
|
heading: 'Create Image',
|
2025-09-14 17:28:21 +00:00
|
|
|
content: html`
|
|
|
|
|
<dees-form>
|
|
|
|
|
<dees-input-text .label=${'name'} .key=${'data.name'} .value=${''}></dees-input-text>
|
|
|
|
|
<dees-input-text .label=${'description'} .key=${'data.description'} .value=${''}></dees-input-text>
|
|
|
|
|
</dees-form>
|
|
|
|
|
`,
|
|
|
|
|
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();
|
|
|
|
|
} },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-05-26 09:54:26 +00:00
|
|
|
name: 'Details',
|
2025-09-14 17:28:21 +00:00
|
|
|
type: ['contextmenu', 'inRow', 'doubleClick'],
|
2026-05-26 09:54:26 +00:00
|
|
|
iconName: 'lucide:Eye',
|
|
|
|
|
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.IImage>) => {
|
|
|
|
|
this.openImageDetail(dataArg.item);
|
2025-09-14 17:28:21 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
2026-05-26 09:54:26 +00:00
|
|
|
name: 'Delete',
|
2025-09-14 17:28:21 +00:00
|
|
|
iconName: 'trash',
|
|
|
|
|
type: ['contextmenu', 'inRow'],
|
|
|
|
|
actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.IImage>) => {
|
2026-05-26 09:54:26 +00:00
|
|
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
2025-09-14 17:28:21 +00:00
|
|
|
heading: `Delete Image "${itemArg.item.data.name}"`,
|
|
|
|
|
content: html`
|
|
|
|
|
<div style="text-align:center">Do you really want to delete the image?</div>
|
|
|
|
|
<div style="font-size: 0.8em; color: red; text-align:center; padding: 16px; margin-top: 24px; border: 1px solid #444; font-family: Intel One Mono; font-size: 16px;">${itemArg.item.id}</div>
|
|
|
|
|
`,
|
|
|
|
|
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[]}
|
|
|
|
|
></dees-table>
|
|
|
|
|
`;
|
|
|
|
|
}
|
2026-05-26 09:54:26 +00:00
|
|
|
|
|
|
|
|
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>`;
|
|
|
|
|
}
|
2025-09-14 17:28:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface HTMLElementTagNameMap {
|
|
|
|
|
'cloudly-view-images': CloudlyViewImages;
|
|
|
|
|
}
|
|
|
|
|
}
|