Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7a307c9d3 | |||
| 06d54db747 | |||
| 756c35aa05 | |||
| 2adb86c5ea | |||
| f6ab7460e1 | |||
| 2b65ddc193 | |||
| bfda4b4ca1 | |||
| a9d9ea585c | |||
| 56a62e7008 | |||
| 05560c9db9 | |||
| 50e69b095c |
@@ -3,6 +3,64 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-26 - 6.3.1
|
||||
|
||||
- remove redundant card wrappers around Cloudly tables (ui)
|
||||
- Lets `dees-table` provide its own card shell in service, image, and task history views.
|
||||
- Moves the live deployment refresh action into the table header actions.
|
||||
|
||||
### Fixes
|
||||
|
||||
- remove redundant wrappers around Cloudly tables (ui)
|
||||
- Let dees-table provide its own card shell in service, image, and task history views.
|
||||
- Move the live deployments refresh action into the deployments table header actions.
|
||||
- Bump @types/node to ^25.9.1.
|
||||
|
||||
## 2026-05-26 - 6.3.0
|
||||
|
||||
- add hosted app lifecycle protocol support (hostedapp)
|
||||
- Implements generic Hosted App TypedRequest handlers for Cloudly-hosted App Store services.
|
||||
- Injects service-scoped runtime identity environment variables into Cloudly App Store installs.
|
||||
- Lets Cloudly report initial admin bootstrap credentials to its parent host when `SERVEZONE_ADMINACCOUNT` is not configured.
|
||||
|
||||
### Features
|
||||
|
||||
- add hosted app lifecycle protocol support (hostedapp)
|
||||
- Adds a hosted app manager with lifecycle, bootstrap, and managed upgrade TypedRequest handlers.
|
||||
- Injects hosted app runtime identity environment variables into App Store installs.
|
||||
- Allows initial admin bootstrap credentials to be requested from the parent hosted app runtime when SERVEZONE_ADMINACCOUNT is not configured.
|
||||
- Updates hosted app platform requirements and @serve.zone/interfaces for the lifecycle protocol.
|
||||
|
||||
## 2026-05-26 - 6.2.0
|
||||
|
||||
### Features
|
||||
|
||||
- add App Store install and upgrade workflows (appstore)
|
||||
- Add an App Store dashboard for browsing templates, viewing version configs, editing install inputs, and installing services
|
||||
- Add App Store state actions, routing, and live upgrade operation progress handling in the web app
|
||||
- Implement upgrade previews, asynchronous service upgrade operations, platform binding reconciliation, and preservation of service volume and published port overrides
|
||||
- Enable service detail upgrades with preview confirmation, progress display, and refreshed service data
|
||||
- Bump @serve.zone/interfaces to ^6.0.1 and add App Store upgrade merge tests
|
||||
|
||||
## 2026-05-26 - 6.1.0
|
||||
|
||||
### Features
|
||||
|
||||
- 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
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- switch App Store APIs to the shared appstore client
|
||||
- Replaces Cloudly App Store manager naming and request methods with App Store naming
|
||||
- Uses `@serve.zone/appstore` for app metadata parsing and source resolution
|
||||
- Adds `servezone.appstore.json` as Cloudly's source-owned App Store manifest
|
||||
|
||||
## 2026-05-24 - 5.9.0
|
||||
|
||||
### Features
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/cloudly",
|
||||
"version": "5.9.0",
|
||||
"version": "6.3.1",
|
||||
"private": true,
|
||||
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
|
||||
"type": "module",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@git.zone/tswatch": "^3.3.5",
|
||||
"@push.rocks/smartnetwork": "^4.7.1",
|
||||
"@types/node": "^25.8.0"
|
||||
"@types/node": "^25.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "3.3.1",
|
||||
@@ -79,7 +79,8 @@
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@serve.zone/api": "^5.3.8",
|
||||
"@serve.zone/interfaces": "^5.10.0",
|
||||
"@serve.zone/appstore": "^0.2.0",
|
||||
"@serve.zone/interfaces": "^6.1.0",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
Generated
+47
-494
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- '@serve.zone/api'
|
||||
- '@serve.zone/appstore'
|
||||
- '@serve.zone/interfaces'
|
||||
|
||||
allowBuilds:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"app": {
|
||||
"id": "cloudly",
|
||||
"name": "Cloudly",
|
||||
"description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
|
||||
"category": "Dev Tools",
|
||||
"iconName": "server",
|
||||
"tags": ["serve.zone", "control-plane", "clusters", "deployments"],
|
||||
"maintainer": "serve.zone",
|
||||
"links": {
|
||||
"Source": "https://code.foss.global/serve.zone/cloudly",
|
||||
"Docs": "https://serve.zone"
|
||||
}
|
||||
},
|
||||
"latestVersion": "latest",
|
||||
"source": {
|
||||
"type": "dockerImage",
|
||||
"image": "code.foss.global/serve.zone/cloudly:latest",
|
||||
"tracking": "digest"
|
||||
},
|
||||
"runtime": {
|
||||
"image": "code.foss.global/serve.zone/cloudly:latest",
|
||||
"port": 80,
|
||||
"envVars": [
|
||||
{
|
||||
"key": "SERVEZONE_ENVIRONMENT",
|
||||
"value": "production",
|
||||
"description": "Cloudly runtime environment.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "SERVEZONE_URL",
|
||||
"value": "${SERVICE_DOMAIN}",
|
||||
"description": "Public Cloudly hostname without protocol.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "SERVEZONE_PORT",
|
||||
"value": "80",
|
||||
"description": "Internal Cloudly HTTP port inside the container.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "SERVEZONE_SSLMODE",
|
||||
"value": "external",
|
||||
"description": "Use external TLS termination through Onebox or dcrouter.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_URL",
|
||||
"value": "${MONGODB_URI}",
|
||||
"description": "Authenticated MongoDB connection URL provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_NAME",
|
||||
"value": "${MONGODB_DATABASE}",
|
||||
"description": "MongoDB database name provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_USER",
|
||||
"value": "${MONGODB_USERNAME}",
|
||||
"description": "MongoDB username provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_PASS",
|
||||
"value": "${MONGODB_PASSWORD}",
|
||||
"description": "MongoDB password provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_ENDPOINT",
|
||||
"value": "onebox-minio",
|
||||
"description": "S3 endpoint host for the MinIO service provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_PORT",
|
||||
"value": "9000",
|
||||
"description": "S3 endpoint port for the MinIO service provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_USESSL",
|
||||
"value": "false",
|
||||
"description": "Use plain HTTP for internal MinIO traffic on the Onebox network.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_BUCKET",
|
||||
"value": "${S3_BUCKET}",
|
||||
"description": "S3 bucket provisioned by Onebox for Cloudly's registry.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_ACCESSKEY",
|
||||
"value": "${S3_ACCESS_KEY}",
|
||||
"description": "S3 access key provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_SECRETKEY",
|
||||
"value": "${S3_SECRET_KEY}",
|
||||
"description": "S3 secret key provisioned by Onebox.",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"platformRequirements": {
|
||||
"mongodb": true,
|
||||
"s3": true
|
||||
},
|
||||
"minOneboxVersion": "2.2.0",
|
||||
"backupBeforeUpgrade": true,
|
||||
"healthCheck": {
|
||||
"path": "/status",
|
||||
"port": 80,
|
||||
"expectedStatus": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { CloudlyAppStoreManager } from '../ts/manager.appstore/classes.appstoremanager.js';
|
||||
|
||||
const createManager = () => Object.create(CloudlyAppStoreManager.prototype) as any;
|
||||
|
||||
tap.test('should preserve service volume overrides during App Store upgrades', async () => {
|
||||
const manager = createManager();
|
||||
const volumes = manager.mergeUpgradeVolumes(
|
||||
[
|
||||
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
|
||||
{ mountPath: '/cache', name: 'custom-cache' },
|
||||
],
|
||||
[
|
||||
'/data',
|
||||
{ mountPath: '/config', readOnly: true },
|
||||
],
|
||||
);
|
||||
|
||||
expect(volumes).toEqual([
|
||||
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
|
||||
{ mountPath: '/config', readOnly: true },
|
||||
{ mountPath: '/cache', name: 'custom-cache' },
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('should preserve service published port overrides during App Store upgrades', async () => {
|
||||
const manager = createManager();
|
||||
const publishedPorts = manager.mergeUpgradePublishedPorts(
|
||||
[
|
||||
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
|
||||
{ targetPort: 9999, publishedPort: 19999 },
|
||||
],
|
||||
[
|
||||
{ targetPort: 5432, publishedPort: 5432 },
|
||||
{ targetPort: 6379 },
|
||||
],
|
||||
);
|
||||
|
||||
expect(publishedPorts).toEqual([
|
||||
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
|
||||
{ targetPort: 6379, protocol: 'tcp' },
|
||||
{ targetPort: 9999, publishedPort: 19999, protocol: 'tcp' },
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('should report unsupported App Store published port configs', async () => {
|
||||
const manager = createManager();
|
||||
const unsupported = manager.getUnsupportedPublishedPorts([
|
||||
{ targetPort: 80, publishedPort: 80 },
|
||||
{ targetPort: 81, publishedPort: 80 },
|
||||
{ targetPort: 82, publishedPort: 82, hostIp: '127.0.0.1' },
|
||||
{ targetPort: 9000, targetPortEnd: 9001, publishedPort: 19000, publishedPortEnd: 19002 },
|
||||
]);
|
||||
|
||||
expect(unsupported.some((messageArg: string) => messageArg.includes('duplicates published port 80/tcp'))).toBeTrue();
|
||||
expect(unsupported.some((messageArg: string) => messageArg.includes('unsupported hostIp'))).toBeTrue();
|
||||
expect(unsupported.some((messageArg: string) => messageArg.includes('mismatched target and published port ranges'))).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.9.0',
|
||||
version: '6.3.1',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
+10
-5
@@ -34,7 +34,8 @@ import { CloudlySettingsManager } from './manager.settings/classes.settingsmanag
|
||||
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
|
||||
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
|
||||
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
|
||||
import { CloudlyAppCatalogManager } from './manager.appcatalog/classes.appcatalogmanager.js';
|
||||
import { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
|
||||
import { CloudlyHostedAppManager } from './manager.hostedapp/classes.hostedappmanager.js';
|
||||
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
|
||||
|
||||
/**
|
||||
@@ -81,7 +82,8 @@ export class Cloudly {
|
||||
public nodeManager: CloudlyNodeManager;
|
||||
public baremetalManager: CloudlyBaremetalManager;
|
||||
public baseOsManager: CloudlyBaseOsManager;
|
||||
public appCatalogManager: CloudlyAppCatalogManager;
|
||||
public appStoreManager: CloudlyAppStoreManager;
|
||||
public hostedAppManager: CloudlyHostedAppManager;
|
||||
public jumpManager: CloudlyJumpManager;
|
||||
|
||||
private readyDeferred = new plugins.smartpromise.Deferred();
|
||||
@@ -119,7 +121,8 @@ export class Cloudly {
|
||||
this.backupManager = new CloudlyBackupManager(this);
|
||||
this.baseOsManager = new CloudlyBaseOsManager(this);
|
||||
this.secretManager = new CloudlySecretManager(this);
|
||||
this.appCatalogManager = new CloudlyAppCatalogManager(this);
|
||||
this.hostedAppManager = new CloudlyHostedAppManager(this);
|
||||
this.appStoreManager = new CloudlyAppStoreManager(this);
|
||||
this.nodeManager = new CloudlyNodeManager(this);
|
||||
this.baremetalManager = new CloudlyBaremetalManager(this);
|
||||
this.jumpManager = new CloudlyJumpManager(this);
|
||||
@@ -151,7 +154,8 @@ export class Cloudly {
|
||||
await this.taskManager.init();
|
||||
await this.backupManager.start();
|
||||
await this.baseOsManager.start();
|
||||
await this.appCatalogManager.start();
|
||||
await this.hostedAppManager.start();
|
||||
await this.appStoreManager.start();
|
||||
await this.registryManager.start();
|
||||
await this.domainManager.init();
|
||||
|
||||
@@ -186,7 +190,8 @@ export class Cloudly {
|
||||
await this.backupManager.stop();
|
||||
await this.baseOsManager.stop();
|
||||
await this.registryManager.stop();
|
||||
await this.appCatalogManager.stop();
|
||||
await this.hostedAppManager.stop();
|
||||
await this.appStoreManager.stop();
|
||||
await this.externalRegistryManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Image } from '../manager.image/classes.image.js';
|
||||
import { Service } from '../manager.service/classes.service.js';
|
||||
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
|
||||
import { PlatformBinding } from '../manager.platform/classes.platformbinding.js';
|
||||
|
||||
type ICatalogApp = plugins.servezoneInterfaces.appcatalog.ICatalogApp;
|
||||
type ICatalog = plugins.servezoneInterfaces.appcatalog.ICatalog;
|
||||
type IAppMeta = plugins.servezoneInterfaces.appcatalog.IAppMeta;
|
||||
type IAppVersionConfig = plugins.servezoneInterfaces.appcatalog.IAppVersionConfig;
|
||||
type IInstallOptions = plugins.servezoneInterfaces.appcatalog.IAppInstallRequest;
|
||||
type IUpgradeableCatalogService = plugins.servezoneInterfaces.appcatalog.IUpgradeableAppService;
|
||||
|
||||
export class CloudlyAppCatalogManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private catalogCache: ICatalog | null = null;
|
||||
private lastFetchTime = 0;
|
||||
private readonly repoBaseUrl = process.env.APPCATALOG_URL || 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
|
||||
private readonly cacheTtlMs = 5 * 60 * 1000;
|
||||
|
||||
constructor(private cloudlyRef: Cloudly) {
|
||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
public async start() {}
|
||||
public async stop() {}
|
||||
|
||||
private registerHandlers() {
|
||||
const addCatalogListHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return { apps: await this.getApps() };
|
||||
}),
|
||||
);
|
||||
};
|
||||
addCatalogListHandler('getAppCatalogTemplates');
|
||||
addCatalogListHandler('getAppTemplates');
|
||||
|
||||
const addConfigHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return {
|
||||
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
|
||||
appMeta: await this.getAppMeta(dataArg.appId),
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
addConfigHandler('getAppCatalogConfig');
|
||||
addConfigHandler('getAppConfig');
|
||||
|
||||
const addInstallHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
const service = await this.installApp(dataArg.install || dataArg);
|
||||
return { service: await service.createSavableObject() };
|
||||
}),
|
||||
);
|
||||
};
|
||||
addInstallHandler('installAppCatalogApp');
|
||||
addInstallHandler('installAppTemplate');
|
||||
|
||||
const addUpgradeableHandler = (methodArg: string) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return { services: await this.getUpgradeableServices() };
|
||||
}),
|
||||
);
|
||||
};
|
||||
addUpgradeableHandler('getUpgradeableAppCatalogServices');
|
||||
addUpgradeableHandler('getUpgradeableServices');
|
||||
}
|
||||
|
||||
public async getCatalog(): Promise<ICatalog> {
|
||||
const now = Date.now();
|
||||
if (this.catalogCache && now - this.lastFetchTime < this.cacheTtlMs) {
|
||||
return this.catalogCache;
|
||||
}
|
||||
const catalog = await this.fetchJson('catalog.json') as ICatalog;
|
||||
if (!catalog || !Array.isArray(catalog.apps)) {
|
||||
throw new Error('Invalid app catalog format');
|
||||
}
|
||||
this.catalogCache = catalog;
|
||||
this.lastFetchTime = now;
|
||||
return catalog;
|
||||
}
|
||||
|
||||
public async getApps(): Promise<ICatalogApp[]> {
|
||||
return (await this.getCatalog()).apps;
|
||||
}
|
||||
|
||||
public async getAppMeta(appIdArg: string): Promise<IAppMeta> {
|
||||
return await this.fetchJson(`apps/${appIdArg}/app.json`) as IAppMeta;
|
||||
}
|
||||
|
||||
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppVersionConfig> {
|
||||
if (!versionArg) {
|
||||
versionArg = (await this.getAppMeta(appIdArg)).latestVersion;
|
||||
}
|
||||
return await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as IAppVersionConfig;
|
||||
}
|
||||
|
||||
public async getUpgradeableServices(): Promise<IUpgradeableCatalogService[]> {
|
||||
const catalog = await this.getCatalog();
|
||||
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const upgradeableServices: IUpgradeableCatalogService[] = [];
|
||||
|
||||
for (const service of services) {
|
||||
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
appTemplateId?: string;
|
||||
appTemplateVersion?: string;
|
||||
};
|
||||
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
||||
continue;
|
||||
}
|
||||
const catalogApp = catalog.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
|
||||
if (!catalogApp || catalogApp.latestVersion === serviceData.appTemplateVersion) {
|
||||
continue;
|
||||
}
|
||||
upgradeableServices.push({
|
||||
serviceName: serviceData.name,
|
||||
appTemplateId: serviceData.appTemplateId,
|
||||
currentVersion: serviceData.appTemplateVersion,
|
||||
latestVersion: catalogApp.latestVersion,
|
||||
hasMigration: false,
|
||||
});
|
||||
}
|
||||
|
||||
return upgradeableServices;
|
||||
}
|
||||
|
||||
public async installApp(optionsArg: IInstallOptions): Promise<Service> {
|
||||
const appMeta = await this.getAppMeta(optionsArg.appId);
|
||||
const version = optionsArg.version || appMeta.latestVersion;
|
||||
const config = await this.getAppVersionConfig(optionsArg.appId, version);
|
||||
const webPort = optionsArg.port || config.port;
|
||||
this.assertSupportedPlatformRequirements(config);
|
||||
const envVars = this.getCatalogEnvVars(config, optionsArg.envVars || {});
|
||||
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
|
||||
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
|
||||
}
|
||||
|
||||
const image = await this.createCatalogImage(optionsArg.serviceName, config.image, appMeta.description);
|
||||
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
|
||||
const serviceData = {
|
||||
name: optionsArg.serviceName,
|
||||
description: appMeta.description,
|
||||
imageId: image.id,
|
||||
imageVersion: this.getImageTag(config.image),
|
||||
deployOnPush: false,
|
||||
appTemplateId: optionsArg.appId,
|
||||
appTemplateVersion: version,
|
||||
environment: envVars,
|
||||
secretBundleId: secretBundle.id,
|
||||
additionalSecretBundleIds: [],
|
||||
serviceCategory: 'workload',
|
||||
deploymentStrategy: 'limited-replicas',
|
||||
maxReplicas: 1,
|
||||
antiAffinity: false,
|
||||
scaleFactor: 1,
|
||||
balancingStrategy: 'round-robin',
|
||||
ports: { web: webPort },
|
||||
volumes: this.normalizeVolumes(config.volumes),
|
||||
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
|
||||
deploymentIds: [],
|
||||
} as plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
appTemplateId: string;
|
||||
appTemplateVersion: string;
|
||||
};
|
||||
const service = await Service.createService(serviceData);
|
||||
secretBundle.data.serviceId = service.id;
|
||||
await secretBundle.save();
|
||||
await this.createPlatformBindings(service, config);
|
||||
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
||||
return service;
|
||||
}
|
||||
|
||||
private async createCatalogImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
|
||||
const image = new Image();
|
||||
image.id = await Image.getNewId();
|
||||
image.data = {
|
||||
name: `${serviceNameArg}-catalog-image`,
|
||||
description: descriptionArg,
|
||||
location: {
|
||||
internal: false,
|
||||
externalRegistryId: '',
|
||||
externalImageTag: imageRefArg,
|
||||
externalImageRef: imageRefArg,
|
||||
},
|
||||
versions: [{
|
||||
versionString: this.getImageTag(imageRefArg),
|
||||
source: 'registry',
|
||||
registryRepository: imageRefArg,
|
||||
registryTag: this.getImageTag(imageRefArg),
|
||||
size: 0,
|
||||
createdAt: Date.now(),
|
||||
}],
|
||||
};
|
||||
await image.save();
|
||||
return image;
|
||||
}
|
||||
|
||||
private async createServiceSecretBundle(serviceNameArg: string, imageIdArg: string): Promise<SecretBundle> {
|
||||
const secretBundle = new SecretBundle();
|
||||
secretBundle.id = plugins.smartunique.shortId(8);
|
||||
secretBundle.data = {
|
||||
name: `${serviceNameArg} catalog secrets`,
|
||||
description: `Generated catalog secret bundle for ${serviceNameArg}`,
|
||||
type: 'service',
|
||||
includedSecretGroupIds: [],
|
||||
includedTags: [],
|
||||
imageClaims: [{ imageId: imageIdArg, permissions: ['read'] }],
|
||||
authorizations: [],
|
||||
};
|
||||
await secretBundle.save();
|
||||
return secretBundle;
|
||||
}
|
||||
|
||||
private async createPlatformBindings(serviceArg: Service, configArg: IAppVersionConfig) {
|
||||
const requirements = configArg.platformRequirements || {};
|
||||
if (requirements.mongodb) {
|
||||
await PlatformBinding.upsertBinding({
|
||||
id: await PlatformBinding.getNewId(),
|
||||
serviceId: serviceArg.id,
|
||||
capability: 'database',
|
||||
desiredState: 'enabled',
|
||||
status: 'requested',
|
||||
});
|
||||
}
|
||||
if (requirements.s3) {
|
||||
await PlatformBinding.upsertBinding({
|
||||
id: await PlatformBinding.getNewId(),
|
||||
serviceId: serviceArg.id,
|
||||
capability: 'objectstorage',
|
||||
desiredState: 'enabled',
|
||||
status: 'requested',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []) {
|
||||
return volumesArg.map((volumeArg) => {
|
||||
if (typeof volumeArg === 'string') {
|
||||
return { mountPath: volumeArg };
|
||||
}
|
||||
return volumeArg;
|
||||
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
|
||||
}
|
||||
|
||||
private getCatalogEnvVars(configArg: IAppVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
const missingRequiredEnvVars: string[] = [];
|
||||
for (const envVar of configArg.envVars || []) {
|
||||
const value = overridesArg[envVar.key] ?? envVar.value ?? '';
|
||||
if (envVar.required && !value) {
|
||||
missingRequiredEnvVars.push(envVar.key);
|
||||
}
|
||||
envVars[envVar.key] = value;
|
||||
}
|
||||
Object.assign(envVars, overridesArg);
|
||||
if (missingRequiredEnvVars.length > 0) {
|
||||
throw new Error(`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
|
||||
private requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
|
||||
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
|
||||
}
|
||||
|
||||
private assertSupportedPlatformRequirements(configArg: IAppVersionConfig) {
|
||||
const unsupported = Object.entries(configArg.platformRequirements || {})
|
||||
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
|
||||
.map(([key]) => key);
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(`Cloudly catalog install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getImageTag(imageRefArg: string) {
|
||||
const lastSlashIndex = imageRefArg.lastIndexOf('/');
|
||||
const lastColonIndex = imageRefArg.lastIndexOf(':');
|
||||
return lastColonIndex > lastSlashIndex ? imageRefArg.slice(lastColonIndex + 1) || 'latest' : 'latest';
|
||||
}
|
||||
|
||||
private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
}
|
||||
|
||||
private async fetchJson(pathArg: string): Promise<unknown> {
|
||||
const url = `${this.repoBaseUrl.replace(/\/+$/, '')}/${pathArg}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,19 +113,28 @@ export class CloudlyAuthManager {
|
||||
}
|
||||
|
||||
const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount;
|
||||
if (!adminAccount) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap');
|
||||
}
|
||||
let username: string;
|
||||
let password: string;
|
||||
let hostedBootstrapActionId: string | undefined;
|
||||
if (adminAccount) {
|
||||
const separatorIndex = adminAccount.indexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
||||
}
|
||||
|
||||
const separatorIndex = adminAccount.indexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
||||
}
|
||||
|
||||
const username = adminAccount.slice(0, separatorIndex).trim();
|
||||
const password = adminAccount.slice(separatorIndex + 1);
|
||||
if (!username || !password) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
||||
username = adminAccount.slice(0, separatorIndex).trim();
|
||||
password = adminAccount.slice(separatorIndex + 1);
|
||||
if (!username || !password) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
||||
}
|
||||
} else {
|
||||
const hostedBootstrap = await this.cloudlyRef.hostedAppManager.requestParentInitialAdminBootstrap();
|
||||
if (!hostedBootstrap) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap unless hosted app lifecycle credentials are available');
|
||||
}
|
||||
username = hostedBootstrap.username;
|
||||
password = hostedBootstrap.password;
|
||||
hostedBootstrapActionId = hostedBootstrap.actionId;
|
||||
}
|
||||
|
||||
const user = new this.CUser({
|
||||
@@ -139,6 +148,14 @@ export class CloudlyAuthManager {
|
||||
});
|
||||
await user.save();
|
||||
logger.log('success', `created initial admin user ${username}`);
|
||||
if (hostedBootstrapActionId) {
|
||||
await this.cloudlyRef.hostedAppManager.completeParentBootstrapAction(
|
||||
hostedBootstrapActionId,
|
||||
'Cloudly created the initial admin user.',
|
||||
).catch((errorArg) => {
|
||||
logger.log('warn', `failed to complete hosted app bootstrap action: ${(errorArg as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async stop() {}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Service } from '../manager.service/classes.service.js';
|
||||
|
||||
type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifecycleState;
|
||||
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
|
||||
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
|
||||
|
||||
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
hostedAppLifecycle?: IHostedAppLifecycleState;
|
||||
};
|
||||
|
||||
export class CloudlyHostedAppManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private cloudlyRef: Cloudly) {
|
||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
public async start() {}
|
||||
public async stop() {}
|
||||
|
||||
private getParentRuntimeIdentity(): IHostedAppRuntimeIdentity | null {
|
||||
const appInstanceId = process.env.SERVEZONE_APP_INSTANCE_ID;
|
||||
const appControlToken = process.env.SERVEZONE_APP_CONTROL_TOKEN;
|
||||
if (!appInstanceId || !appControlToken) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appInstanceId,
|
||||
appControlToken,
|
||||
hostType: process.env.SERVEZONE_APP_HOST_TYPE || 'onebox',
|
||||
};
|
||||
}
|
||||
|
||||
private createParentRuntimeTypedRequest<TRequest extends plugins.typedrequestInterfaces.ITypedRequest>(methodArg: TRequest['method']): plugins.typedrequest.TypedRequest<TRequest> | null {
|
||||
const runtimeUrl = process.env.SERVEZONE_RUNTIME_URL;
|
||||
if (!runtimeUrl) {
|
||||
return null;
|
||||
}
|
||||
return new plugins.typedrequest.TypedRequest<TRequest>(
|
||||
`${runtimeUrl.replace(/\/+$/, '')}/typedrequest`,
|
||||
methodArg,
|
||||
);
|
||||
}
|
||||
|
||||
public async requestParentInitialAdminBootstrap(): Promise<{
|
||||
username: string;
|
||||
password: string;
|
||||
actionId: string;
|
||||
} | null> {
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||
'hostedAppRequestBootstrapAction',
|
||||
);
|
||||
if (!identity || !request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = 'admin';
|
||||
const password = plugins.smartunique.uniSimple('cloudlyadmin', 32);
|
||||
const response = await request.fire({
|
||||
identity,
|
||||
action: {
|
||||
type: 'credentials',
|
||||
label: 'Cloudly initial admin',
|
||||
url: `https://${this.cloudlyRef.config.data.publicUrl}`,
|
||||
username,
|
||||
password,
|
||||
message: 'Use these credentials to sign in to Cloudly, then change the admin password.',
|
||||
},
|
||||
});
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
actionId: response.action.id,
|
||||
};
|
||||
}
|
||||
|
||||
public async completeParentBootstrapAction(actionIdArg?: string, messageArg?: string): Promise<void> {
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||
'hostedAppCompleteBootstrapAction',
|
||||
);
|
||||
if (!identity || !request) {
|
||||
return;
|
||||
}
|
||||
await request.fire({
|
||||
identity,
|
||||
actionId: actionIdArg,
|
||||
message: messageArg,
|
||||
});
|
||||
}
|
||||
|
||||
public createHostedAppRuntimeEnvVars(serviceNameArg: string): {
|
||||
appInstanceId: string;
|
||||
appControlToken: string;
|
||||
envVars: Record<string, string>;
|
||||
lifecycle: IHostedAppLifecycleState;
|
||||
} {
|
||||
const appInstanceId = plugins.smartunique.uniSimple('hostedapp');
|
||||
const appControlToken = plugins.smartunique.uniSimple('hostedapptoken', 64);
|
||||
const runtimeUrl = `https://${this.cloudlyRef.config.data.publicUrl}`;
|
||||
return {
|
||||
appInstanceId,
|
||||
appControlToken,
|
||||
envVars: {
|
||||
SERVEZONE_RUNTIME_URL: runtimeUrl,
|
||||
SERVEZONE_APP_INSTANCE_ID: appInstanceId,
|
||||
SERVEZONE_APP_CONTROL_TOKEN: appControlToken,
|
||||
SERVEZONE_APP_HOST_TYPE: 'cloudly',
|
||||
},
|
||||
lifecycle: {
|
||||
appInstanceId,
|
||||
hostType: 'cloudly',
|
||||
appName: serviceNameArg,
|
||||
runtimeStatus: 'unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async requireHostedAppIdentity(identityArg: IHostedAppRuntimeIdentity): Promise<Service> {
|
||||
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const service = services.find((serviceArg) => {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
return (
|
||||
serviceData.hostedAppLifecycle?.appInstanceId === identityArg?.appInstanceId ||
|
||||
serviceData.environment?.SERVEZONE_APP_INSTANCE_ID === identityArg?.appInstanceId
|
||||
);
|
||||
});
|
||||
if (!service) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Hosted app service not found');
|
||||
}
|
||||
const serviceData = service.data as TExtendedServiceData;
|
||||
if (serviceData.environment?.SERVEZONE_APP_CONTROL_TOKEN !== identityArg?.appControlToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Hosted app identity is invalid');
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
private async getUpgradeState(serviceArg: Service): Promise<IHostedAppUpgradeState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
const latestOperation = this.cloudlyRef.appStoreManager
|
||||
.getUpgradeOperations()
|
||||
.find((operationArg) => operationArg.serviceId === serviceArg.id);
|
||||
if (latestOperation) {
|
||||
return {
|
||||
status: latestOperation.status === 'running' ? 'running' : latestOperation.status,
|
||||
appTemplateId: latestOperation.appTemplateId,
|
||||
currentVersion: latestOperation.fromVersion,
|
||||
targetVersion: latestOperation.targetVersion,
|
||||
operationId: latestOperation.id,
|
||||
warnings: latestOperation.warnings,
|
||||
error: latestOperation.error,
|
||||
startedAt: latestOperation.startedAt,
|
||||
updatedAt: latestOperation.updatedAt,
|
||||
completedAt: latestOperation.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
||||
return { status: 'unknown' };
|
||||
}
|
||||
|
||||
const upgradeableServices = await this.cloudlyRef.appStoreManager.getUpgradeableAppStoreServices();
|
||||
const upgradeable = upgradeableServices.find((serviceArg2) => serviceArg2.serviceId === serviceArg.id);
|
||||
if (!upgradeable) {
|
||||
return {
|
||||
status: 'upToDate',
|
||||
appTemplateId: serviceData.appTemplateId,
|
||||
currentVersion: serviceData.appTemplateVersion,
|
||||
latestVersion: serviceData.appTemplateVersion,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'available',
|
||||
appTemplateId: upgradeable.appTemplateId,
|
||||
currentVersion: upgradeable.currentVersion,
|
||||
latestVersion: upgradeable.latestVersion,
|
||||
targetVersion: upgradeable.latestVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private async getLifecycleState(serviceArg: Service): Promise<IHostedAppLifecycleState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
const appInstanceId = serviceData.hostedAppLifecycle?.appInstanceId || serviceData.environment?.SERVEZONE_APP_INSTANCE_ID;
|
||||
const state: IHostedAppLifecycleState = {
|
||||
...(serviceData.hostedAppLifecycle || ({} as IHostedAppLifecycleState)),
|
||||
appInstanceId: appInstanceId || '',
|
||||
hostType: 'cloudly',
|
||||
appName: serviceData.hostedAppLifecycle?.appName || serviceData.name,
|
||||
publicUrl: serviceData.hostedAppLifecycle?.publicUrl || (serviceData.domains?.[0]?.name ? `https://${serviceData.domains[0].name}` : undefined),
|
||||
upgradeState: await this.getUpgradeState(serviceArg),
|
||||
};
|
||||
serviceData.hostedAppLifecycle = state;
|
||||
serviceArg.data = serviceData;
|
||||
await serviceArg.save();
|
||||
return state;
|
||||
}
|
||||
|
||||
private async updateLifecycleState(serviceArg: Service, stateArg: IHostedAppLifecycleState): Promise<IHostedAppLifecycleState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
serviceData.hostedAppLifecycle = stateArg;
|
||||
serviceArg.data = serviceData;
|
||||
await serviceArg.save();
|
||||
return await this.getLifecycleState(serviceArg);
|
||||
}
|
||||
|
||||
private registerHandlers() {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_ReportLifecycleState>(
|
||||
'hostedAppReportLifecycleState',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
...dataArg.report,
|
||||
appInstanceId: existingState.appInstanceId,
|
||||
hostType: 'cloudly',
|
||||
reportedAt: Date.now(),
|
||||
});
|
||||
return { state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetLifecycleState>(
|
||||
'hostedAppGetLifecycleState',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
return { state: await this.getLifecycleState(service) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||
'hostedAppRequestBootstrapAction',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const now = Date.now();
|
||||
const action = {
|
||||
...dataArg.action,
|
||||
id: dataArg.action.id || plugins.smartunique.shortId(12),
|
||||
status: 'ready' as const,
|
||||
label: dataArg.action.label || 'Initial setup',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
runtimeStatus: 'setupRequired',
|
||||
bootstrapAction: action,
|
||||
reportedAt: now,
|
||||
});
|
||||
return { action, state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||
'hostedAppCompleteBootstrapAction',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const now = Date.now();
|
||||
const bootstrapAction = existingState.bootstrapAction
|
||||
? {
|
||||
...existingState.bootstrapAction,
|
||||
id: dataArg.actionId || existingState.bootstrapAction.id,
|
||||
status: 'completed' as const,
|
||||
message: dataArg.message || existingState.bootstrapAction.message,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
}
|
||||
: undefined;
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
runtimeStatus: existingState.runtimeStatus === 'setupRequired' ? 'running' : existingState.runtimeStatus,
|
||||
bootstrapAction,
|
||||
reportedAt: now,
|
||||
});
|
||||
return { state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
|
||||
'hostedAppStartManagedUpgrade',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const upgradeState = await this.getUpgradeState(service);
|
||||
const targetVersion = dataArg.targetVersion || upgradeState.targetVersion || upgradeState.latestVersion;
|
||||
if (!targetVersion) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No managed upgrade target is available');
|
||||
}
|
||||
const operation = await this.cloudlyRef.appStoreManager.startHostedAppUpgrade(service.id, targetVersion);
|
||||
const nextUpgradeState: IHostedAppUpgradeState = {
|
||||
status: 'running',
|
||||
appTemplateId: operation.appTemplateId,
|
||||
currentVersion: operation.fromVersion,
|
||||
targetVersion: operation.targetVersion,
|
||||
operationId: operation.id,
|
||||
warnings: operation.warnings,
|
||||
startedAt: operation.startedAt,
|
||||
updatedAt: operation.updatedAt,
|
||||
};
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
upgradeState: nextUpgradeState,
|
||||
reportedAt: Date.now(),
|
||||
});
|
||||
return { upgradeState: nextUpgradeState, state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
|
||||
'hostedAppGetManagedUpgradeStatus',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
return { upgradeState: await this.getUpgradeState(service) };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,18 @@ export class Image extends plugins.smartdata.SmartDataDbDoc<
|
||||
) {
|
||||
const image = new Image();
|
||||
image.id = await this.getNewId();
|
||||
console.log(imageDataArg);
|
||||
Object.assign(image, {
|
||||
data: {
|
||||
name: imageDataArg.name,
|
||||
description: imageDataArg.description,
|
||||
location: imageDataArg.location || {
|
||||
internal: true,
|
||||
externalRegistryId: '',
|
||||
externalImageTag: '',
|
||||
},
|
||||
versions: [],
|
||||
},
|
||||
});
|
||||
console.log((Image as any).saveableProperties);
|
||||
await image.save();
|
||||
return image;
|
||||
}
|
||||
|
||||
@@ -116,12 +116,15 @@ export class ImageManager {
|
||||
await refImage.save();
|
||||
const imagePushStream = reqArg.imageStream;
|
||||
(async () => {
|
||||
const archiveHash = plugins.crypto.createHash('sha256');
|
||||
let archiveSize = 0;
|
||||
const smartWebDuplex = new plugins.smartstream.webstream.WebDuplexStream<
|
||||
Uint8Array,
|
||||
Uint8Array
|
||||
>({
|
||||
writeFunction: async (chunkArg, toolsArg) => {
|
||||
console.log(chunkArg);
|
||||
archiveSize += chunkArg.byteLength;
|
||||
archiveHash.update(chunkArg);
|
||||
return chunkArg;
|
||||
},
|
||||
});
|
||||
@@ -130,6 +133,17 @@ export class ImageManager {
|
||||
storagePath,
|
||||
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) => {
|
||||
console.error(`failed to store image ${refImage.id}:${imageVersion}`, error);
|
||||
});
|
||||
|
||||
+4
-2
@@ -8,9 +8,10 @@ export { path, crypto, stream, fsPromises };
|
||||
|
||||
// @apiglobal scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest, typedsocket };
|
||||
export { typedrequest, typedrequestInterfaces, typedsocket };
|
||||
|
||||
// @apiclient.xyz scope
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
@@ -86,5 +87,6 @@ export {
|
||||
|
||||
// @servezone scope
|
||||
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
||||
import * as servezoneAppstore from '@serve.zone/appstore';
|
||||
|
||||
export { servezoneInterfaces };
|
||||
export { servezoneAppstore, servezoneInterfaces };
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.9.0',
|
||||
version: '6.3.1',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
+211
-1
@@ -21,7 +21,7 @@ export interface IDataState {
|
||||
secretBundles?: plugins.interfaces.data.ISecretBundle[];
|
||||
clusters?: plugins.interfaces.data.ICluster[];
|
||||
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
|
||||
images?: any[];
|
||||
images?: plugins.interfaces.data.IImage[];
|
||||
services?: plugins.interfaces.data.IService[];
|
||||
deployments?: plugins.interfaces.data.IDeployment[];
|
||||
domains?: plugins.interfaces.data.IDomain[];
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
@@ -42,6 +43,10 @@ export class CloudlyViewDeployments extends DeesElement {
|
||||
.health-unknown { background: #f5f5f5; color: #666; }
|
||||
.resource-usage { display: flex; gap: 12px; font-size: 0.9em; color: #888; }
|
||||
.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=${[
|
||||
{
|
||||
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',
|
||||
iconName: 'plus',
|
||||
@@ -212,6 +225,49 @@ export class CloudlyViewDeployments extends DeesElement {
|
||||
></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 {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import * as plugins from '../../../plugins.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';
|
||||
|
||||
@@ -10,39 +18,84 @@ 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();
|
||||
appstate.dataState
|
||||
const subscription = appstate.dataState
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((dataArg) => {
|
||||
this.data = dataArg;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
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); }
|
||||
.detail-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
|
||||
.spaced-table, .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`
|
||||
<cloudly-sectionheading>Images</cloudly-sectionheading>
|
||||
<dees-table
|
||||
heading1="Images"
|
||||
heading2="an image is needed for running a service"
|
||||
.data=${this.data.images}
|
||||
.data=${this.data.images || []}
|
||||
.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=${[
|
||||
{
|
||||
name: 'create Image',
|
||||
name: 'Create Image',
|
||||
type: ['header', 'footer'],
|
||||
iconName: 'plus',
|
||||
actionFunc: async () => {
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'create new Image',
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Create Image',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<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'],
|
||||
iconName: 'lucide:SquarePen',
|
||||
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
const environmentsArray: Array<plugins.interfaces.data.ISecretGroup['data']['environments'][any] & { environment: string; }> = [];
|
||||
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); } },
|
||||
],
|
||||
});
|
||||
iconName: 'lucide:Eye',
|
||||
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.IImage>) => {
|
||||
this.openImageDetail(dataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'history',
|
||||
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',
|
||||
name: 'Delete',
|
||||
iconName: 'trash',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
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}"`,
|
||||
content: html`
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
${servicesUsingImage.length ? html`
|
||||
<dees-table
|
||||
class="spaced-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="detail-card spaced-card">
|
||||
<div class="section-title">Services Using This Image</div>
|
||||
<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 {
|
||||
|
||||
@@ -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 = [
|
||||
@@ -59,7 +78,6 @@ export class CloudlyViewServices extends DeesElement {
|
||||
.category-distributed { background: #9c27b0; 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; }
|
||||
.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-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; }
|
||||
@@ -126,7 +144,7 @@ export class CloudlyViewServices extends DeesElement {
|
||||
.data=${this.data.services || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.IService) => {
|
||||
return {
|
||||
Name: html`<button class="link-button" @click=${() => this.openServiceDetail(itemArg)}>${itemArg.data.name}</button>`,
|
||||
Name: itemArg.data.name,
|
||||
Description: itemArg.data.description,
|
||||
Category: this.getCategoryBadgeHtml(itemArg.data.serviceCategory || 'workload'),
|
||||
'Deployment Strategy': html`
|
||||
@@ -144,7 +162,7 @@ export class CloudlyViewServices extends DeesElement {
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'eye',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openServiceDetail(actionDataArg.item as plugins.interfaces.data.IService);
|
||||
},
|
||||
@@ -302,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>
|
||||
@@ -313,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>
|
||||
` : ''}
|
||||
|
||||
@@ -342,58 +368,65 @@ export class CloudlyViewServices extends DeesElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px;">
|
||||
<div>
|
||||
<div class="section-title">Deployments</div>
|
||||
<div class="detail-subtitle">Container-level runtime actions happen here.</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => this.loadDeploymentsForService(service)}>Refresh</button>
|
||||
</div>
|
||||
${this.deploymentsLoading ? html`<div class="detail-subtitle">Loading deployments...</div>` : html`
|
||||
<dees-table
|
||||
.heading1=${'Live Deployments'}
|
||||
.heading2=${this.serviceDeployments.length ? 'Docker Swarm tasks reported by connected Coreflows' : 'No live deployments reported'}
|
||||
.data=${this.serviceDeployments}
|
||||
.displayFunction=${(deploymentArg: any) => ({
|
||||
Status: this.renderStatusBadge(deploymentArg.status),
|
||||
Node: deploymentArg.nodeName || deploymentArg.nodeId || '-',
|
||||
Slot: deploymentArg.slot || '-',
|
||||
Version: deploymentArg.version || service.data.imageVersion,
|
||||
Container: deploymentArg.containerId ? deploymentArg.containerId.slice(0, 12) : '-',
|
||||
CPU: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}%` : '-',
|
||||
Memory: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.memoryUsedMB} MB` : '-',
|
||||
Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Open IDE',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openDeploymentWorkspace(actionDataArg.item);
|
||||
},
|
||||
${this.deploymentsLoading ? html`<div class="detail-subtitle">Loading deployments...</div>` : html`
|
||||
<dees-table
|
||||
.heading1=${'Live Deployments'}
|
||||
.heading2=${this.serviceDeployments.length ? 'Docker Swarm tasks reported by connected Coreflows' : 'No live deployments reported'}
|
||||
.data=${this.serviceDeployments}
|
||||
.displayFunction=${(deploymentArg: any) => ({
|
||||
Status: this.renderStatusBadge(deploymentArg.status),
|
||||
Node: deploymentArg.nodeName || deploymentArg.nodeId || '-',
|
||||
Slot: deploymentArg.slot || '-',
|
||||
Version: deploymentArg.version || service.data.imageVersion,
|
||||
Container: deploymentArg.containerId ? deploymentArg.containerId.slice(0, 12) : '-',
|
||||
CPU: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}%` : '-',
|
||||
Memory: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.memoryUsedMB} MB` : '-',
|
||||
Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
await this.loadDeploymentsForService(service);
|
||||
},
|
||||
{
|
||||
name: 'Restart',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.restartDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'lucide:Eye',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.showDeploymentDetailsModal(actionDataArg.item);
|
||||
},
|
||||
{
|
||||
name: 'Kill Container',
|
||||
iconName: 'skull',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.confirmKillDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Open IDE',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openDeploymentWorkspace(actionDataArg.item);
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`}
|
||||
</div>
|
||||
},
|
||||
{
|
||||
name: 'Restart',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.restartDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Kill Container',
|
||||
iconName: 'skull',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.confirmKillDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`}
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-card">
|
||||
@@ -440,10 +473,89 @@ 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>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
this.selectedService = serviceArg;
|
||||
this.serviceDeployments = [];
|
||||
@@ -472,13 +584,72 @@ export class CloudlyViewServices extends DeesElement {
|
||||
|
||||
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
|
||||
try {
|
||||
const response = await this.fireTypedRequest('getUpgradeableServices', {}) 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) {
|
||||
|
||||
@@ -266,32 +266,30 @@ export class CloudlyViewTasks extends DeesElement {
|
||||
|
||||
<cloudly-sectionheading>Execution History</cloudly-sectionheading>
|
||||
|
||||
<dees-panel .title=${'Recent Executions'} .subtitle=${'History of task runs and their outcomes'} .variant=${'outline'}>
|
||||
<dees-table
|
||||
.heading1=${'Task Executions'}
|
||||
.heading2=${'History of task runs and their outcomes'}
|
||||
.data=${this.data.taskExecutions || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
return {
|
||||
Task: itemArg.data.taskName,
|
||||
Status: html`<span class="status-badge status-${itemArg.data.status}">${itemArg.data.status}</span>`,
|
||||
'Started At': formatDate(itemArg.data.startedAt),
|
||||
Duration: itemArg.data.duration ? formatDuration(itemArg.data.duration) : '-',
|
||||
'Triggered By': itemArg.data.triggeredBy,
|
||||
Logs: itemArg.data.logs?.length || 0,
|
||||
} as any;
|
||||
}}
|
||||
.actionFunction=${async (itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
const actions: any[] = [
|
||||
{ name: 'View Details', iconName: 'lucide:Eye', type: ['inRow'], actionFunc: async () => { this.selectedExecution = itemArg; } }
|
||||
];
|
||||
if (itemArg.data.status === 'running') {
|
||||
actions.push({ name: 'Cancel', iconName: 'lucide:SquareX', type: ['inRow'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: itemArg.id }); await this.loadExecutionsWithFilter(); } });
|
||||
}
|
||||
return actions;
|
||||
}}
|
||||
></dees-table>
|
||||
</dees-panel>
|
||||
<dees-table
|
||||
.heading1=${'Task Executions'}
|
||||
.heading2=${'History of task runs and their outcomes'}
|
||||
.data=${this.data.taskExecutions || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
return {
|
||||
Task: itemArg.data.taskName,
|
||||
Status: html`<span class="status-badge status-${itemArg.data.status}">${itemArg.data.status}</span>`,
|
||||
'Started At': formatDate(itemArg.data.startedAt),
|
||||
Duration: itemArg.data.duration ? formatDuration(itemArg.data.duration) : '-',
|
||||
'Triggered By': itemArg.data.triggeredBy,
|
||||
Logs: itemArg.data.logs?.length || 0,
|
||||
} as any;
|
||||
}}
|
||||
.actionFunction=${async (itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
const actions: any[] = [
|
||||
{ name: 'View Details', iconName: 'lucide:Eye', type: ['inRow'], actionFunc: async () => { this.selectedExecution = itemArg; } }
|
||||
];
|
||||
if (itemArg.data.status === 'running') {
|
||||
actions.push({ name: 'Cancel', iconName: 'lucide:SquareX', type: ['inRow'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: itemArg.id }); await this.loadExecutionsWithFilter(); } });
|
||||
}
|
||||
return actions;
|
||||
}}
|
||||
></dees-table>
|
||||
|
||||
${this.selectedExecution ? html`
|
||||
<cloudly-sectionheading>Execution Details</cloudly-sectionheading>
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user