Compare commits

...

9 Commits

Author SHA1 Message Date
jkunz bfda4b4ca1 v6.1.0
Docker (tags) / release (push) Failing after 1s
2026-05-26 09:58:01 +00:00
jkunz a9d9ea585c chore(changelog): remove duplicate pending entry 2026-05-26 09:54:59 +00:00
jkunz 56a62e7008 feat(images): improve image operations UI and record archive metadata 2026-05-26 09:54:26 +00:00
jkunz 05560c9db9 v6.0.0
Docker (tags) / release (push) Failing after 1s
2026-05-25 03:03:25 +00:00
jkunz 50e69b095c feat(appstore): use shared resolver 2026-05-25 03:03:03 +00:00
jkunz d5445609a0 v5.9.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 13:15:16 +00:00
jkunz fd7c7b4313 fix(cloudly): invalidate expired dashboard sessions 2026-05-24 13:14:49 +00:00
jkunz 057af996aa chore(cloudly): consume released Spark interfaces 2026-05-24 12:54:09 +00:00
jkunz 6565c44c29 feat(cloudly): accept Spark node heartbeats 2026-05-24 12:47:15 +00:00
23 changed files with 1095 additions and 735 deletions
+37
View File
@@ -3,6 +3,43 @@
## Pending
## 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
- accept Spark node heartbeats
- Adds a Spark heartbeat HTTP endpoint for cluster nodes
- Stores Spark metrics and runtime info on cluster node records
- Extends jump onboarding with per-node Spark telemetry credentials
### Fixes
- invalidate expired dashboard sessions and return admins to login
### Maintenance
- refresh release tooling dependencies
- update `@serve.zone/interfaces` to the Spark telemetry contract release
## 2026-05-24 - 5.8.2
- update Cloudly to consume the released Jump Code API client
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/cloudly",
"version": "5.8.2",
"version": "6.1.0",
"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",
@@ -23,15 +23,15 @@
"docs": "tsdoc aidoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdoc": "^2.0.5",
"@git.zone/tsdocker": "^2.2.6",
"@git.zone/tsdocker": "^2.3.0",
"@git.zone/tspublish": "^1.11.7",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@push.rocks/smartnetwork": "^4.7.1",
"@types/node": "^25.6.2"
"@types/node": "^25.9.0"
},
"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.9.0",
"@serve.zone/appstore": "^0.2.0",
"@serve.zone/interfaces": "^6.0.0",
"@tsclass/tsclass": "^9.5.1"
},
"files": [
+67 -493
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,5 +1,6 @@
minimumReleaseAgeExclude:
- '@serve.zone/api'
- '@serve.zone/appstore'
- '@serve.zone/interfaces'
allowBuilds:
+129
View File
@@ -0,0 +1,129 @@
{
"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": "SERVEZONE_ADMINACCOUNT",
"value": "",
"description": "Initial admin account in username:password format. Only used when Cloudly has no human users yet.",
"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": "1.24.2",
"backupBeforeUpgrade": true,
"healthCheck": {
"path": "/status",
"port": 80,
"expectedStatus": 200
}
}
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.8.2',
version: '6.1.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+5 -5
View File
@@ -34,7 +34,7 @@ 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 { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
/**
@@ -81,7 +81,7 @@ export class Cloudly {
public nodeManager: CloudlyNodeManager;
public baremetalManager: CloudlyBaremetalManager;
public baseOsManager: CloudlyBaseOsManager;
public appCatalogManager: CloudlyAppCatalogManager;
public appStoreManager: CloudlyAppStoreManager;
public jumpManager: CloudlyJumpManager;
private readyDeferred = new plugins.smartpromise.Deferred();
@@ -119,7 +119,7 @@ export class Cloudly {
this.backupManager = new CloudlyBackupManager(this);
this.baseOsManager = new CloudlyBaseOsManager(this);
this.secretManager = new CloudlySecretManager(this);
this.appCatalogManager = new CloudlyAppCatalogManager(this);
this.appStoreManager = new CloudlyAppStoreManager(this);
this.nodeManager = new CloudlyNodeManager(this);
this.baremetalManager = new CloudlyBaremetalManager(this);
this.jumpManager = new CloudlyJumpManager(this);
@@ -151,7 +151,7 @@ export class Cloudly {
await this.taskManager.init();
await this.backupManager.start();
await this.baseOsManager.start();
await this.appCatalogManager.start();
await this.appStoreManager.start();
await this.registryManager.start();
await this.domainManager.init();
@@ -186,7 +186,7 @@ export class Cloudly {
await this.backupManager.stop();
await this.baseOsManager.stop();
await this.registryManager.stop();
await this.appCatalogManager.stop();
await this.appStoreManager.stop();
await this.externalRegistryManager.stop();
}
}
+5
View File
@@ -120,6 +120,11 @@ export class CloudlyServer {
'POST',
async (ctx) => this.cloudlyRef.baseOsManager.handleHeartbeatHttpRequest(ctx),
);
this.typedServer.addRoute(
'/spark/v1/nodes/heartbeat',
'POST',
async (ctx) => this.cloudlyRef.nodeManager.handleSparkHeartbeatHttpRequest(ctx),
);
this.typedServer.addRoute(
'/baseos/v1/images/:buildId/download',
'GET',
@@ -5,19 +5,18 @@ 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;
type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex;
type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig;
type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest;
type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;
export class CloudlyAppCatalogManager {
export class CloudlyAppStoreManager {
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;
private readonly appStoreResolver = new plugins.servezoneAppstore.AppStoreResolver({
baseUrl: process.env.APPSTORE_URL || 'https://code.foss.global/serve.zone/appstore/raw/branch/main',
});
constructor(private cloudlyRef: Cloudly) {
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
@@ -28,88 +27,74 @@ export class CloudlyAppCatalogManager {
public async stop() {}
private registerHandlers() {
const addCatalogListHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreTemplates>(
'getAppStoreTemplates',
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) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreConfig>(
'getAppStoreConfig',
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) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_InstallAppStoreApp>(
'installAppStoreApp',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
const service = await this.installApp(dataArg.install || dataArg);
const service = await this.installApp(dataArg.install);
return { service: await service.createSavableObject() };
}),
);
};
addInstallHandler('installAppCatalogApp');
addInstallHandler('installAppTemplate');
},
),
);
const addUpgradeableHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetUpgradeableAppStoreServices>(
'getUpgradeableAppStoreServices',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { services: await this.getUpgradeableServices() };
}),
);
};
addUpgradeableHandler('getUpgradeableAppCatalogServices');
addUpgradeableHandler('getUpgradeableServices');
return { services: await this.getUpgradeableAppStoreServices() };
},
),
);
}
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 getAppStore(): Promise<IAppStoreIndex> {
return await this.appStoreResolver.getAppStoreIndex();
}
public async getApps(): Promise<ICatalogApp[]> {
return (await this.getCatalog()).apps;
public async getApps(): Promise<IAppStoreApp[]> {
return await this.appStoreResolver.getApps();
}
public async getAppMeta(appIdArg: string): Promise<IAppMeta> {
return await this.fetchJson(`apps/${appIdArg}/app.json`) as IAppMeta;
public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
return await this.appStoreResolver.getAppMeta(appIdArg);
}
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppVersionConfig> {
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppStoreVersionConfig> {
if (!versionArg) {
versionArg = (await this.getAppMeta(appIdArg)).latestVersion;
}
return await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as IAppVersionConfig;
return await this.appStoreResolver.getAppVersionConfig(appIdArg, versionArg);
}
public async getUpgradeableServices(): Promise<IUpgradeableCatalogService[]> {
const catalog = await this.getCatalog();
public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
const appStore = await this.getAppStore();
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const upgradeableServices: IUpgradeableCatalogService[] = [];
const upgradeableServices: IUpgradeableAppStoreService[] = [];
for (const service of services) {
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
@@ -119,15 +104,15 @@ export class CloudlyAppCatalogManager {
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
continue;
}
const catalogApp = catalog.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
if (!catalogApp || catalogApp.latestVersion === serviceData.appTemplateVersion) {
const appStoreApp = appStore.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) {
continue;
}
upgradeableServices.push({
serviceName: serviceData.name,
appTemplateId: serviceData.appTemplateId,
currentVersion: serviceData.appTemplateVersion,
latestVersion: catalogApp.latestVersion,
latestVersion: appStoreApp.latestVersion,
hasMigration: false,
});
}
@@ -135,18 +120,19 @@ export class CloudlyAppCatalogManager {
return upgradeableServices;
}
public async installApp(optionsArg: IInstallOptions): Promise<Service> {
public async installApp(optionsArg: IAppStoreInstallOptions): Promise<Service> {
const appMeta = await this.getAppMeta(optionsArg.appId);
const version = optionsArg.version || appMeta.latestVersion;
const config = await this.getAppVersionConfig(optionsArg.appId, version);
const appStoreVersion = config.appStoreVersion || version;
const webPort = optionsArg.port || config.port;
this.assertSupportedPlatformRequirements(config);
const envVars = this.getCatalogEnvVars(config, optionsArg.envVars || {});
const envVars = this.getAppStoreEnvVars(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 image = await this.createAppStoreImage(optionsArg.serviceName, config.image, appMeta.description);
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
const serviceData = {
name: optionsArg.serviceName,
@@ -155,7 +141,7 @@ export class CloudlyAppCatalogManager {
imageVersion: this.getImageTag(config.image),
deployOnPush: false,
appTemplateId: optionsArg.appId,
appTemplateVersion: version,
appTemplateVersion: appStoreVersion,
environment: envVars,
secretBundleId: secretBundle.id,
additionalSecretBundleIds: [],
@@ -181,11 +167,11 @@ export class CloudlyAppCatalogManager {
return service;
}
private async createCatalogImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
private async createAppStoreImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
const image = new Image();
image.id = await Image.getNewId();
image.data = {
name: `${serviceNameArg}-catalog-image`,
name: `${serviceNameArg}-appstore-image`,
description: descriptionArg,
location: {
internal: false,
@@ -210,8 +196,8 @@ export class CloudlyAppCatalogManager {
const secretBundle = new SecretBundle();
secretBundle.id = plugins.smartunique.shortId(8);
secretBundle.data = {
name: `${serviceNameArg} catalog secrets`,
description: `Generated catalog secret bundle for ${serviceNameArg}`,
name: `${serviceNameArg} appstore secrets`,
description: `Generated appstore secret bundle for ${serviceNameArg}`,
type: 'service',
includedSecretGroupIds: [],
includedTags: [],
@@ -222,7 +208,7 @@ export class CloudlyAppCatalogManager {
return secretBundle;
}
private async createPlatformBindings(serviceArg: Service, configArg: IAppVersionConfig) {
private async createPlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
const requirements = configArg.platformRequirements || {};
if (requirements.mongodb) {
await PlatformBinding.upsertBinding({
@@ -244,7 +230,7 @@ export class CloudlyAppCatalogManager {
}
}
private normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []) {
private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) {
return volumesArg.map((volumeArg) => {
if (typeof volumeArg === 'string') {
return { mountPath: volumeArg };
@@ -253,7 +239,7 @@ export class CloudlyAppCatalogManager {
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
}
private getCatalogEnvVars(configArg: IAppVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
const envVars: Record<string, string> = {};
const missingRequiredEnvVars: string[] = [];
for (const envVar of configArg.envVars || []) {
@@ -274,12 +260,12 @@ export class CloudlyAppCatalogManager {
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
}
private assertSupportedPlatformRequirements(configArg: IAppVersionConfig) {
private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) {
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(', ')}`);
throw new Error(`Cloudly App Store install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
}
}
@@ -294,13 +280,4 @@ export class CloudlyAppCatalogManager {
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();
}
}
+48 -29
View File
@@ -11,6 +11,17 @@ export interface IJwtData {
expiresAt: number;
}
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
identity: plugins.servezoneInterfaces.data.IIdentity;
};
response: {
valid: boolean;
reason?: string;
};
}
export class CloudlyAuthManager {
cloudlyRef: Cloudly;
public get db() {
@@ -82,6 +93,16 @@ export class CloudlyAuthManager {
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<IReq_AdminValidateIdentity>('adminValidateIdentity', async (dataArg) => {
const valid = await this.adminIdentityGuard.exec(dataArg).catch(() => false);
return {
valid,
reason: valid ? undefined : 'identity is not valid',
};
}),
);
}
private async bootstrapInitialAdmin() {
@@ -126,28 +147,22 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const expired = jwtData.expiresAt < Date.now();
plugins.smartexpect
.expect(jwtData.status)
.setFailMessage('user not logged in')
.toEqual('loggedIn');
plugins.smartexpect.expect(expired).setFailMessage(`jwt expired`).toBeFalse();
plugins.smartexpect
.expect(dataArg.identity.expiresAt)
.setFailMessage(
`expiresAt >>identity valid until:${dataArg.identity.expiresAt}, but jwt says: ${jwtData.expiresAt}<< has been tampered with`,
)
.toEqual(jwtData.expiresAt);
plugins.smartexpect
.expect(dataArg.identity.userId)
.setFailMessage('userId has been tampered with')
.toEqual(jwtData.userId);
if (expired) {
throw new Error('identity is expired');
try {
const jwt = dataArg.identity?.jwt;
if (!jwt) {
return false;
}
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const expired = jwtData.expiresAt < Date.now();
return (
jwtData.status === 'loggedIn' &&
!expired &&
dataArg.identity.expiresAt === jwtData.expiresAt &&
dataArg.identity.userId === jwtData.userId
);
} catch {
return false;
}
return true;
},
{
failedHint: 'identity is not valid.',
@@ -159,16 +174,17 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]);
const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId });
const isAdminBool = user.data.role === 'admin';
console.log(`user is admin: ${isAdminBool}`);
return isAdminBool;
return user?.data.role === 'admin';
},
{
failedHint: 'user is not admin.',
failedHint: 'identity is not valid or user is not admin.',
name: 'adminIdentityGuard',
},
);
@@ -177,14 +193,17 @@ export class CloudlyAuthManager {
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [this.validIdentityGuard]);
const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId });
return user.data.role === 'admin' || user.data.role === 'cluster';
return user?.data.role === 'admin' || user?.data.role === 'cluster';
},
{
failedHint: 'user is not admin or cluster.',
failedHint: 'identity is not valid or user is not admin or cluster.',
name: 'adminOrClusterIdentityGuard',
},
);
+5 -2
View File
@@ -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;
}
+15 -1
View File
@@ -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);
});
+7 -1
View File
@@ -15,6 +15,7 @@ interface IClaimJumpCodeResponse {
accepted: boolean;
message?: string;
nodeId?: string;
sparkNodeToken?: string;
cloudlyUrl?: string;
coreflowJumpCode?: string;
}
@@ -148,8 +149,10 @@ export class CloudlyJumpManager {
const nodeId = plugins.smartunique.shortId(8);
const now = Date.now();
const sparkNodeToken = await this.cloudlyRef.authManager.createNewSecureToken();
const node = new this.cloudlyRef.nodeManager.CClusterNode();
node.id = nodeId;
node.sparkNodeTokenHash = this.hashSecret(sparkNodeToken);
node.data = {
clusterId: cluster.id,
nodeType: jumpCodeDoc.data.nodeType,
@@ -178,6 +181,7 @@ export class CloudlyJumpManager {
return {
accepted: true,
nodeId: node.id,
sparkNodeToken,
cloudlyUrl: cluster.data.cloudlyUrl || `${this.getPublicCloudlyUrl()}/`,
coreflowJumpCode,
};
@@ -292,8 +296,10 @@ CLAIM_RESPONSE="$(curl -fsSL -X POST "\${CLAIM_URL}" -H 'content-type: applicati
export CLAIM_RESPONSE
CLOUDLY_URL="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.accepted) { throw new Error(data.message || "Cloudly rejected jump code"); } process.stdout.write(data.cloudlyUrl);')"
COREFLOW_JUMPCODE="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.coreflowJumpCode) { throw new Error("Cloudly did not return a Coreflow jump code"); } process.stdout.write(data.coreflowJumpCode);')"
SPARK_NODE_ID="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.nodeId) { throw new Error("Cloudly did not return a Spark node id"); } process.stdout.write(data.nodeId);')"
SPARK_NODE_TOKEN="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.sparkNodeToken) { throw new Error("Cloudly did not return a Spark node token"); } process.stdout.write(data.sparkNodeToken);')"
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}"
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}" --nodeId="\${SPARK_NODE_ID}" --nodeToken="\${SPARK_NODE_TOKEN}"
echo "Cloudly jump completed. This system is now connected."
`;
+17
View File
@@ -39,6 +39,9 @@ export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IClusterNode['data'];
@plugins.smartdata.svDb()
public sparkNodeTokenHash?: string;
constructor() {
super();
}
@@ -54,6 +57,20 @@ export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
await this.save();
}
public async updateSparkHeartbeat(
metricsArg: plugins.servezoneInterfaces.data.IClusterNodeMetrics,
runtimeInfoArg: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo,
) {
this.data.metrics = metricsArg;
this.data.sparkRuntimeInfo = runtimeInfoArg;
this.data.status = 'online';
this.data.lastHealthCheck = Date.now();
if (typeof runtimeInfoArg.swarmNodeId === 'string' && runtimeInfoArg.swarmNodeId) {
this.data.swarmNodeId = runtimeInfoArg.swarmNodeId;
}
await this.save();
}
public async updateStatus(status: plugins.servezoneInterfaces.data.IClusterNode['data']['status']) {
this.data.status = status;
await this.save();
+123
View File
@@ -4,6 +4,18 @@ import { Cluster } from '../manager.cluster/classes.cluster.js';
import { ClusterNode } from './classes.clusternode.js';
import { CurlFresh } from './classes.curlfresh.js';
interface ISparkHeartbeatRequest {
nodeId?: string;
nodeToken?: string;
metrics?: plugins.servezoneInterfaces.data.IClusterNodeMetrics;
runtimeInfo?: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo;
}
interface ISparkHeartbeatResponse {
accepted: boolean;
message?: string;
}
export class CloudlyNodeManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
@@ -51,6 +63,69 @@ export class CloudlyNodeManager {
public async stop() {}
public async handleSparkHeartbeatHttpRequest(
ctxArg: plugins.typedserver.IRequestContext,
): Promise<Response> {
try {
const requestData = await this.readJsonBody<ISparkHeartbeatRequest>(ctxArg);
const response = await this.acceptSparkHeartbeat(requestData);
return this.createJsonResponse(200, response);
} catch (error) {
return this.createJsonResponse(400, {
accepted: false,
message: `Spark heartbeat failed: ${(error as Error).message}`,
} satisfies ISparkHeartbeatResponse);
}
}
public async acceptSparkHeartbeat(
requestDataArg: ISparkHeartbeatRequest,
): Promise<ISparkHeartbeatResponse> {
if (!requestDataArg.nodeId) {
return {
accepted: false,
message: 'Spark node id is missing',
};
}
if (!requestDataArg.nodeToken) {
return {
accepted: false,
message: 'Spark node token is missing',
};
}
if (!this.isSparkMetrics(requestDataArg.metrics)) {
return {
accepted: false,
message: 'Spark metrics are missing or invalid',
};
}
if (!this.isSparkRuntimeInfo(requestDataArg.runtimeInfo, requestDataArg.nodeId)) {
return {
accepted: false,
message: 'Spark runtime info is missing or invalid',
};
}
const node = await this.CClusterNode.getInstance({ id: requestDataArg.nodeId });
if (!node) {
return {
accepted: false,
message: 'Spark node is unknown',
};
}
if (node.sparkNodeTokenHash !== this.hashSecret(requestDataArg.nodeToken)) {
return {
accepted: false,
message: 'Spark node token is invalid',
};
}
await node.updateSparkHeartbeat(requestDataArg.metrics, requestDataArg.runtimeInfo);
return {
accepted: true,
};
}
/**
* creates the node infrastructure on hetzner
* ensures that there are exactly the resources that are needed
@@ -133,4 +208,52 @@ export class CloudlyNodeManager {
});
return results;
}
private isSparkMetrics(valueArg: unknown): valueArg is plugins.servezoneInterfaces.data.IClusterNodeMetrics {
if (!valueArg || typeof valueArg !== 'object') {
return false;
}
const metrics = valueArg as Partial<plugins.servezoneInterfaces.data.IClusterNodeMetrics>;
return typeof metrics.cpuUsagePercent === 'number'
&& typeof metrics.memoryUsedMB === 'number'
&& typeof metrics.memoryAvailableMB === 'number'
&& typeof metrics.diskUsedGB === 'number'
&& typeof metrics.diskAvailableGB === 'number'
&& typeof metrics.containerCount === 'number'
&& typeof metrics.timestamp === 'number';
}
private isSparkRuntimeInfo(
valueArg: unknown,
nodeIdArg: string,
): valueArg is plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo {
if (!valueArg || typeof valueArg !== 'object') {
return false;
}
const runtimeInfo = valueArg as Record<string, unknown>;
return runtimeInfo.runtime === 'spark'
&& runtimeInfo.nodeId === nodeIdArg
&& typeof runtimeInfo.checkedAt === 'number';
}
private hashSecret(secretArg: string) {
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
}
private async readJsonBody<T>(ctxArg: plugins.typedserver.IRequestContext): Promise<T> {
const bodyString = (await ctxArg.text()).trim();
return bodyString ? JSON.parse(bodyString) as T : {} as T;
}
private createJsonResponse(
statusCodeArg: number,
bodyArg: object,
): Response {
return new Response(JSON.stringify(bodyArg), {
status: statusCodeArg,
headers: {
'Content-Type': 'application/json',
},
});
}
}
+2 -1
View File
@@ -86,5 +86,6 @@ export {
// @servezone scope
import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as servezoneAppstore from '@serve.zone/appstore';
export { servezoneInterfaces };
export { servezoneAppstore, servezoneInterfaces };
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.8.2',
version: '6.1.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+164 -37
View File
@@ -16,6 +16,55 @@ export interface IUiState {
activeSubview: string | null;
}
export interface IDataState {
secretGroups?: plugins.interfaces.data.ISecretGroup[];
secretBundles?: plugins.interfaces.data.ISecretBundle[];
clusters?: plugins.interfaces.data.ICluster[];
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
images?: plugins.interfaces.data.IImage[];
services?: plugins.interfaces.data.IService[];
deployments?: plugins.interfaces.data.IDeployment[];
domains?: plugins.interfaces.data.IDomain[];
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
tasks?: any[];
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
mails?: any[];
logs?: any[];
s3?: any[];
dbs?: any[];
backups?: any[];
}
const emptyDataState: IDataState = {
secretGroups: [],
secretBundles: [],
clusters: [],
externalRegistries: [],
images: [],
services: [],
deployments: [],
domains: [],
dnsEntries: [],
tasks: [],
taskExecutions: [],
mails: [],
logs: [],
s3: [],
dbs: [],
backups: [],
};
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
identity: plugins.interfaces.data.IIdentity;
};
response: {
valid: boolean;
reason?: string;
};
}
const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'platform', 'runtime', 'registry', 'secrets', 'domains', 'storage', 'logs'];
@@ -49,7 +98,7 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
}
const newState = {
...currentState,
...(identity ? { identity } : {}),
identity,
};
try {
// Keep shared API client in sync and establish WS for modules using sockets
@@ -67,50 +116,19 @@ export const loginAction = loginStatePart.createAction<{ username: string; passw
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const currentState = statePartArg.getState() || { identity: null };
try {
apiClient.identity = null;
dataState.setState({ ...emptyDataState });
} catch {}
return {
...currentState,
identity: null,
};
});
export interface IDataState {
secretGroups?: plugins.interfaces.data.ISecretGroup[];
secretBundles?: plugins.interfaces.data.ISecretBundle[];
clusters?: plugins.interfaces.data.ICluster[];
externalRegistries?: plugins.interfaces.data.IExternalRegistry[];
images?: any[];
services?: plugins.interfaces.data.IService[];
deployments?: plugins.interfaces.data.IDeployment[];
domains?: plugins.interfaces.data.IDomain[];
dnsEntries?: plugins.interfaces.data.IDnsEntry[];
tasks?: any[];
taskExecutions?: plugins.interfaces.data.ITaskExecution[];
mails?: any[];
logs?: any[];
s3?: any[];
dbs?: any[];
backups?: any[];
}
export const dataState = await appstate.getStatePart<IDataState>(
'data',
{
secretGroups: [],
secretBundles: [],
clusters: [],
externalRegistries: [],
images: [],
services: [],
deployments: [],
domains: [],
dnsEntries: [],
tasks: [],
taskExecutions: [],
mails: [],
logs: [],
s3: [],
dbs: [],
backups: [],
},
{ ...emptyDataState },
'soft'
);
@@ -124,6 +142,115 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
}) as TCloudlyApiClientWithNullableIdentity;
let identityExpiryTimer: number | undefined;
let identityInvalidationRunning = false;
const getErrorText = (errorArg: unknown): string => {
if (!errorArg) return '';
if (typeof errorArg === 'string') return errorArg;
const errorLike = errorArg as { errorText?: string; message?: string; text?: string };
return errorLike.errorText || errorLike.message || errorLike.text || '';
};
const isAuthRejectionText = (errorTextArg: string): boolean => {
const errorText = errorTextArg.toLowerCase();
return [
'identity is not valid',
'jwt expired',
'identity is expired',
'user not logged in',
'has been tampered with',
'invalid jwt',
'invalid signature',
].some((textPart) => errorText.includes(textPart));
};
export const isIdentityExpired = (identityArg: plugins.interfaces.data.IIdentity | null | undefined): boolean => {
return typeof identityArg?.expiresAt === 'number' && identityArg.expiresAt <= Date.now();
};
export const invalidateIdentity = async (reasonArg = 'identity is not valid'): Promise<void> => {
if (identityInvalidationRunning) return;
identityInvalidationRunning = true;
try {
const currentLoginState = loginStatePart.getState() || { identity: null };
if (currentLoginState.identity) {
console.warn(`Cloudly session invalidated: ${reasonArg}`);
}
apiClient.identity = null;
try { await apiClient.typedsocketClient?.setTag('identity', null); } catch {}
loginStatePart.setState({
...currentLoginState,
identity: null,
});
dataState.setState({ ...emptyDataState });
} finally {
identityInvalidationRunning = false;
}
};
export const validateStoredIdentity = async (): Promise<boolean> => {
const identity = loginStatePart.getState()?.identity ?? null;
if (!identity) return false;
if (isIdentityExpired(identity)) {
await invalidateIdentity('identity expired');
return false;
}
const validateIdentityRequest = new plugins.typedrequest.TypedRequest<IReq_AdminValidateIdentity>(
'/typedrequest',
'adminValidateIdentity',
);
try {
const response = await validateIdentityRequest.fire({ identity });
if (!response?.valid) {
await invalidateIdentity(response?.reason || 'identity rejected by server');
return false;
}
} catch (error) {
const errorText = getErrorText(error);
if (isAuthRejectionText(errorText)) {
await invalidateIdentity(errorText);
return false;
}
console.warn('Could not validate stored identity:', error);
}
return !!loginStatePart.getState()?.identity;
};
const scheduleIdentityExpiryTimer = () => {
if (identityExpiryTimer) {
window.clearTimeout(identityExpiryTimer);
identityExpiryTimer = undefined;
}
const identity = loginStatePart.getState()?.identity ?? null;
if (!identity?.expiresAt) return;
const msUntilExpiry = identity.expiresAt - Date.now();
if (msUntilExpiry <= 0) {
void invalidateIdentity('identity expired');
return;
}
identityExpiryTimer = window.setTimeout(() => {
void invalidateIdentity('identity expired');
}, Math.min(msUntilExpiry, 2147483647));
};
plugins.typedrequest.TypedRouter.setGlobalHooks({
onIncomingResponse: (entryArg) => {
if (entryArg.error && isAuthRejectionText(entryArg.error)) {
void invalidateIdentity(entryArg.error);
}
},
});
loginStatePart.select((stateArg) => stateArg?.identity ?? null).subscribe(() => {
scheduleIdentityExpiryTimer();
});
scheduleIdentityExpiryTimer();
// Getting data
export const getAllDataAction = dataState.createAction(async (statePartArg) => {
let currentState = statePartArg.getState() || {};
+50 -3
View File
@@ -169,6 +169,17 @@ export class CloudlyDashboard extends DeesElement {
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
});
this.rxSubscriptions.push(uiSubscription);
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg?.identity ?? null)
.subscribe((identityArg) => {
const hadIdentity = !!this.identity;
this.identity = identityArg ?? null;
if (!identityArg && hadIdentity) {
void this.switchToLoginContent('Session expired. Please sign in again.');
}
});
this.rxSubscriptions.push(loginSubscription);
}
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
@@ -267,9 +278,16 @@ export class CloudlyDashboard extends DeesElement {
const domtools = await this.domtoolsPromise;
const loginState = appstate.loginStatePart.getState();
if (loginState?.identity) {
this.identity = loginState.identity;
const identityValid = await appstate.validateStoredIdentity();
const currentIdentity = appstate.loginStatePart.getState()?.identity ?? null;
if (!identityValid || !currentIdentity) {
await this.switchToLoginContent('Session expired. Please sign in again.');
return;
}
this.identity = currentIdentity;
try {
appstate.apiClient.identity = loginState.identity;
appstate.apiClient.identity = currentIdentity;
if (!appstate.apiClient['typedsocketClient']) {
await appstate.apiClient.start();
}
@@ -301,5 +319,34 @@ export class CloudlyDashboard extends DeesElement {
}
}
private async logout() {}
private async switchToLoginContent(statusMessageArg?: string) {
const simpleLogin = this.shadowRoot?.querySelector('dees-simple-login') as any;
if (!simpleLogin?.shadowRoot) return;
const loginDiv = simpleLogin.shadowRoot.querySelector('.login') as HTMLDivElement | null;
const loginContainerDiv = simpleLogin.shadowRoot.querySelector('.loginContainer') as HTMLDivElement | null;
const slotContainerDiv = simpleLogin.shadowRoot.querySelector('.slotContainer') as HTMLDivElement | null;
const form = simpleLogin.shadowRoot.querySelector('dees-form') as any;
if (loginDiv) {
loginDiv.style.opacity = '1';
loginDiv.style.transform = 'translateY(0px)';
}
if (loginContainerDiv) {
loginContainerDiv.style.pointerEvents = 'all';
}
if (slotContainerDiv) {
slotContainerDiv.style.opacity = '0';
slotContainerDiv.style.transform = 'translateY(20px)';
slotContainerDiv.style.pointerEvents = 'none';
}
if (form && statusMessageArg) {
form.setStatus('error', statusMessageArg);
}
}
private async logout() {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
await this.switchToLoginContent();
}
}
@@ -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 {
+233 -56
View File
@@ -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); }
.summary-card, .detail-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
.spaced-card { margin-top: 14px; }
.details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
.section-title { font-size: 14px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 10px; }
.kv-list { display: grid; gap: 8px; }
.kv-row { display: grid; grid-template-columns: 150px 1fr; gap: 10px; font-size: 13px; }
.kv-key { color: var(--ci-shade-4, #71717a); }
.kv-value { color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; }
.image-name { font-weight: 700; color: var(--ci-shade-7, #e4e4e7); }
.empty-state { color: var(--ci-shade-4, #71717a); font-size: 13px; padding: 12px 0; }
.source-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.source-upload { background: rgba(59, 130, 246, 0.16); color: #60a5fa; }
.source-registry { background: rgba(34, 197, 94, 0.16); color: #22c55e; }
.source-unknown { background: rgba(161, 161, 170, 0.16); color: #a1a1aa; }
dees-statsgrid { margin-bottom: 18px; }
@media (max-width: 900px) { .details-grid { grid-template-columns: 1fr; } .detail-header { flex-direction: column; } }
`,
];
public render() {
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>
<div class="detail-card">
<div class="section-title">Versions</div>
<dees-table
.heading1=${'Image Versions'}
.heading2=${versions.length ? 'Stored image versions and registry metadata' : 'No versions recorded'}
.data=${versions}
.displayFunction=${(versionArg: plugins.interfaces.data.IImage['data']['versions'][number]) => ({
Version: versionArg.versionString,
Source: this.renderSourceBadge(versionArg.source),
Size: this.formatBytes(versionArg.size),
Digest: versionArg.digest || '-',
Repository: versionArg.registryRepository || '-',
Tag: versionArg.registryTag || '-',
Storage: versionArg.storagePath || '-',
Created: this.formatDate(versionArg.createdAt),
})}
></dees-table>
</div>
<div class="detail-card spaced-card">
<div class="section-title">Services Using This Image</div>
${servicesUsingImage.length ? html`
<dees-table
.heading1=${'Service Usage'}
.heading2=${'Services currently configured with this image ID'}
.data=${servicesUsingImage}
.displayFunction=${(serviceArg: plugins.interfaces.data.IService) => ({
Name: serviceArg.data.name,
Version: serviceArg.data.imageVersion || '-',
Category: serviceArg.data.serviceCategory || 'workload',
Strategy: serviceArg.data.deploymentStrategy || 'custom',
Domains: serviceArg.data.domains?.map((domainArg) => domainArg.name).join(', ') || '-',
Deployments: serviceArg.data.deploymentIds?.length || 0,
})}
></dees-table>
` : html`<div class="empty-state">No services currently reference this image.</div>`}
</div>
<div class="details-grid">
<div class="detail-card">
<div class="section-title">Registry Source</div>
<div class="kv-list">
<div class="kv-row"><span class="kv-key">Image ID</span><span class="kv-value">${image.id}</span></div>
<div class="kv-row"><span class="kv-key">Name</span><span class="kv-value">${image.data.name}</span></div>
<div class="kv-row"><span class="kv-key">Description</span><span class="kv-value">${image.data.description || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Internal</span><span class="kv-value">${location?.internal === false ? 'no' : 'yes'}</span></div>
<div class="kv-row"><span class="kv-key">External Registry</span><span class="kv-value">${location?.externalRegistryId || '-'}</span></div>
<div class="kv-row"><span class="kv-key">External Tag</span><span class="kv-value">${location?.externalImageTag || '-'}</span></div>
<div class="kv-row"><span class="kv-key">External Ref</span><span class="kv-value">${location?.externalImageRef || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Latest Created</span><span class="kv-value">${this.formatDate(latestVersion?.createdAt)}</span></div>
</div>
</div>
<div class="detail-card">
<div class="section-title">Last Push</div>
<div class="kv-list">
<div class="kv-row"><span class="kv-key">Repository</span><span class="kv-value">${lastPushEvent?.repository || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Tag</span><span class="kv-value">${lastPushEvent?.tag || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Digest</span><span class="kv-value">${lastPushEvent?.digest || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Image URL</span><span class="kv-value">${lastPushEvent?.imageUrl || '-'}</span></div>
<div class="kv-row"><span class="kv-key">Pushed At</span><span class="kv-value">${this.formatDate(lastPushEvent?.pushedAt)}</span></div>
<div class="kv-row"><span class="kv-key">Actor</span><span class="kv-value">${lastPushEvent?.actorUserId || '-'}</span></div>
</div>
</div>
</div>
`;
}
private getActiveImage(): plugins.interfaces.data.IImage | null {
if (!this.selectedImage) {
return null;
}
return this.data.images?.find((imageArg) => imageArg.id === this.selectedImage!.id) || this.selectedImage;
}
private openImageDetail(imageArg: plugins.interfaces.data.IImage) {
this.selectedImage = imageArg;
this.currentView = 'detail';
}
private getImageTotalSize(imageArg: plugins.interfaces.data.IImage): number {
return (imageArg.data.versions || []).reduce((sumArg, versionArg) => sumArg + (versionArg.size || 0), 0);
}
private getSortedImageVersions(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IImage['data']['versions'] {
return [...(imageArg.data.versions || [])].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
}
private getLatestImageVersion(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IImage['data']['versions'][number] | undefined {
return this.getSortedImageVersions(imageArg)[0];
}
private getServicesUsingImage(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IService[] {
return (this.data.services || []).filter((serviceArg) => serviceArg.data.imageId === imageArg.id);
}
private getImageStatsTiles(imageArg: plugins.interfaces.data.IImage) {
const latestVersion = this.getLatestImageVersion(imageArg);
const totalSize = this.getImageTotalSize(imageArg);
const servicesUsingImage = this.getServicesUsingImage(imageArg);
return [
{ id: 'versions', title: 'Versions', value: imageArg.data.versions?.length || 0, type: 'number' as const, icon: 'lucide:Tags', description: 'Recorded image versions' },
{ id: 'size', title: 'Total Size', value: this.formatBytes(totalSize), type: 'text' as const, icon: 'lucide:HardDrive', description: 'Stored archive size' },
{ id: 'latest', title: 'Latest Version', value: latestVersion?.versionString || '-', type: 'text' as const, icon: 'lucide:GitBranch', description: this.formatDate(latestVersion?.createdAt) },
{ id: 'usage', title: 'Used By', value: servicesUsingImage.length, type: 'number' as const, icon: 'lucide:Layers', description: 'Configured services' },
];
}
private getLocationLabel(imageArg: plugins.interfaces.data.IImage): string {
const location = imageArg.data.location;
if (!location || location.internal) {
return 'Internal registry';
}
return location.externalImageRef || location.externalImageTag || 'External registry';
}
private formatBytes(sizeArg?: number): string {
if (!sizeArg) {
return sizeArg === 0 ? '0 B' : '-';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = sizeArg;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size = size / 1024;
unitIndex++;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
private formatDate(timestampArg?: number): string {
return timestampArg ? new Date(timestampArg).toLocaleString() : '-';
}
private renderSourceBadge(sourceArg?: 'upload' | 'registry'): TemplateResult {
const source = sourceArg || 'unknown';
return html`<span class="source-badge source-${source}">${source}</span>`;
}
}
declare global {
+50 -4
View File
@@ -59,7 +59,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 +125,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 +143,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);
},
@@ -366,6 +365,14 @@ export class CloudlyViewServices extends DeesElement {
Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-',
})}
.dataActions=${[
{
name: 'Details',
iconName: 'lucide:Eye',
type: ['contextmenu', 'inRow', 'doubleClick'],
actionFunc: async (actionDataArg: any) => {
await this.showDeploymentDetailsModal(actionDataArg.item);
},
},
{
name: 'Open IDE',
iconName: 'terminal',
@@ -444,6 +451,45 @@ export class CloudlyViewServices extends DeesElement {
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,7 +518,7 @@ export class CloudlyViewServices extends DeesElement {
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
try {
const response = await this.fireTypedRequest('getUpgradeableServices', {}) as { services: any[] };
const response = await this.fireTypedRequest('getUpgradeableAppStoreServices', {}) as { services: any[] };
this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null;
} catch {
this.upgradeInfo = null;
+5 -5
View File
@@ -4,6 +4,11 @@ export {
interfaces
}
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// @design.estate scope
import * as deesDomtools from '@design.estate/dees-domtools';
import * as deesElement from '@design.estate/dees-element';
@@ -11,11 +16,6 @@ import * as deesCatalog from '@design.estate/dees-catalog';
export { deesDomtools, deesElement, deesCatalog };
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
export { typedrequest };
// @push.rocks scope
import * as webjwt from '@push.rocks/webjwt';
import * as smartstate from '@push.rocks/smartstate';