428 lines
19 KiB
TypeScript
428 lines
19 KiB
TypeScript
import * as plugins from '../../../plugins.js';
|
|
import * as shared from '../../shared/index.js';
|
|
import * as appstate from '../../../appstate.js';
|
|
import { appRouter } from '../../../router.js';
|
|
|
|
import {
|
|
DeesElement,
|
|
customElement,
|
|
html,
|
|
state,
|
|
css,
|
|
cssManager,
|
|
type TemplateResult,
|
|
} from '@design.estate/dees-element';
|
|
|
|
type TEditableEnvVar = {
|
|
key: string;
|
|
value: string;
|
|
description: string;
|
|
required?: boolean;
|
|
platformInjected?: boolean;
|
|
};
|
|
|
|
@customElement('cloudly-view-appstore')
|
|
export class CloudlyViewAppStore extends DeesElement {
|
|
@state()
|
|
private accessor appStoreState: appstate.IAppStoreState = {
|
|
apps: [],
|
|
upgradeableServices: [],
|
|
upgradeOperations: [],
|
|
};
|
|
|
|
@state()
|
|
private accessor currentView: 'grid' | 'detail' = 'grid';
|
|
|
|
@state()
|
|
private accessor selectedApp: plugins.interfaces.appstore.IAppStoreApp | null = null;
|
|
|
|
@state()
|
|
private accessor selectedAppMeta: plugins.interfaces.appstore.IAppStoreAppMeta | null = null;
|
|
|
|
@state()
|
|
private accessor selectedAppConfig: plugins.interfaces.appstore.IAppStoreVersionConfig | null = null;
|
|
|
|
@state()
|
|
private accessor configLoadError = '';
|
|
|
|
@state()
|
|
private accessor selectedVersion = '';
|
|
|
|
@state()
|
|
private accessor editableEnvVars: TEditableEnvVar[] = [];
|
|
|
|
@state()
|
|
private accessor serviceName = '';
|
|
|
|
@state()
|
|
private accessor serviceDomain = '';
|
|
|
|
@state()
|
|
private accessor deployMode = false;
|
|
|
|
@state()
|
|
private accessor loading = false;
|
|
|
|
private configRequestToken = 0;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
shared.viewHostCss,
|
|
css`
|
|
.card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; margin-bottom: 14px; }
|
|
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
|
.title { margin: 0; color: var(--ci-shade-7, #e4e4e7); font-size: 24px; font-weight: 700; }
|
|
.subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; line-height: 1.5; }
|
|
.section-title { color: var(--ci-shade-7, #e4e4e7); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
|
.badge { display: inline-flex; padding: 3px 9px; border-radius: 999px; background: rgba(59, 130, 246, 0.16); color: #60a5fa; font-size: 12px; margin: 0 6px 6px 0; }
|
|
.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); }
|
|
.button.primary { background: var(--ci-color-primary, #2563eb); border-color: var(--ci-color-primary, #2563eb); color: white; }
|
|
.button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|
.actions { display: flex; gap: 10px; align-items: center; margin-top: 14px; }
|
|
.field { display: grid; gap: 6px; margin-top: 12px; }
|
|
.field label { color: var(--ci-shade-5, #a1a1aa); font-size: 12px; font-weight: 600; }
|
|
input, select { width: 100%; box-sizing: border-box; background: var(--ci-shade-2, #27272a); border: 1px solid var(--ci-shade-3, #3f3f46); border-radius: 6px; padding: 9px 10px; color: var(--ci-shade-7, #e4e4e7); }
|
|
.env-table { width: 100%; border-collapse: collapse; }
|
|
.env-table th, .env-table td { text-align: left; padding: 7px 8px 7px 0; border-bottom: 1px solid var(--ci-shade-2, #27272a); vertical-align: top; }
|
|
.env-key, .mono { font-family: monospace; color: var(--ci-shade-6, #d4d4d8); overflow-wrap: anywhere; }
|
|
.muted { color: var(--ci-shade-4, #71717a); font-size: 12px; }
|
|
.warning { margin-top: 10px; padding: 10px 12px; border-radius: 7px; background: rgba(245, 158, 11, 0.12); color: #fbbf24; font-size: 12px; }
|
|
.operation { display: grid; gap: 7px; }
|
|
.operation-log { max-height: 120px; overflow: auto; white-space: pre-wrap; font-family: monospace; font-size: 12px; color: var(--ci-shade-5, #a1a1aa); background: var(--ci-shade-0, #030305); border-radius: 6px; padding: 10px; }
|
|
@media (max-width: 760px) { .header { flex-direction: column; } .actions { flex-direction: column; align-items: stretch; } }
|
|
`,
|
|
];
|
|
|
|
constructor() {
|
|
super();
|
|
const subscription = appstate.appStoreStatePart
|
|
.select((stateArg) => stateArg)
|
|
.subscribe((stateArg) => {
|
|
this.appStoreState = stateArg;
|
|
});
|
|
this.rxSubscriptions.push(subscription);
|
|
const loginSubscription = appstate.loginStatePart
|
|
.select((stateArg) => stateArg.identity)
|
|
.subscribe((identityArg) => {
|
|
if (identityArg) {
|
|
void this.refreshAppStoreData();
|
|
}
|
|
});
|
|
this.rxSubscriptions.push(loginSubscription);
|
|
}
|
|
|
|
public async connectedCallback() {
|
|
super.connectedCallback();
|
|
await this.refreshAppStoreData();
|
|
}
|
|
|
|
private async refreshAppStoreData() {
|
|
if (!appstate.loginStatePart.getState()?.identity) {
|
|
return;
|
|
}
|
|
await Promise.allSettled([
|
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreTemplatesAction, null),
|
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null),
|
|
]);
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
if (this.currentView === 'detail') {
|
|
return this.renderDetailView();
|
|
}
|
|
return this.renderGridView();
|
|
}
|
|
|
|
private renderGridView(): TemplateResult {
|
|
return html`
|
|
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
|
${this.renderOperations()}
|
|
<dees-table
|
|
.heading1=${'App Store Apps'}
|
|
.heading2=${'Install workload services that follow a serve.zone App Store template'}
|
|
.data=${this.appStoreState.apps}
|
|
.displayFunction=${(appArg: plugins.interfaces.appstore.IAppStoreApp) => ({
|
|
Name: appArg.name,
|
|
Category: html`<span class="badge">${appArg.category}</span>`,
|
|
Version: appArg.latestVersion,
|
|
Source: appArg.source?.type || 'curated',
|
|
Tags: appArg.tags?.join(', ') || '-',
|
|
})}
|
|
.dataActions=${[
|
|
{
|
|
name: 'Details',
|
|
iconName: 'lucide:Eye',
|
|
type: ['contextmenu', 'inRow', 'doubleClick'],
|
|
actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, false),
|
|
},
|
|
{
|
|
name: 'Install',
|
|
iconName: 'lucide:Download',
|
|
type: ['contextmenu', 'inRow'],
|
|
actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, true),
|
|
},
|
|
] as plugins.deesCatalog.ITableAction[]}
|
|
></dees-table>
|
|
`;
|
|
}
|
|
|
|
private renderOperations(): TemplateResult | '' {
|
|
const operations = this.appStoreState.upgradeOperations
|
|
.slice(0, 3);
|
|
if (operations.length === 0) return '';
|
|
return html`
|
|
<div class="card">
|
|
<div class="section-title">Recent Upgrade Operations</div>
|
|
${operations.map((operationArg) => html`
|
|
<div class="operation">
|
|
<div class="mono">${operationArg.serviceName}: ${operationArg.fromVersion} -> ${operationArg.targetVersion} (${operationArg.status}/${operationArg.step})</div>
|
|
<div class="operation-log">${operationArg.progressLines.slice(-6).join('\n')}</div>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderDetailView(): TemplateResult {
|
|
const app = this.selectedApp;
|
|
const meta = this.selectedAppMeta;
|
|
const config = this.selectedAppConfig;
|
|
if (this.configLoadError) {
|
|
return html`
|
|
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back to App Store</button>
|
|
<div class="card" style="margin-top: 14px;">
|
|
<div class="section-title">Could not load app details</div>
|
|
<div class="warning">${this.configLoadError}</div>
|
|
<div class="actions">
|
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back</button>
|
|
${this.selectedApp ? html`<button class="button primary" @click=${async () => {
|
|
this.loading = true;
|
|
await this.fetchVersionConfig(this.selectedApp!.id, this.selectedVersion || this.selectedApp!.latestVersion);
|
|
this.loading = false;
|
|
}}>Retry</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
if (this.loading || !app || !config) {
|
|
return html`<cloudly-sectionheading>App Store</cloudly-sectionheading><div class="card">Loading app details...</div>`;
|
|
}
|
|
const platformRequirements = config.platformRequirements || {};
|
|
const enabledRequirements = Object.entries(platformRequirements).filter(([, enabled]) => enabled);
|
|
const platformRequirementLabels: Record<string, string> = {
|
|
mongodb: 'MongoDB',
|
|
s3: 'S3',
|
|
clickhouse: 'ClickHouse',
|
|
valkey: 'Valkey',
|
|
mariadb: 'MariaDB',
|
|
};
|
|
const volumes = this.getConfigVolumes(config);
|
|
const publishedPorts = config.publishedPorts || [];
|
|
return html`
|
|
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back to App Store</button>
|
|
<div class="card" style="margin-top: 14px;">
|
|
<div class="header">
|
|
<div>
|
|
<h2 class="title">${app.name}</h2>
|
|
<div class="subtitle">${app.description}</div>
|
|
<div style="margin-top: 10px;">
|
|
<span class="badge">${app.category}</span>
|
|
${app.tags?.map((tagArg) => html`<span class="badge">${tagArg}</span>`)}
|
|
</div>
|
|
</div>
|
|
<div class="mono">${config.image}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="section-title">Version</div>
|
|
<select @change=${(eventArg: Event) => this.changeVersion((eventArg.target as HTMLSelectElement).value)}>
|
|
${(meta?.versions || [this.selectedVersion]).map((versionArg) => html`
|
|
<option value=${versionArg} ?selected=${versionArg === this.selectedVersion}>${versionArg}${versionArg === app.latestVersion ? ' (latest)' : ''}</option>
|
|
`)}
|
|
</select>
|
|
${config.minCloudlyVersion ? html`<div class="muted" style="margin-top: 8px;">Requires Cloudly >= ${config.minCloudlyVersion}</div>` : ''}
|
|
</div>
|
|
|
|
${enabledRequirements.length ? html`
|
|
<div class="card">
|
|
<div class="section-title">Platform Requirements</div>
|
|
${enabledRequirements.map(([key]) => html`<span class="badge">${platformRequirementLabels[key] || key}</span>`)}
|
|
<div class="muted">Cloudly currently provisions MongoDB and S3 requirements through platform bindings.</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${(volumes.length || publishedPorts.length) ? html`
|
|
<div class="card">
|
|
<div class="section-title">Deployment Footprint</div>
|
|
${volumes.map((volumeArg) => html`<div class="mono">Volume: ${volumeArg.source || volumeArg.name || 'managed'} -> ${volumeArg.mountPath}</div>`)}
|
|
${publishedPorts.map((portArg) => html`<div class="mono">Published port: ${this.formatPublishedPort(portArg)}</div>`)}
|
|
${publishedPorts.length ? html`<div class="warning">This app publishes raw host ports outside the HTTP proxy.</div>` : ''}
|
|
</div>
|
|
` : ''}
|
|
|
|
${this.editableEnvVars.length ? html`
|
|
<div class="card">
|
|
<div class="section-title">Environment</div>
|
|
<table class="env-table">
|
|
<thead><tr><th>Key</th><th>Value</th><th>Description</th></tr></thead>
|
|
<tbody>
|
|
${this.editableEnvVars.map((envVarArg, indexArg) => html`
|
|
<tr>
|
|
<td class="env-key">${envVarArg.key}${envVarArg.required ? html` <span class="badge">required</span>` : ''}</td>
|
|
<td><input .value=${envVarArg.value} ?disabled=${envVarArg.platformInjected || !this.deployMode} @input=${(eventArg: Event) => this.updateEnvVar(indexArg, (eventArg.target as HTMLInputElement).value)} /></td>
|
|
<td class="muted">${envVarArg.description}${envVarArg.platformInjected ? ' Auto-injected by platform.' : ''}</td>
|
|
</tr>
|
|
`)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
` : ''}
|
|
|
|
${this.deployMode ? html`
|
|
<div class="card">
|
|
<div class="section-title">Install Service</div>
|
|
<div class="field"><label>Service name</label><input .value=${this.serviceName} @input=${(eventArg: Event) => { this.serviceName = (eventArg.target as HTMLInputElement).value; }} /></div>
|
|
<div class="field"><label>Domain</label><input .value=${this.serviceDomain} @input=${(eventArg: Event) => { this.serviceDomain = this.normalizeDomain((eventArg.target as HTMLInputElement).value); }} /></div>
|
|
<div class="muted" style="margin-top: 8px;">Domain is required when the template uses SERVICE_DOMAIN.</div>
|
|
<div class="actions">
|
|
<button class="button" @click=${() => { this.deployMode = false; }}>Cancel</button>
|
|
<button class="button primary" @click=${() => this.installSelectedApp()}>Install ${this.selectedVersion}</button>
|
|
</div>
|
|
</div>
|
|
` : html`
|
|
<div class="actions">
|
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back</button>
|
|
<button class="button primary" @click=${() => { this.deployMode = true; }}>Install this App</button>
|
|
</div>
|
|
`}
|
|
`;
|
|
}
|
|
|
|
private async openApp(appArg: plugins.interfaces.appstore.IAppStoreApp, deployModeArg: boolean) {
|
|
this.selectedApp = appArg;
|
|
this.selectedAppMeta = null;
|
|
this.selectedAppConfig = null;
|
|
this.configLoadError = '';
|
|
this.selectedVersion = appArg.latestVersion;
|
|
this.serviceName = appArg.id;
|
|
this.serviceDomain = '';
|
|
this.deployMode = deployModeArg;
|
|
this.loading = true;
|
|
this.currentView = 'detail';
|
|
await this.fetchVersionConfig(appArg.id, appArg.latestVersion);
|
|
this.loading = false;
|
|
}
|
|
|
|
private async changeVersion(versionArg: string) {
|
|
if (!this.selectedApp || this.selectedVersion === versionArg) return;
|
|
this.selectedVersion = versionArg;
|
|
this.loading = true;
|
|
await this.fetchVersionConfig(this.selectedApp.id, versionArg);
|
|
this.loading = false;
|
|
}
|
|
|
|
private async fetchVersionConfig(appIdArg: string, versionArg: string): Promise<boolean> {
|
|
const requestToken = ++this.configRequestToken;
|
|
this.configLoadError = '';
|
|
this.selectedAppConfig = null;
|
|
try {
|
|
const response = await appstate.getAppStoreConfig(appIdArg, versionArg);
|
|
if (requestToken !== this.configRequestToken) {
|
|
return false;
|
|
}
|
|
this.selectedAppMeta = response.appMeta;
|
|
this.selectedAppConfig = response.config;
|
|
this.editableEnvVars = (response.config.envVars || []).map((envVarArg) => ({
|
|
key: envVarArg.key,
|
|
value: envVarArg.value || '',
|
|
description: envVarArg.description || '',
|
|
required: envVarArg.required,
|
|
platformInjected: Boolean(envVarArg.value?.includes('${') && !envVarArg.value.includes('${SERVICE_DOMAIN}')),
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
if (requestToken === this.configRequestToken) {
|
|
this.configLoadError = (error as Error).message;
|
|
this.editableEnvVars = [];
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to load app config: ${(error as Error).message}`, type: 'error' });
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private updateEnvVar(indexArg: number, valueArg: string) {
|
|
const envVars = [...this.editableEnvVars];
|
|
envVars[indexArg] = { ...envVars[indexArg], value: valueArg };
|
|
this.editableEnvVars = envVars;
|
|
}
|
|
|
|
private async installSelectedApp() {
|
|
if (!this.selectedApp || !this.selectedAppConfig) return;
|
|
const missingEnvVars = this.editableEnvVars.filter((envVarArg) => envVarArg.required && !envVarArg.platformInjected && !envVarArg.value.trim());
|
|
if (missingEnvVars.length) {
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Missing env vars: ${missingEnvVars.map((envVarArg) => envVarArg.key).join(', ')}`, type: 'error' });
|
|
return;
|
|
}
|
|
const needsDomain = (this.selectedAppConfig.envVars || []).some((envVarArg) => envVarArg.value?.includes('${SERVICE_DOMAIN}'));
|
|
if (needsDomain && !this.serviceDomain) {
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'A domain is required for this app.', type: 'error' });
|
|
return;
|
|
}
|
|
const envVars: Record<string, string> = {};
|
|
for (const envVar of this.editableEnvVars) {
|
|
if (envVar.key && envVar.value) {
|
|
envVars[envVar.key] = envVar.value;
|
|
}
|
|
}
|
|
try {
|
|
await appstate.installAppStoreApp({
|
|
appId: this.selectedApp.id,
|
|
version: this.selectedVersion,
|
|
serviceName: this.serviceName || this.selectedApp.id,
|
|
domain: this.serviceDomain || undefined,
|
|
envVars,
|
|
});
|
|
await Promise.allSettled([
|
|
appstate.dataState.dispatchAction(appstate.getAllDataAction, null),
|
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
|
]);
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'App Store service installed', type: 'success' });
|
|
appRouter.navigateToView('runtime', 'services');
|
|
} catch (error) {
|
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Install failed: ${(error as Error).message}`, type: 'error' });
|
|
}
|
|
}
|
|
|
|
private getConfigVolumes(configArg: plugins.interfaces.appstore.IAppStoreVersionConfig) {
|
|
return (configArg.volumes || []).map((volumeArg) => {
|
|
if (typeof volumeArg === 'string') {
|
|
return { mountPath: volumeArg };
|
|
}
|
|
return volumeArg;
|
|
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
|
|
}
|
|
|
|
private formatPublishedPort(portArg: plugins.interfaces.appstore.IAppStorePublishedPort): string {
|
|
const protocol = portArg.protocol || 'tcp';
|
|
const target = portArg.targetPortEnd ? `${portArg.targetPort}-${portArg.targetPortEnd}` : String(portArg.targetPort);
|
|
const publishedStart = portArg.publishedPort || portArg.targetPort;
|
|
const publishedEnd = portArg.publishedPortEnd || (portArg.targetPortEnd ? publishedStart + (portArg.targetPortEnd - portArg.targetPort) : undefined);
|
|
const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart);
|
|
return `${portArg.hostIp || '0.0.0.0'}:${published}/${protocol} -> ${target}/${protocol}`;
|
|
}
|
|
|
|
private normalizeDomain(valueArg: string) {
|
|
return valueArg.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'cloudly-view-appstore': CloudlyViewAppStore;
|
|
}
|
|
}
|