Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfda4b4ca1 | |||
| a9d9ea585c | |||
| 56a62e7008 | |||
| 05560c9db9 | |||
| 50e69b095c | |||
| d5445609a0 | |||
| fd7c7b4313 | |||
| 057af996aa | |||
| 6565c44c29 | |||
| ebb4f36c67 | |||
| e7d3140f7a | |||
| 304767a75c | |||
| 0fa95d6c99 |
+24
-9
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"@git.zone/tsdocker": {
|
||||
"registries": ["code.foss.global"],
|
||||
"registries": [
|
||||
"code.foss.global"
|
||||
],
|
||||
"registryRepoMap": {
|
||||
"code.foss.global": "serve.zone/cloudly"
|
||||
},
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
@@ -14,7 +19,9 @@
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true,
|
||||
"includeFiles": ["./html/**/*"]
|
||||
"includeFiles": [
|
||||
"./html/**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -29,7 +36,10 @@
|
||||
"watchers": [
|
||||
{
|
||||
"name": "backend",
|
||||
"watch": ["./ts/**/*", "./ts_cliclient/**/*"],
|
||||
"watch": [
|
||||
"./ts/**/*",
|
||||
"./ts_cliclient/**/*"
|
||||
],
|
||||
"command": "pnpm run startTs",
|
||||
"restart": true,
|
||||
"debounce": 300,
|
||||
@@ -41,11 +51,16 @@
|
||||
"name": "website",
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"watchPatterns": ["./ts_web/**/*", "./html/**/*"],
|
||||
"watchPatterns": [
|
||||
"./ts_web/**/*",
|
||||
"./html/**/*"
|
||||
],
|
||||
"triggerReload": true,
|
||||
"bundler": "esbuild",
|
||||
"production": false,
|
||||
"includeFiles": ["./html/**/*"]
|
||||
"includeFiles": [
|
||||
"./html/**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -58,9 +73,6 @@
|
||||
},
|
||||
"dockerBuildargEnvMap": {}
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "service",
|
||||
@@ -113,5 +125,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . ./
|
||||
|
||||
@@ -3,6 +3,59 @@
|
||||
## 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
|
||||
- bumps `@serve.zone/api` to `^5.3.8`
|
||||
- keeps Docker release installs working with fresh serve.zone package releases
|
||||
- refreshes mature build, bundle, test, docs, and watch tooling
|
||||
- stabilizes API client integration test setup and cleanup
|
||||
|
||||
## 2026-05-23 - 5.8.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- allow Docker builds to install freshly released serve.zone interfaces
|
||||
- Copies pnpm-workspace.yaml into the Docker dependency install layer
|
||||
- Excludes @serve.zone/interfaces from pnpm 11 minimum release age checks during release builds
|
||||
|
||||
## 2026-05-23 - 5.8.0
|
||||
|
||||
### Features
|
||||
|
||||
+13
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/cloudly",
|
||||
"version": "5.8.0",
|
||||
"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/tsbundle": "^2.10.1",
|
||||
"@git.zone/tsdoc": "^2.0.3",
|
||||
"@git.zone/tsdocker": "^2.2.6",
|
||||
"@git.zone/tspublish": "^1.11.6",
|
||||
"@git.zone/tstest": "^3.6.5",
|
||||
"@git.zone/tswatch": "^3.3.3",
|
||||
"@git.zone/tsbuild": "^4.4.1",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdoc": "^2.0.5",
|
||||
"@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",
|
||||
@@ -45,7 +45,7 @@
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-domtools": "^2.5.6",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@git.zone/tsrun": "^2.0.3",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@push.rocks/early": "^4.0.4",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
@@ -78,8 +78,9 @@
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@serve.zone/api": "^5.3.7",
|
||||
"@serve.zone/interfaces": "^5.9.0",
|
||||
"@serve.zone/api": "^5.3.8",
|
||||
"@serve.zone/appstore": "^0.2.0",
|
||||
"@serve.zone/interfaces": "^6.0.0",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
|
||||
Generated
+524
-308
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,8 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- '@serve.zone/api'
|
||||
- '@serve.zone/appstore'
|
||||
- '@serve.zone/interfaces'
|
||||
|
||||
allowBuilds:
|
||||
'@design.estate/dees-catalog': false
|
||||
cpu-features: true
|
||||
|
||||
@@ -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,3 +1,6 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { Qenv } from '@push.rocks/qenv';
|
||||
import { SmartNetwork } from '@push.rocks/smartnetwork';
|
||||
import { tap } from '@git.zone/tstest/tapbundle';
|
||||
@@ -58,7 +61,11 @@ export const testCloudlyConfig: cloudly.ICloudlyConfig = {
|
||||
})(),
|
||||
};
|
||||
|
||||
await tapNodeTools.testFileProvider.getDockerAlpineImageAsLocalTarball();
|
||||
const alpineImageTarballPath = path.join(process.cwd(), '.nogit/testfiles/alpine.tar');
|
||||
const existingAlpineImageTarball = await fs.stat(alpineImageTarballPath).catch(() => undefined);
|
||||
if (!existingAlpineImageTarball?.isFile() || existingAlpineImageTarball.size === 0) {
|
||||
await tapNodeTools.testFileProvider.getDockerAlpineImageAsLocalTarball();
|
||||
}
|
||||
|
||||
export const createCloudly = async () => {
|
||||
const cloudlyInstance = new cloudly.Cloudly(testCloudlyConfig);
|
||||
|
||||
@@ -463,10 +463,10 @@ tap.test('should upload an image version', async () => {
|
||||
});
|
||||
|
||||
tap.test('should stop the apiclient', async (toolsArg) => {
|
||||
await toolsArg.delayFor(10000);
|
||||
await helpers.stopCloudly();
|
||||
await testClient.stop();
|
||||
await testCloudly.stop();
|
||||
await helpers.stopCloudly();
|
||||
toolsArg.delayFor(1000).then(() => process.exit());
|
||||
})
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.8.0',
|
||||
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.'
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+68
-91
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.8.0',
|
||||
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
@@ -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() || {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user