feat(appstore): add App Store install and upgrade workflows

This commit is contained in:
2026-05-26 11:06:21 +00:00
parent bfda4b4ca1
commit 2b65ddc193
10 changed files with 1656 additions and 43 deletions
+210
View File
@@ -35,6 +35,63 @@ export interface IDataState {
backups?: any[];
}
export type TAppStoreUpgradeStatus = 'running' | 'success' | 'failed';
export type TAppStoreUpgradeStep =
| 'queued'
| 'validating'
| 'migration'
| 'applying'
| 'updating-service'
| 'pushing-config'
| 'complete'
| 'failed';
export interface IAppStoreUpgradeChange {
field: string;
currentValue: string;
targetValue: string;
}
export interface IAppStoreUpgradePreview {
serviceId: string;
serviceName: string;
appTemplateId: string;
fromVersion: string;
targetVersion: string;
resolvedTargetVersion: string;
hasMigration: boolean;
requiresManualReview: boolean;
changes: IAppStoreUpgradeChange[];
warnings: string[];
blockers: string[];
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
}
export interface IAppStoreUpgradeOperation {
id: string;
serviceId: string;
serviceName: string;
appTemplateId: string;
fromVersion: string;
targetVersion: string;
status: TAppStoreUpgradeStatus;
step: TAppStoreUpgradeStep;
progressLines: string[];
warnings: string[];
error?: string;
startedAt: number;
updatedAt: number;
completedAt?: number;
service?: plugins.interfaces.data.IService;
}
export interface IAppStoreState {
apps: plugins.interfaces.appstore.IAppStoreApp[];
upgradeableServices: Array<plugins.interfaces.appstore.IUpgradeableAppStoreService & { serviceId?: string }>;
upgradeOperations: IAppStoreUpgradeOperation[];
}
const emptyDataState: IDataState = {
secretGroups: [],
secretBundles: [],
@@ -54,6 +111,12 @@ const emptyDataState: IDataState = {
backups: [],
};
const emptyAppStoreState: IAppStoreState = {
apps: [],
upgradeableServices: [],
upgradeOperations: [],
};
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
@@ -119,6 +182,7 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
try {
apiClient.identity = null;
dataState.setState({ ...emptyDataState });
appStoreStatePart.setState({ ...emptyAppStoreState });
} catch {}
return {
...currentState,
@@ -132,6 +196,12 @@ export const dataState = await appstate.getStatePart<IDataState>(
'soft'
);
export const appStoreStatePart = await appstate.getStatePart<IAppStoreState>(
'appstore',
{ ...emptyAppStoreState },
'soft',
);
// Shared API client instance (used by UI actions)
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
identity: plugins.interfaces.data.IIdentity | null;
@@ -142,6 +212,54 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
}) as TCloudlyApiClientWithNullableIdentity;
const upsertUpgradeOperation = (
operationsArg: IAppStoreUpgradeOperation[],
operationArg: IAppStoreUpgradeOperation,
) => {
const operations = operationsArg.filter((existingOperation) => existingOperation.id !== operationArg.id);
operations.unshift(operationArg);
return operations.slice(0, 25);
};
const upsertService = (
servicesArg: plugins.interfaces.data.IService[] = [],
serviceArg: plugins.interfaces.data.IService,
) => {
const services = servicesArg.filter((existingService) => existingService.id !== serviceArg.id);
services.unshift(serviceArg);
return services;
};
apiClient.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(
'pushAppStoreUpgradeProgress',
async (dataArg: { operation: IAppStoreUpgradeOperation }) => {
const appStoreState = appStoreStatePart.getState() || {
apps: [],
upgradeableServices: [],
upgradeOperations: [],
};
appStoreStatePart.setState({
...appStoreState,
upgradeOperations: upsertUpgradeOperation(appStoreState.upgradeOperations, dataArg.operation),
upgradeableServices: dataArg.operation.status === 'success'
? appStoreState.upgradeableServices.filter((serviceArg) => {
return serviceArg.serviceId !== dataArg.operation.serviceId && serviceArg.serviceName !== dataArg.operation.serviceName;
})
: appStoreState.upgradeableServices,
});
if (dataArg.operation.service) {
const currentDataState = dataState.getState() || {};
dataState.setState({
...currentDataState,
services: upsertService(currentDataState.services, dataArg.operation.service),
});
}
return {};
},
),
);
let identityExpiryTimer: number | undefined;
let identityInvalidationRunning = false;
@@ -184,6 +302,7 @@ export const invalidateIdentity = async (reasonArg = 'identity is not valid'): P
identity: null,
});
dataState.setState({ ...emptyDataState });
appStoreStatePart.setState({ ...emptyAppStoreState });
} finally {
identityInvalidationRunning = false;
}
@@ -737,3 +856,94 @@ export const addClusterAction = dataState.createAction(
return await context.dispatch(getAllDataAction, null);
}
);
const getIdentityForRequest = () => {
const identity = loginStatePart.getState()?.identity ?? null;
if (!identity) {
throw new Error('No Cloudly identity is available');
}
return identity;
};
export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
const response = await request.fire({ identity: getIdentityForRequest() });
return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
apps: response.apps || [],
};
},
);
export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAction(
async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getUpgradeableAppStoreServices');
const response = await request.fire({ identity: getIdentityForRequest() });
return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
upgradeableServices: response.services || [],
};
},
);
export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createAction(
async (statePartArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradeOperations');
const response = await request.fire({ identity: getIdentityForRequest() });
return {
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
upgradeOperations: response.operations || [],
};
},
);
export const startAppStoreServiceUpgradeAction = appStoreStatePart.createAction<{
serviceId: string;
targetVersion: string;
}>(
async (statePartArg, payloadArg) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'startAppStoreServiceUpgrade');
const response = await request.fire({
identity: getIdentityForRequest(),
serviceId: payloadArg.serviceId,
targetVersion: payloadArg.targetVersion,
});
const currentState = statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] };
return {
...currentState,
upgradeOperations: upsertUpgradeOperation(currentState.upgradeOperations, response.operation),
};
},
);
export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreConfig');
return await request.fire({
identity: getIdentityForRequest(),
appId: appIdArg,
version: versionArg,
}) as {
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
};
};
export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradePreview');
const response = await request.fire({
identity: getIdentityForRequest(),
serviceId: serviceIdArg,
targetVersion: targetVersionArg,
});
return response.preview as IAppStoreUpgradePreview;
};
export const installAppStoreApp = async (installArg: plugins.interfaces.appstore.IAppStoreInstallRequest) => {
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'installAppStoreApp');
const response = await request.fire({
identity: getIdentityForRequest(),
install: installArg,
});
return response.service as plugins.interfaces.data.IService;
};
+2
View File
@@ -19,6 +19,7 @@ import { CloudlyViewDbs } from './views/dbs/index.js';
import { CloudlyViewDeployments } from './views/deployments/index.js';
import { CloudlyViewDns } from './views/dns/index.js';
import { CloudlyViewDomains } from './views/domains/index.js';
import { CloudlyViewAppStore } from './views/appstore/index.js';
import { CloudlyViewImages } from './views/images/index.js';
import { CloudlyViewLogs } from './views/logs/index.js';
import { CloudlyViewMails } from './views/mails/index.js';
@@ -79,6 +80,7 @@ export class CloudlyDashboard extends DeesElement {
iconName: 'lucide:Network',
subViews: [
{ slug: 'clusters', name: 'Clusters', iconName: 'lucide:Network', element: CloudlyViewClusters },
{ slug: 'appstore', name: 'App Store', iconName: 'lucide:Store', element: CloudlyViewAppStore },
{ slug: 'services', name: 'Services', iconName: 'lucide:Layers', element: CloudlyViewServices },
{ slug: 'images', name: 'Images', iconName: 'lucide:Image', element: CloudlyViewImages },
{ slug: 'deployments', name: 'Deployments', iconName: 'lucide:Rocket', element: CloudlyViewDeployments },
+420
View File
@@ -0,0 +1,420 @@
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 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">${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;
}
}
+131 -5
View File
@@ -34,6 +34,13 @@ export class CloudlyViewServices extends DeesElement {
@state()
private accessor upgradeInfo: any = null;
@state()
private accessor appStoreState: appstate.IAppStoreState = {
apps: [],
upgradeableServices: [],
upgradeOperations: [],
};
@state()
private accessor workspaceEnvironment: DeploymentExecutionEnvironment | null = null;
@@ -46,8 +53,20 @@ export class CloudlyViewServices extends DeesElement {
.select((stateArg) => stateArg)
.subscribe((dataArg) => {
this.data = dataArg;
if (this.selectedService) {
const updatedService = dataArg.services?.find((serviceArg) => serviceArg.id === this.selectedService!.id);
if (updatedService) {
this.selectedService = updatedService;
}
}
});
this.rxSubscriptions.push(subscription);
const appStoreSubscription = appstate.appStoreStatePart
.select((stateArg) => stateArg)
.subscribe((stateArg) => {
this.appStoreState = stateArg;
});
this.rxSubscriptions.push(appStoreSubscription);
}
public static styles = [
@@ -301,6 +320,8 @@ export class CloudlyViewServices extends DeesElement {
appTemplateId?: string;
appTemplateVersion?: string;
};
const upgradeOperation = this.getUpgradeOperationForService(service);
const upgradeInfo = this.getUpgradeInfoForService(service);
return html`
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
@@ -312,13 +333,19 @@ export class CloudlyViewServices extends DeesElement {
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
</div>
${this.upgradeInfo ? html`
${upgradeOperation ? this.renderUpgradeOperation(upgradeOperation) : ''}
${upgradeInfo ? html`
<div class="update-card">
<div>
<div class="section-title" style="margin-bottom: 3px;">App catalog update available</div>
<div class="detail-subtitle">${this.upgradeInfo.appTemplateId}: ${this.upgradeInfo.currentVersion} -> ${this.upgradeInfo.latestVersion}</div>
<div class="detail-subtitle">${upgradeInfo.appTemplateId}: ${upgradeInfo.currentVersion} -> ${upgradeInfo.latestVersion}</div>
</div>
<button class="primary-button" disabled title="Cloudly does not yet have catalog upgrade apply support">Detected</button>
<button
class="primary-button"
?disabled=${upgradeOperation?.status === 'running'}
@click=${() => this.startUpgradeForService(service)}
>${upgradeOperation?.status === 'running' ? 'Upgrading...' : 'Upgrade'}</button>
</div>
` : ''}
@@ -447,6 +474,46 @@ export class CloudlyViewServices extends DeesElement {
`;
}
private getUpgradeOperationForService(serviceArg: plugins.interfaces.data.IService): appstate.IAppStoreUpgradeOperation | null {
return this.appStoreState.upgradeOperations.find((operationArg) => {
return operationArg.serviceId === serviceArg.id || operationArg.serviceName === serviceArg.data.name;
}) || null;
}
private getUpgradeInfoForService(serviceArg: plugins.interfaces.data.IService): any | null {
const operation = this.getUpgradeOperationForService(serviceArg);
if (operation?.status === 'success') {
return null;
}
const liveUpgradeInfo = this.appStoreState.upgradeableServices.find((upgradeArg) => {
return upgradeArg.serviceId === serviceArg.id || upgradeArg.serviceName === serviceArg.data.name;
});
if (liveUpgradeInfo) {
return liveUpgradeInfo;
}
if (this.upgradeInfo?.serviceId === serviceArg.id || this.upgradeInfo?.serviceName === serviceArg.data.name) {
return this.upgradeInfo;
}
return null;
}
private renderUpgradeOperation(operationArg: appstate.IAppStoreUpgradeOperation): TemplateResult {
const color = operationArg.status === 'failed' ? '#f87171' : '#60a5fa';
return html`
<div class="update-card" style="border-color: ${color}; background: var(--ci-shade-1, #09090b); display: block;">
<div style="display: flex; justify-content: space-between; gap: 16px; align-items: flex-start;">
<div>
<div class="section-title" style="margin-bottom: 3px;">Upgrade ${operationArg.fromVersion} -> ${operationArg.targetVersion}</div>
<div class="detail-subtitle">${operationArg.status} / ${operationArg.step}${operationArg.error ? `: ${operationArg.error}` : ''}</div>
</div>
<span style="color: ${color}; font-size: 12px; text-transform: uppercase;">${operationArg.status}</span>
</div>
<div style="margin-top: 12px; padding: 10px 12px; background: var(--ci-shade-0, #030305); border-radius: 6px; color: var(--ci-shade-5, #a1a1aa); font-family: monospace; font-size: 12px; line-height: 1.5; max-height: 130px; overflow: auto; white-space: pre-wrap;">${operationArg.progressLines.slice(-8).join('\n')}</div>
${operationArg.warnings.length ? html`<div style="margin-top: 10px; color: #fbbf24; font-size: 12px;">${operationArg.warnings.join(' | ')}</div>` : ''}
</div>
`;
}
private renderStatusBadge(statusArg: string): TemplateResult {
return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`;
}
@@ -518,13 +585,72 @@ export class CloudlyViewServices extends DeesElement {
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
try {
const response = await this.fireTypedRequest('getUpgradeableAppStoreServices', {}) as { services: any[] };
this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null;
await Promise.all([
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null),
]);
this.upgradeInfo = this.getUpgradeInfoForService(serviceArg);
} catch {
this.upgradeInfo = null;
}
}
private async startUpgradeForService(serviceArg: plugins.interfaces.data.IService) {
const upgradeInfo = this.getUpgradeInfoForService(serviceArg);
if (!upgradeInfo?.latestVersion) {
return;
}
try {
const preview = await appstate.getAppStoreUpgradePreview(serviceArg.id, upgradeInfo.latestVersion);
if (preview.blockers.length > 0) {
plugins.deesCatalog.DeesToast.createAndShow({ message: preview.blockers.join('; '), type: 'error' });
return;
}
let upgradeStarting = false;
await plugins.deesCatalog.DeesModal.createAndShow({
heading: `Upgrade ${serviceArg.data.name}`,
content: html`
<div style="width: min(720px, calc(100vw - 48px)); max-width: 100%;">
<div class="detail-subtitle" style="margin-bottom: 12px;">${preview.fromVersion} -> ${preview.resolvedTargetVersion}</div>
<div style="display: grid; gap: 8px;">
${preview.changes.map((changeArg) => html`
<div style="display: grid; grid-template-columns: minmax(120px, 0.35fr) 1fr; gap: 10px; font-size: 13px;">
<span style="color: var(--ci-shade-4, #71717a);">${changeArg.field}</span>
<span style="color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere;">${changeArg.currentValue} -> ${changeArg.targetValue}</span>
</div>
`)}
</div>
${preview.warnings.length ? html`<div style="margin-top: 12px; color: #fbbf24; font-size: 12px;">${preview.warnings.join(' | ')}</div>` : ''}
</div>
`,
menuOptions: [
{
name: 'Start Upgrade',
action: async (modalArg: any) => {
if (upgradeStarting) {
return;
}
upgradeStarting = true;
try {
await appstate.appStoreStatePart.dispatchAction(appstate.startAppStoreServiceUpgradeAction, {
serviceId: serviceArg.id,
targetVersion: preview.resolvedTargetVersion,
});
await modalArg.destroy();
} catch (error) {
upgradeStarting = false;
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
}
},
},
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
],
});
} catch (error) {
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
}
}
private async restartDeployment(deploymentArg: plugins.interfaces.data.IDeployment) {
await this.fireTypedRequest('restartDeployment', { deploymentId: deploymentArg.id });
if (this.selectedService) {
+1 -1
View File
@@ -7,7 +7,7 @@ const flatViews = ['overview', 'logs'] as const;
const subviewMap: Record<string, readonly string[]> = {
platform: ['settings', 'baseos', 'fleet'] as const,
runtime: ['clusters', 'services', 'images', 'deployments', 'tasks'] as const,
runtime: ['clusters', 'appstore', 'services', 'images', 'deployments', 'tasks'] as const,
registry: ['externalregistries', 'testing'] as const,
secrets: ['secretgroups', 'secretbundles'] as const,
domains: ['domains', 'dns', 'mails'] as const,