Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bac0a5f71 | |||
| 26256c92bd | |||
| c7a307c9d3 | |||
| 06d54db747 | |||
| 756c35aa05 | |||
| 2adb86c5ea | |||
| f6ab7460e1 | |||
| 2b65ddc193 | |||
| bfda4b4ca1 | |||
| a9d9ea585c | |||
| 56a62e7008 | |||
| 05560c9db9 | |||
| 50e69b095c | |||
| d5445609a0 | |||
| fd7c7b4313 | |||
| 057af996aa | |||
| 6565c44c29 | |||
| ebb4f36c67 | |||
| e7d3140f7a | |||
| 304767a75c | |||
| 0fa95d6c99 | |||
| 5d6d43b564 | |||
| bef236cd86 | |||
| 59043b7281 | |||
| befd0efdc0 | |||
| b1a0ce684a | |||
| d0b15ab51b |
+38
-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/**/*.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/**/*.html"]
|
||||
"includeFiles": [
|
||||
"./html/**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -58,10 +73,8 @@
|
||||
},
|
||||
"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",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -98,6 +111,22 @@
|
||||
"backend",
|
||||
"security"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker",
|
||||
"patterns": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@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 . ./
|
||||
|
||||
+133
@@ -3,6 +3,139 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-05-26 - 6.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- add hosted Cloudly parent upgrade controls (hostedapp)
|
||||
- Proxy admin upgrade status and start requests to the parent hosted-app runtime with the service-scoped runtime identity.
|
||||
- Add a Settings hosted runtime panel for status refresh, parent upgrade start, and running-upgrade polling.
|
||||
- Update `@serve.zone/interfaces` to `^6.2.0` for the parent upgrade contracts.
|
||||
- add hosted Cloudly parent upgrade controls (hostedapp)
|
||||
- Proxy admin upgrade status and start requests to the parent hosted-app runtime using the service runtime identity.
|
||||
- Add Settings hosted runtime status, refresh, upgrade start, and running-upgrade polling UI.
|
||||
- Update @serve.zone/interfaces to ^6.2.0 for parent upgrade request contracts.
|
||||
|
||||
## 2026-05-26 - 6.3.1
|
||||
|
||||
- remove redundant card wrappers around Cloudly tables (ui)
|
||||
- Lets `dees-table` provide its own card shell in service, image, and task history views.
|
||||
- Moves the live deployment refresh action into the table header actions.
|
||||
|
||||
### Fixes
|
||||
|
||||
- remove redundant wrappers around Cloudly tables (ui)
|
||||
- Let dees-table provide its own card shell in service, image, and task history views.
|
||||
- Move the live deployments refresh action into the deployments table header actions.
|
||||
- Bump @types/node to ^25.9.1.
|
||||
|
||||
## 2026-05-26 - 6.3.0
|
||||
|
||||
- add hosted app lifecycle protocol support (hostedapp)
|
||||
- Implements generic Hosted App TypedRequest handlers for Cloudly-hosted App Store services.
|
||||
- Injects service-scoped runtime identity environment variables into Cloudly App Store installs.
|
||||
- Lets Cloudly report initial admin bootstrap credentials to its parent host when `SERVEZONE_ADMINACCOUNT` is not configured.
|
||||
|
||||
### Features
|
||||
|
||||
- add hosted app lifecycle protocol support (hostedapp)
|
||||
- Adds a hosted app manager with lifecycle, bootstrap, and managed upgrade TypedRequest handlers.
|
||||
- Injects hosted app runtime identity environment variables into App Store installs.
|
||||
- Allows initial admin bootstrap credentials to be requested from the parent hosted app runtime when SERVEZONE_ADMINACCOUNT is not configured.
|
||||
- Updates hosted app platform requirements and @serve.zone/interfaces for the lifecycle protocol.
|
||||
|
||||
## 2026-05-26 - 6.2.0
|
||||
|
||||
### Features
|
||||
|
||||
- add App Store install and upgrade workflows (appstore)
|
||||
- Add an App Store dashboard for browsing templates, viewing version configs, editing install inputs, and installing services
|
||||
- Add App Store state actions, routing, and live upgrade operation progress handling in the web app
|
||||
- Implement upgrade previews, asynchronous service upgrade operations, platform binding reconciliation, and preservation of service volume and published port overrides
|
||||
- Enable service detail upgrades with preview confirmation, progress display, and refreshed service data
|
||||
- Bump @serve.zone/interfaces to ^6.0.1 and add App Store upgrade merge tests
|
||||
|
||||
## 2026-05-26 - 6.1.0
|
||||
|
||||
### Features
|
||||
|
||||
- improve image operations UI and record archive metadata (images)
|
||||
- Add image list metadata, detail drilldown, version tables, and service usage context.
|
||||
- Record uploaded image archive size and SHA-256 digest after storage completes.
|
||||
- Add deployment detail modals and safe double-click Details actions in deployment and service views.
|
||||
- Initialize default location metadata when creating images.
|
||||
|
||||
## 2026-05-25 - 6.0.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- switch App Store APIs to the shared appstore client
|
||||
- Replaces Cloudly App Store manager naming and request methods with App Store naming
|
||||
- Uses `@serve.zone/appstore` for app metadata parsing and source resolution
|
||||
- Adds `servezone.appstore.json` as Cloudly's source-owned App Store manifest
|
||||
|
||||
## 2026-05-24 - 5.9.0
|
||||
|
||||
### Features
|
||||
|
||||
- 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
|
||||
|
||||
- add service detail runtime actions and app catalog onboarding
|
||||
- Adds service detail pages with live deployments, restart, kill, and deployment IDE access
|
||||
- Adds app catalog install/update detection contracts and Cloudly handlers
|
||||
- Adds node jump codes for connecting systems to clusters
|
||||
- Updates Cloudly to pnpm 11 and @serve.zone/interfaces 5.9.0
|
||||
|
||||
## 2026-05-21 - 5.7.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- clean up Cloudly dashboard console and asset errors
|
||||
- replace invalid Lucide icon references in table actions and context menu items
|
||||
- add PWA manifest and local SVG favicon assets to avoid 404s
|
||||
- align local Cloudly custom element tags with canonical kebab-case names
|
||||
- remove noisy frontend debug logging from login and revenue checks
|
||||
|
||||
## 2026-05-21 - 5.7.0
|
||||
|
||||
### Features
|
||||
|
||||
- add SPA dashboard path navigation (web)
|
||||
- support direct links to dashboard views and subviews via URL paths
|
||||
- sync appdash selection with browser history and enable server SPA fallback
|
||||
|
||||
## 2026-05-21 - 5.6.0
|
||||
|
||||
### Features
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Cloudly">
|
||||
<rect width="64" height="64" rx="14" fill="#050505"/>
|
||||
<path d="M19 40h27a9 9 0 0 0 1.5-17.9A14 14 0 0 0 20.2 17 11.5 11.5 0 0 0 19 40Z" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M23 32h18" stroke="#7dd3fc" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
+1
-2
@@ -14,7 +14,7 @@
|
||||
|
||||
<!--Lets make sure we recognize this as an PWA-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!--Lets load standard fonts-->
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
@@ -114,7 +114,6 @@
|
||||
} else {
|
||||
window.revenueEnabled = false;
|
||||
}
|
||||
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||
};
|
||||
|
||||
runRevenueCheck();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Cloudly",
|
||||
"short_name": "Cloudly",
|
||||
"description": "Cloudly infrastructure management dashboard",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/cloudly",
|
||||
"version": "5.6.0",
|
||||
"version": "6.4.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.1"
|
||||
},
|
||||
"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.6.0",
|
||||
"@serve.zone/api": "^5.3.8",
|
||||
"@serve.zone/appstore": "^0.2.0",
|
||||
"@serve.zone/interfaces": "^6.2.0",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
@@ -134,5 +135,5 @@
|
||||
"backend",
|
||||
"security"
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
"packageManager": "pnpm@11.2.2"
|
||||
}
|
||||
|
||||
Generated
+534
-309
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- '@serve.zone/api'
|
||||
- '@serve.zone/appstore'
|
||||
- '@serve.zone/interfaces'
|
||||
|
||||
allowBuilds:
|
||||
'@design.estate/dees-catalog': false
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
mongodb-memory-server: false
|
||||
puppeteer: false
|
||||
sharp: false
|
||||
ssh2: true
|
||||
@@ -52,6 +52,7 @@ Cloudly currently coordinates these areas:
|
||||
| `CloudflareConnector` | Optional Cloudflare account used by ACME DNS-01 when `cloudflareToken` is configured in settings. |
|
||||
| `LetsencryptConnector` | SmartACME certificate issuance and certificate lookup. |
|
||||
| `CloudlyCoreflowManager` | Authenticates Coreflow, returns cluster config payloads, and pushes config updates to connected Coreflow clients. |
|
||||
| `CloudlyJumpManager` | Creates short-lived Jump Codes for onboarding existing systems into clusters. |
|
||||
| `CloudlyRegistryManager` | Embedded OCI registry backed by configured S3 storage, including deploy-on-push metadata updates. |
|
||||
| `CloudlyBaseOsManager` | BaseOS registration, heartbeat, image build orchestration, worker selection, and artifact downloads. |
|
||||
| `CloudlyBackupManager` | Service backup/restore orchestration and remote archive object replication. |
|
||||
@@ -194,6 +195,18 @@ The implemented cluster flow is intentionally simple:
|
||||
|
||||
When service, platform, or gateway settings change, Cloudly pushes updated config to connected Coreflow clients where supported.
|
||||
|
||||
### Jump Codes for Existing Systems
|
||||
|
||||
Admins can generate a short-lived, single-use Jump Code for a cluster. The dashboard displays a command in this form:
|
||||
|
||||
```sh
|
||||
curl -fsSL 'https://cloudly.example.com/jump/<code>' | sudo bash
|
||||
```
|
||||
|
||||
The public `/jump/<code>` URL renders a browser landing page for humans and a shell bootstrap script for `curl`/CLI clients. The script installs the required host tooling, claims the code through `POST /jump/v1/claim`, receives the cluster runtime token, and starts Spark in `coreflow-node` mode. The long-lived cluster token is never displayed in the dashboard command.
|
||||
|
||||
Jump Codes expire by default after 30 minutes and are consumed on first successful claim.
|
||||
|
||||
## Registry and Deploy-On-Push
|
||||
|
||||
Cloudly serves an OCI registry under `/v2` through `CloudlyRegistryManager`. The registry uses configured S3 storage and issues OCI tokens from Cloudly authentication state.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"app": {
|
||||
"id": "cloudly",
|
||||
"name": "Cloudly",
|
||||
"description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
|
||||
"category": "Dev Tools",
|
||||
"iconName": "server",
|
||||
"tags": ["serve.zone", "control-plane", "clusters", "deployments"],
|
||||
"maintainer": "serve.zone",
|
||||
"links": {
|
||||
"Source": "https://code.foss.global/serve.zone/cloudly",
|
||||
"Docs": "https://serve.zone"
|
||||
}
|
||||
},
|
||||
"latestVersion": "latest",
|
||||
"source": {
|
||||
"type": "dockerImage",
|
||||
"image": "code.foss.global/serve.zone/cloudly:latest",
|
||||
"tracking": "digest"
|
||||
},
|
||||
"runtime": {
|
||||
"image": "code.foss.global/serve.zone/cloudly:latest",
|
||||
"port": 80,
|
||||
"envVars": [
|
||||
{
|
||||
"key": "SERVEZONE_ENVIRONMENT",
|
||||
"value": "production",
|
||||
"description": "Cloudly runtime environment.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "SERVEZONE_URL",
|
||||
"value": "${SERVICE_DOMAIN}",
|
||||
"description": "Public Cloudly hostname without protocol.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "SERVEZONE_PORT",
|
||||
"value": "80",
|
||||
"description": "Internal Cloudly HTTP port inside the container.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "SERVEZONE_SSLMODE",
|
||||
"value": "external",
|
||||
"description": "Use external TLS termination through Onebox or dcrouter.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_URL",
|
||||
"value": "${MONGODB_URI}",
|
||||
"description": "Authenticated MongoDB connection URL provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_NAME",
|
||||
"value": "${MONGODB_DATABASE}",
|
||||
"description": "MongoDB database name provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_USER",
|
||||
"value": "${MONGODB_USERNAME}",
|
||||
"description": "MongoDB username provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "MONGODB_PASS",
|
||||
"value": "${MONGODB_PASSWORD}",
|
||||
"description": "MongoDB password provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_ENDPOINT",
|
||||
"value": "onebox-minio",
|
||||
"description": "S3 endpoint host for the MinIO service provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_PORT",
|
||||
"value": "9000",
|
||||
"description": "S3 endpoint port for the MinIO service provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_USESSL",
|
||||
"value": "false",
|
||||
"description": "Use plain HTTP for internal MinIO traffic on the Onebox network.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_BUCKET",
|
||||
"value": "${S3_BUCKET}",
|
||||
"description": "S3 bucket provisioned by Onebox for Cloudly's registry.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_ACCESSKEY",
|
||||
"value": "${S3_ACCESS_KEY}",
|
||||
"description": "S3 access key provisioned by Onebox.",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"key": "S3_SECRETKEY",
|
||||
"value": "${S3_SECRET_KEY}",
|
||||
"description": "S3 secret key provisioned by Onebox.",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"platformRequirements": {
|
||||
"mongodb": true,
|
||||
"s3": true
|
||||
},
|
||||
"minOneboxVersion": "2.2.0",
|
||||
"backupBeforeUpgrade": true,
|
||||
"healthCheck": {
|
||||
"path": "/status",
|
||||
"port": 80,
|
||||
"expectedStatus": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+94
-2
@@ -16,6 +16,28 @@ const logErrorDetails = (errorArg: unknown) => {
|
||||
console.error(` - Error:`, errorArg);
|
||||
};
|
||||
|
||||
const withParentRuntimeEnvCleared = async <T>(callbackArg: () => Promise<T>): Promise<T> => {
|
||||
const previousEnv = {
|
||||
SERVEZONE_RUNTIME_URL: process.env.SERVEZONE_RUNTIME_URL,
|
||||
SERVEZONE_APP_INSTANCE_ID: process.env.SERVEZONE_APP_INSTANCE_ID,
|
||||
SERVEZONE_APP_CONTROL_TOKEN: process.env.SERVEZONE_APP_CONTROL_TOKEN,
|
||||
};
|
||||
delete process.env.SERVEZONE_RUNTIME_URL;
|
||||
delete process.env.SERVEZONE_APP_INSTANCE_ID;
|
||||
delete process.env.SERVEZONE_APP_CONTROL_TOKEN;
|
||||
try {
|
||||
return await callbackArg();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tap.preTask('should start cloudly', async () => {
|
||||
testCloudly = await helpers.createCloudly();
|
||||
await testCloudly.start();
|
||||
@@ -92,6 +114,76 @@ tap.test('should get an identity', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should report parent hosted upgrade unavailable when not hosted', async () => {
|
||||
await withParentRuntimeEnvCleared(async () => {
|
||||
const statusRequest = testClient.typedsocketClient.createTypedRequest<any>('getHostedAppParentUpgradeStatus');
|
||||
const statusResponse = await statusRequest.fire({ identity: testClient.identity });
|
||||
expect(statusResponse.isHosted).toBeFalse();
|
||||
expect(statusResponse.unavailableReason).toEqual('SERVEZONE_RUNTIME_URL is not configured.');
|
||||
expect(statusResponse.upgradeState.status).toEqual('unknown');
|
||||
|
||||
const startRequest = testClient.typedsocketClient.createTypedRequest<any>('startHostedAppParentUpgrade');
|
||||
const startResponse = await startRequest.fire({
|
||||
identity: testClient.identity,
|
||||
targetVersion: '0.0.0-test',
|
||||
});
|
||||
expect(startResponse.isHosted).toBeFalse();
|
||||
expect(startResponse.upgradeState.status).toEqual('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should create and consume node jump codes', async () => {
|
||||
const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster');
|
||||
const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest<any>('createNodeJumpCommand');
|
||||
const jumpCommand = await createJumpCommandTR.fire({
|
||||
identity: testClient.identity,
|
||||
clusterId: cluster.id,
|
||||
});
|
||||
|
||||
expect(jumpCommand.jumpUrl.includes('/jump/')).toBeTrue();
|
||||
expect(jumpCommand.command.includes(jumpCommand.jumpUrl)).toBeTrue();
|
||||
|
||||
const setupResponse = await fetch(jumpCommand.jumpUrl, {
|
||||
headers: {
|
||||
accept: '*/*',
|
||||
'user-agent': 'curl/8.0',
|
||||
},
|
||||
});
|
||||
const setupScript = await setupResponse.text();
|
||||
expect(setupResponse.status).toEqual(200);
|
||||
expect(setupScript.includes('spark installdaemon --mode=coreflow-node')).toBeTrue();
|
||||
|
||||
const claimResponse = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode, hostname: 'jump-code-test-node' }),
|
||||
},
|
||||
);
|
||||
const claimBody = await claimResponse.json();
|
||||
expect(claimResponse.status).toEqual(200);
|
||||
expect(claimBody.accepted).toBeTrue();
|
||||
expect(claimBody.nodeId).toBeTruthy();
|
||||
expect(claimBody.coreflowJumpCode).toBeTruthy();
|
||||
|
||||
const secondClaimResponse = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode }),
|
||||
},
|
||||
);
|
||||
const secondClaimBody = await secondClaimResponse.json();
|
||||
expect(secondClaimResponse.status).toEqual(400);
|
||||
expect(secondClaimBody.accepted).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should expose the OCI registry endpoint', async () => {
|
||||
const response = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/`,
|
||||
@@ -411,10 +503,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();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { CloudlyAppStoreManager } from '../ts/manager.appstore/classes.appstoremanager.js';
|
||||
|
||||
const createManager = () => Object.create(CloudlyAppStoreManager.prototype) as any;
|
||||
|
||||
tap.test('should preserve service volume overrides during App Store upgrades', async () => {
|
||||
const manager = createManager();
|
||||
const volumes = manager.mergeUpgradeVolumes(
|
||||
[
|
||||
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
|
||||
{ mountPath: '/cache', name: 'custom-cache' },
|
||||
],
|
||||
[
|
||||
'/data',
|
||||
{ mountPath: '/config', readOnly: true },
|
||||
],
|
||||
);
|
||||
|
||||
expect(volumes).toEqual([
|
||||
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
|
||||
{ mountPath: '/config', readOnly: true },
|
||||
{ mountPath: '/cache', name: 'custom-cache' },
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('should preserve service published port overrides during App Store upgrades', async () => {
|
||||
const manager = createManager();
|
||||
const publishedPorts = manager.mergeUpgradePublishedPorts(
|
||||
[
|
||||
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
|
||||
{ targetPort: 9999, publishedPort: 19999 },
|
||||
],
|
||||
[
|
||||
{ targetPort: 5432, publishedPort: 5432 },
|
||||
{ targetPort: 6379 },
|
||||
],
|
||||
);
|
||||
|
||||
expect(publishedPorts).toEqual([
|
||||
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
|
||||
{ targetPort: 6379, protocol: 'tcp' },
|
||||
{ targetPort: 9999, publishedPort: 19999, protocol: 'tcp' },
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('should report unsupported App Store published port configs', async () => {
|
||||
const manager = createManager();
|
||||
const unsupported = manager.getUnsupportedPublishedPorts([
|
||||
{ targetPort: 80, publishedPort: 80 },
|
||||
{ targetPort: 81, publishedPort: 80 },
|
||||
{ targetPort: 82, publishedPort: 82, hostIp: '127.0.0.1' },
|
||||
{ targetPort: 9000, targetPortEnd: 9001, publishedPort: 19000, publishedPortEnd: 19002 },
|
||||
]);
|
||||
|
||||
expect(unsupported.some((messageArg: string) => messageArg.includes('duplicates published port 80/tcp'))).toBeTrue();
|
||||
expect(unsupported.some((messageArg: string) => messageArg.includes('unsupported hostIp'))).toBeTrue();
|
||||
expect(unsupported.some((messageArg: string) => messageArg.includes('mismatched target and published port ranges'))).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.6.0',
|
||||
version: '6.4.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ 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 { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
|
||||
import { CloudlyHostedAppManager } from './manager.hostedapp/classes.hostedappmanager.js';
|
||||
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
|
||||
|
||||
/**
|
||||
* Cloudly class can be used to instantiate a cloudly server.
|
||||
@@ -79,6 +82,9 @@ export class Cloudly {
|
||||
public nodeManager: CloudlyNodeManager;
|
||||
public baremetalManager: CloudlyBaremetalManager;
|
||||
public baseOsManager: CloudlyBaseOsManager;
|
||||
public appStoreManager: CloudlyAppStoreManager;
|
||||
public hostedAppManager: CloudlyHostedAppManager;
|
||||
public jumpManager: CloudlyJumpManager;
|
||||
|
||||
private readyDeferred = new plugins.smartpromise.Deferred();
|
||||
|
||||
@@ -115,8 +121,11 @@ export class Cloudly {
|
||||
this.backupManager = new CloudlyBackupManager(this);
|
||||
this.baseOsManager = new CloudlyBaseOsManager(this);
|
||||
this.secretManager = new CloudlySecretManager(this);
|
||||
this.hostedAppManager = new CloudlyHostedAppManager(this);
|
||||
this.appStoreManager = new CloudlyAppStoreManager(this);
|
||||
this.nodeManager = new CloudlyNodeManager(this);
|
||||
this.baremetalManager = new CloudlyBaremetalManager(this);
|
||||
this.jumpManager = new CloudlyJumpManager(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,12 +147,15 @@ export class Cloudly {
|
||||
await this.secretManager.start();
|
||||
await this.nodeManager.start();
|
||||
await this.baremetalManager.start();
|
||||
await this.jumpManager.start();
|
||||
await this.serviceManager.start();
|
||||
await this.platformManager.start();
|
||||
await this.deploymentManager.start();
|
||||
await this.taskManager.init();
|
||||
await this.backupManager.start();
|
||||
await this.baseOsManager.start();
|
||||
await this.hostedAppManager.start();
|
||||
await this.appStoreManager.start();
|
||||
await this.registryManager.start();
|
||||
await this.domainManager.init();
|
||||
|
||||
@@ -173,10 +185,13 @@ export class Cloudly {
|
||||
await this.serviceManager.stop();
|
||||
await this.platformManager.stop();
|
||||
await this.deploymentManager.stop();
|
||||
await this.jumpManager.stop();
|
||||
await this.taskManager.stop();
|
||||
await this.backupManager.stop();
|
||||
await this.baseOsManager.stop();
|
||||
await this.registryManager.stop();
|
||||
await this.hostedAppManager.stop();
|
||||
await this.appStoreManager.stop();
|
||||
await this.externalRegistryManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export class CloudlyServer {
|
||||
: {}),
|
||||
injectReload: true,
|
||||
serveDir: paths.distServeDir,
|
||||
spaFallback: true,
|
||||
watch: true,
|
||||
compression: {
|
||||
enabled: true,
|
||||
@@ -94,6 +95,21 @@ export class CloudlyServer {
|
||||
'ALL',
|
||||
async (ctx) => this.cloudlyRef.nodeManager.curlfreshInstance.handleRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/jump/v1/claim',
|
||||
'POST',
|
||||
async (ctx) => this.cloudlyRef.jumpManager.handleClaimHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/jump/:code/setup.sh',
|
||||
'GET',
|
||||
async (ctx) => this.cloudlyRef.jumpManager.handleSetupScriptHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/jump/:code',
|
||||
'GET',
|
||||
async (ctx) => this.cloudlyRef.jumpManager.handleJumpHttpRequest(ctx),
|
||||
);
|
||||
this.typedServer.addRoute(
|
||||
'/baseos/v1/nodes/register',
|
||||
'POST',
|
||||
@@ -104,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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
@@ -92,19 +113,28 @@ export class CloudlyAuthManager {
|
||||
}
|
||||
|
||||
const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount;
|
||||
if (!adminAccount) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap');
|
||||
}
|
||||
let username: string;
|
||||
let password: string;
|
||||
let hostedBootstrapActionId: string | undefined;
|
||||
if (adminAccount) {
|
||||
const separatorIndex = adminAccount.indexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
||||
}
|
||||
|
||||
const separatorIndex = adminAccount.indexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
||||
}
|
||||
|
||||
const username = adminAccount.slice(0, separatorIndex).trim();
|
||||
const password = adminAccount.slice(separatorIndex + 1);
|
||||
if (!username || !password) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
||||
username = adminAccount.slice(0, separatorIndex).trim();
|
||||
password = adminAccount.slice(separatorIndex + 1);
|
||||
if (!username || !password) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
||||
}
|
||||
} else {
|
||||
const hostedBootstrap = await this.cloudlyRef.hostedAppManager.requestParentInitialAdminBootstrap();
|
||||
if (!hostedBootstrap) {
|
||||
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap unless hosted app lifecycle credentials are available');
|
||||
}
|
||||
username = hostedBootstrap.username;
|
||||
password = hostedBootstrap.password;
|
||||
hostedBootstrapActionId = hostedBootstrap.actionId;
|
||||
}
|
||||
|
||||
const user = new this.CUser({
|
||||
@@ -118,6 +148,14 @@ export class CloudlyAuthManager {
|
||||
});
|
||||
await user.save();
|
||||
logger.log('success', `created initial admin user ${username}`);
|
||||
if (hostedBootstrapActionId) {
|
||||
await this.cloudlyRef.hostedAppManager.completeParentBootstrapAction(
|
||||
hostedBootstrapActionId,
|
||||
'Cloudly created the initial admin user.',
|
||||
).catch((errorArg) => {
|
||||
logger.log('warn', `failed to complete hosted app bootstrap action: ${(errorArg as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async stop() {}
|
||||
@@ -126,28 +164,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 +191,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 +210,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',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,30 @@ import { Cloudly } from '../classes.cloudly.js';
|
||||
import type { Cluster } from '../manager.cluster/classes.cluster.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
type TCoreflowDeploymentRequest =
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_RestartDeployment
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_KillDeployment
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadFile
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceWriteFile
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadDir
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceMkdir
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceRm
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExists
|
||||
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExec;
|
||||
|
||||
type TCoreflowDeploymentActionMethod =
|
||||
| 'coreflowRestartDeployment'
|
||||
| 'coreflowKillDeployment';
|
||||
|
||||
type TCoreflowDeploymentActionRequest = Extract<TCoreflowDeploymentRequest, {
|
||||
method: TCoreflowDeploymentActionMethod;
|
||||
}>;
|
||||
|
||||
export type TCoreflowDeploymentWorkspaceMethod = Exclude<
|
||||
TCoreflowDeploymentRequest['method'],
|
||||
TCoreflowDeploymentActionMethod
|
||||
>;
|
||||
|
||||
/**
|
||||
* in charge of talking to coreflow services on clusters
|
||||
* coreflow runs on a server when ServerManager is done.
|
||||
@@ -159,4 +183,87 @@ export class CloudlyCoreflowManager {
|
||||
|
||||
return connections.length;
|
||||
}
|
||||
|
||||
public async getRuntimeDeploymentsForService(
|
||||
serviceArg: plugins.servezoneInterfaces.data.IService,
|
||||
): Promise<plugins.servezoneInterfaces.data.IDeployment[]> {
|
||||
const connections = await this.getConnectedCoreflowConnections();
|
||||
const deployments: plugins.servezoneInterfaces.data.IDeployment[] = [];
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_GetServiceDeployments>(
|
||||
'coreflowGetServiceDeployments',
|
||||
connection,
|
||||
);
|
||||
const response = await request.fire({ service: serviceArg });
|
||||
deployments.push(...(response.deployments || []));
|
||||
} catch (error) {
|
||||
logger.log('warn', `failed to query coreflow deployments: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
return deployments;
|
||||
}
|
||||
|
||||
public async fireDeploymentRuntimeAction(
|
||||
methodArg: TCoreflowDeploymentActionMethod,
|
||||
deploymentIdArg: string,
|
||||
): Promise<{ deployment: plugins.servezoneInterfaces.data.IDeployment }> {
|
||||
const response = await this.fireCoreflowRequestUntilFound<TCoreflowDeploymentActionRequest>(methodArg, {
|
||||
deploymentId: deploymentIdArg,
|
||||
});
|
||||
if (!response.deployment) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Coreflow did not return deployment data');
|
||||
}
|
||||
return { deployment: response.deployment };
|
||||
}
|
||||
|
||||
public async fireDeploymentWorkspaceRequest(
|
||||
methodArg: TCoreflowDeploymentWorkspaceMethod,
|
||||
payloadArg: Extract<TCoreflowDeploymentRequest, { method: typeof methodArg }>['request'],
|
||||
) {
|
||||
return await this.fireCoreflowRequestUntilFound(methodArg, payloadArg);
|
||||
}
|
||||
|
||||
private async fireCoreflowRequestUntilFound<TRequest extends TCoreflowDeploymentRequest>(
|
||||
methodArg: TRequest['method'],
|
||||
payloadArg: TRequest['request'],
|
||||
): Promise<TRequest['response']> {
|
||||
const connections = await this.getConnectedCoreflowConnections();
|
||||
if (connections.length === 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No connected coreflow');
|
||||
}
|
||||
|
||||
let lastError: Error | undefined;
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<TRequest>(
|
||||
methodArg,
|
||||
connection,
|
||||
);
|
||||
const response = await request.fire(payloadArg);
|
||||
if (response?.found) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
lastError?.message || 'No connected coreflow found the requested deployment',
|
||||
);
|
||||
}
|
||||
|
||||
private async getConnectedCoreflowConnections() {
|
||||
const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket;
|
||||
if (!typedsocket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await typedsocket.findAllTargetConnections(async (connectionArg) => {
|
||||
const identityTag = await connectionArg.getTagById('identity');
|
||||
const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined;
|
||||
return identity?.role === 'cluster' && !!identity.userId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Deployment } from './classes.deployment.js';
|
||||
import type { TCoreflowDeploymentWorkspaceMethod } from '../manager.coreflow/coreflowmanager.js';
|
||||
|
||||
export class DeploymentManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -71,6 +72,18 @@ export class DeploymentManager {
|
||||
this.cloudlyRef.authManager.validIdentityGuard,
|
||||
]);
|
||||
|
||||
const service = await this.cloudlyRef.serviceManager.CService.getInstance({
|
||||
id: reqArg.serviceId,
|
||||
});
|
||||
if (service) {
|
||||
const runtimeDeployments = await this.cloudlyRef.coreflowManager.getRuntimeDeploymentsForService(
|
||||
await service.createSavableObject(),
|
||||
);
|
||||
if (runtimeDeployments.length > 0) {
|
||||
return { deployments: runtimeDeployments };
|
||||
}
|
||||
}
|
||||
|
||||
const deployments = await this.CDeployment.getInstances({
|
||||
serviceId: reqArg.serviceId,
|
||||
});
|
||||
@@ -204,29 +217,41 @@ export class DeploymentManager {
|
||||
'restartDeployment',
|
||||
async (reqArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(reqArg, [
|
||||
this.cloudlyRef.authManager.validIdentityGuard,
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
|
||||
const deployment = await this.CDeployment.getInstance({
|
||||
id: reqArg.deploymentId,
|
||||
});
|
||||
|
||||
if (!deployment) {
|
||||
throw new Error('Deployment not found');
|
||||
}
|
||||
|
||||
// TODO: Implement actual restart logic with Docker/container runtime
|
||||
deployment.status = 'starting';
|
||||
await deployment.save();
|
||||
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
|
||||
'coreflowRestartDeployment',
|
||||
reqArg.deploymentId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deployment: await deployment.createSavableObject(),
|
||||
deployment: result.deployment,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_KillDeployment>(
|
||||
'killDeployment',
|
||||
async (reqArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(reqArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
|
||||
'coreflowKillDeployment',
|
||||
reqArg.deploymentId,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
deployment: result.deployment,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Scale deployment
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_ScaleDeployment>(
|
||||
@@ -254,6 +279,31 @@ export class DeploymentManager {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const addDeploymentWorkspaceHandler = (methodArg: string, coreflowMethodArg: TCoreflowDeploymentWorkspaceMethod) => {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(methodArg, async (reqArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(reqArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
const { identity: _identity, ...payload } = reqArg;
|
||||
const response = await this.cloudlyRef.coreflowManager.fireDeploymentWorkspaceRequest(
|
||||
coreflowMethodArg,
|
||||
payload,
|
||||
);
|
||||
const { found: _found, ...publicResponse } = response;
|
||||
return publicResponse;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceReadFile', 'coreflowDeploymentWorkspaceReadFile');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceWriteFile', 'coreflowDeploymentWorkspaceWriteFile');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceReadDir', 'coreflowDeploymentWorkspaceReadDir');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceMkdir', 'coreflowDeploymentWorkspaceMkdir');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceRm', 'coreflowDeploymentWorkspaceRm');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceExists', 'coreflowDeploymentWorkspaceExists');
|
||||
addDeploymentWorkspaceHandler('deploymentWorkspaceExec', 'coreflowDeploymentWorkspaceExec');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import { Service } from '../manager.service/classes.service.js';
|
||||
|
||||
type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifecycleState;
|
||||
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
|
||||
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
|
||||
|
||||
interface IHostedAppParentUpgradeResponse {
|
||||
isHosted: boolean;
|
||||
unavailableReason?: string;
|
||||
upgradeState: IHostedAppUpgradeState;
|
||||
}
|
||||
|
||||
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
|
||||
hostedAppLifecycle?: IHostedAppLifecycleState;
|
||||
};
|
||||
|
||||
export class CloudlyHostedAppManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private cloudlyRef: Cloudly) {
|
||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
public async start() {}
|
||||
public async stop() {}
|
||||
|
||||
private getParentRuntimeIdentity(): IHostedAppRuntimeIdentity | null {
|
||||
const appInstanceId = process.env.SERVEZONE_APP_INSTANCE_ID;
|
||||
const appControlToken = process.env.SERVEZONE_APP_CONTROL_TOKEN;
|
||||
if (!appInstanceId || !appControlToken) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appInstanceId,
|
||||
appControlToken,
|
||||
hostType: process.env.SERVEZONE_APP_HOST_TYPE || 'onebox',
|
||||
};
|
||||
}
|
||||
|
||||
private createParentRuntimeTypedRequest<TRequest extends plugins.typedrequestInterfaces.ITypedRequest>(methodArg: TRequest['method']): plugins.typedrequest.TypedRequest<TRequest> | null {
|
||||
const runtimeUrl = process.env.SERVEZONE_RUNTIME_URL;
|
||||
if (!runtimeUrl) {
|
||||
return null;
|
||||
}
|
||||
return new plugins.typedrequest.TypedRequest<TRequest>(
|
||||
`${runtimeUrl.replace(/\/+$/, '')}/typedrequest`,
|
||||
methodArg,
|
||||
);
|
||||
}
|
||||
|
||||
private getParentRuntimeUnavailableReason(): string | undefined {
|
||||
if (!process.env.SERVEZONE_RUNTIME_URL) {
|
||||
return 'SERVEZONE_RUNTIME_URL is not configured.';
|
||||
}
|
||||
if (!process.env.SERVEZONE_APP_INSTANCE_ID || !process.env.SERVEZONE_APP_CONTROL_TOKEN) {
|
||||
return 'Hosted app runtime identity is not configured.';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getErrorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
|
||||
public async getParentUpgradeStatus(): Promise<IHostedAppParentUpgradeResponse> {
|
||||
const unavailableReason = this.getParentRuntimeUnavailableReason();
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
|
||||
'hostedAppGetManagedUpgradeStatus',
|
||||
);
|
||||
if (unavailableReason || !identity || !request) {
|
||||
return {
|
||||
isHosted: false,
|
||||
unavailableReason,
|
||||
upgradeState: { status: 'unknown' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.fire({ identity });
|
||||
return {
|
||||
isHosted: true,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
return {
|
||||
isHosted: true,
|
||||
unavailableReason: message,
|
||||
upgradeState: {
|
||||
status: 'unknown',
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async startParentUpgrade(targetVersionArg?: string): Promise<IHostedAppParentUpgradeResponse> {
|
||||
const unavailableReason = this.getParentRuntimeUnavailableReason();
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
|
||||
'hostedAppStartManagedUpgrade',
|
||||
);
|
||||
if (unavailableReason || !identity || !request) {
|
||||
return {
|
||||
isHosted: false,
|
||||
unavailableReason,
|
||||
upgradeState: { status: 'unknown' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request.fire({
|
||||
identity,
|
||||
targetVersion: targetVersionArg,
|
||||
});
|
||||
return {
|
||||
isHosted: true,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = this.getErrorMessage(error);
|
||||
return {
|
||||
isHosted: true,
|
||||
unavailableReason: message,
|
||||
upgradeState: {
|
||||
status: 'failed',
|
||||
error: message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async requestParentInitialAdminBootstrap(): Promise<{
|
||||
username: string;
|
||||
password: string;
|
||||
actionId: string;
|
||||
} | null> {
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||
'hostedAppRequestBootstrapAction',
|
||||
);
|
||||
if (!identity || !request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = 'admin';
|
||||
const password = plugins.smartunique.uniSimple('cloudlyadmin', 32);
|
||||
const response = await request.fire({
|
||||
identity,
|
||||
action: {
|
||||
type: 'credentials',
|
||||
label: 'Cloudly initial admin',
|
||||
url: `https://${this.cloudlyRef.config.data.publicUrl}`,
|
||||
username,
|
||||
password,
|
||||
message: 'Use these credentials to sign in to Cloudly, then change the admin password.',
|
||||
},
|
||||
});
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
actionId: response.action.id,
|
||||
};
|
||||
}
|
||||
|
||||
public async completeParentBootstrapAction(actionIdArg?: string, messageArg?: string): Promise<void> {
|
||||
const identity = this.getParentRuntimeIdentity();
|
||||
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||
'hostedAppCompleteBootstrapAction',
|
||||
);
|
||||
if (!identity || !request) {
|
||||
return;
|
||||
}
|
||||
await request.fire({
|
||||
identity,
|
||||
actionId: actionIdArg,
|
||||
message: messageArg,
|
||||
});
|
||||
}
|
||||
|
||||
public createHostedAppRuntimeEnvVars(serviceNameArg: string): {
|
||||
appInstanceId: string;
|
||||
appControlToken: string;
|
||||
envVars: Record<string, string>;
|
||||
lifecycle: IHostedAppLifecycleState;
|
||||
} {
|
||||
const appInstanceId = plugins.smartunique.uniSimple('hostedapp');
|
||||
const appControlToken = plugins.smartunique.uniSimple('hostedapptoken', 64);
|
||||
const runtimeUrl = `https://${this.cloudlyRef.config.data.publicUrl}`;
|
||||
return {
|
||||
appInstanceId,
|
||||
appControlToken,
|
||||
envVars: {
|
||||
SERVEZONE_RUNTIME_URL: runtimeUrl,
|
||||
SERVEZONE_APP_INSTANCE_ID: appInstanceId,
|
||||
SERVEZONE_APP_CONTROL_TOKEN: appControlToken,
|
||||
SERVEZONE_APP_HOST_TYPE: 'cloudly',
|
||||
},
|
||||
lifecycle: {
|
||||
appInstanceId,
|
||||
hostType: 'cloudly',
|
||||
appName: serviceNameArg,
|
||||
runtimeStatus: 'unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async requireHostedAppIdentity(identityArg: IHostedAppRuntimeIdentity): Promise<Service> {
|
||||
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||
const service = services.find((serviceArg) => {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
return (
|
||||
serviceData.hostedAppLifecycle?.appInstanceId === identityArg?.appInstanceId ||
|
||||
serviceData.environment?.SERVEZONE_APP_INSTANCE_ID === identityArg?.appInstanceId
|
||||
);
|
||||
});
|
||||
if (!service) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Hosted app service not found');
|
||||
}
|
||||
const serviceData = service.data as TExtendedServiceData;
|
||||
if (serviceData.environment?.SERVEZONE_APP_CONTROL_TOKEN !== identityArg?.appControlToken) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Hosted app identity is invalid');
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
private async getUpgradeState(serviceArg: Service): Promise<IHostedAppUpgradeState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
const latestOperation = this.cloudlyRef.appStoreManager
|
||||
.getUpgradeOperations()
|
||||
.find((operationArg) => operationArg.serviceId === serviceArg.id);
|
||||
if (latestOperation) {
|
||||
return {
|
||||
status: latestOperation.status === 'running' ? 'running' : latestOperation.status,
|
||||
appTemplateId: latestOperation.appTemplateId,
|
||||
currentVersion: latestOperation.fromVersion,
|
||||
targetVersion: latestOperation.targetVersion,
|
||||
operationId: latestOperation.id,
|
||||
warnings: latestOperation.warnings,
|
||||
error: latestOperation.error,
|
||||
startedAt: latestOperation.startedAt,
|
||||
updatedAt: latestOperation.updatedAt,
|
||||
completedAt: latestOperation.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
||||
return { status: 'unknown' };
|
||||
}
|
||||
|
||||
const upgradeableServices = await this.cloudlyRef.appStoreManager.getUpgradeableAppStoreServices();
|
||||
const upgradeable = upgradeableServices.find((serviceArg2) => serviceArg2.serviceId === serviceArg.id);
|
||||
if (!upgradeable) {
|
||||
return {
|
||||
status: 'upToDate',
|
||||
appTemplateId: serviceData.appTemplateId,
|
||||
currentVersion: serviceData.appTemplateVersion,
|
||||
latestVersion: serviceData.appTemplateVersion,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'available',
|
||||
appTemplateId: upgradeable.appTemplateId,
|
||||
currentVersion: upgradeable.currentVersion,
|
||||
latestVersion: upgradeable.latestVersion,
|
||||
targetVersion: upgradeable.latestVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private async getLifecycleState(serviceArg: Service): Promise<IHostedAppLifecycleState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
const appInstanceId = serviceData.hostedAppLifecycle?.appInstanceId || serviceData.environment?.SERVEZONE_APP_INSTANCE_ID;
|
||||
const state: IHostedAppLifecycleState = {
|
||||
...(serviceData.hostedAppLifecycle || ({} as IHostedAppLifecycleState)),
|
||||
appInstanceId: appInstanceId || '',
|
||||
hostType: 'cloudly',
|
||||
appName: serviceData.hostedAppLifecycle?.appName || serviceData.name,
|
||||
publicUrl: serviceData.hostedAppLifecycle?.publicUrl || (serviceData.domains?.[0]?.name ? `https://${serviceData.domains[0].name}` : undefined),
|
||||
upgradeState: await this.getUpgradeState(serviceArg),
|
||||
};
|
||||
serviceData.hostedAppLifecycle = state;
|
||||
serviceArg.data = serviceData;
|
||||
await serviceArg.save();
|
||||
return state;
|
||||
}
|
||||
|
||||
private async updateLifecycleState(serviceArg: Service, stateArg: IHostedAppLifecycleState): Promise<IHostedAppLifecycleState> {
|
||||
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||
serviceData.hostedAppLifecycle = stateArg;
|
||||
serviceArg.data = serviceData;
|
||||
await serviceArg.save();
|
||||
return await this.getLifecycleState(serviceArg);
|
||||
}
|
||||
|
||||
private registerHandlers() {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_ReportLifecycleState>(
|
||||
'hostedAppReportLifecycleState',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
...dataArg.report,
|
||||
appInstanceId: existingState.appInstanceId,
|
||||
hostType: 'cloudly',
|
||||
reportedAt: Date.now(),
|
||||
});
|
||||
return { state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetLifecycleState>(
|
||||
'hostedAppGetLifecycleState',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
return { state: await this.getLifecycleState(service) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||
'hostedAppRequestBootstrapAction',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const now = Date.now();
|
||||
const action = {
|
||||
...dataArg.action,
|
||||
id: dataArg.action.id || plugins.smartunique.shortId(12),
|
||||
status: 'ready' as const,
|
||||
label: dataArg.action.label || 'Initial setup',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
runtimeStatus: 'setupRequired',
|
||||
bootstrapAction: action,
|
||||
reportedAt: now,
|
||||
});
|
||||
return { action, state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||
'hostedAppCompleteBootstrapAction',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const now = Date.now();
|
||||
const bootstrapAction = existingState.bootstrapAction
|
||||
? {
|
||||
...existingState.bootstrapAction,
|
||||
id: dataArg.actionId || existingState.bootstrapAction.id,
|
||||
status: 'completed' as const,
|
||||
message: dataArg.message || existingState.bootstrapAction.message,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
}
|
||||
: undefined;
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
runtimeStatus: existingState.runtimeStatus === 'setupRequired' ? 'running' : existingState.runtimeStatus,
|
||||
bootstrapAction,
|
||||
reportedAt: now,
|
||||
});
|
||||
return { state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
|
||||
'hostedAppStartManagedUpgrade',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
const upgradeState = await this.getUpgradeState(service);
|
||||
const targetVersion = dataArg.targetVersion || upgradeState.targetVersion || upgradeState.latestVersion;
|
||||
if (!targetVersion) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No managed upgrade target is available');
|
||||
}
|
||||
const operation = await this.cloudlyRef.appStoreManager.startHostedAppUpgrade(service.id, targetVersion);
|
||||
const nextUpgradeState: IHostedAppUpgradeState = {
|
||||
status: 'running',
|
||||
appTemplateId: operation.appTemplateId,
|
||||
currentVersion: operation.fromVersion,
|
||||
targetVersion: operation.targetVersion,
|
||||
operationId: operation.id,
|
||||
warnings: operation.warnings,
|
||||
startedAt: operation.startedAt,
|
||||
updatedAt: operation.updatedAt,
|
||||
};
|
||||
const existingState = await this.getLifecycleState(service);
|
||||
const state = await this.updateLifecycleState(service, {
|
||||
...existingState,
|
||||
upgradeState: nextUpgradeState,
|
||||
reportedAt: Date.now(),
|
||||
});
|
||||
return { upgradeState: nextUpgradeState, state };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
|
||||
'hostedAppGetManagedUpgradeStatus',
|
||||
async (dataArg) => {
|
||||
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||
return { upgradeState: await this.getUpgradeState(service) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_GetHostedAppParentUpgradeStatus>(
|
||||
'getHostedAppParentUpgradeStatus',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return await this.getParentUpgradeStatus();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_StartHostedAppParentUpgrade>(
|
||||
'startHostedAppParentUpgrade',
|
||||
async (dataArg) => {
|
||||
await this.passAdminIdentity(dataArg);
|
||||
return await this.startParentUpgrade(dataArg.targetVersion);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
|
||||
await plugins.smartguard.passGuardsOrReject(dataArg, [
|
||||
this.cloudlyRef.authManager.adminIdentityGuard,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface IJumpCodeData {
|
||||
clusterId: string;
|
||||
createdBy: string;
|
||||
role: plugins.servezoneInterfaces.data.IClusterNode['data']['role'];
|
||||
nodeType: plugins.servezoneInterfaces.data.IClusterNode['data']['nodeType'];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
consumedAt?: number;
|
||||
consumedByNodeId?: string;
|
||||
}
|
||||
|
||||
export interface IJumpCodePublic {
|
||||
id: string;
|
||||
data: IJumpCodeData;
|
||||
}
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class JumpCode extends plugins.smartdata.SmartDataDbDoc<JumpCode, IJumpCodePublic> {
|
||||
constructor(optionsArg?: IJumpCodePublic & { tokenHash?: string }) {
|
||||
super();
|
||||
if (optionsArg) {
|
||||
Object.assign(this, optionsArg);
|
||||
}
|
||||
}
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tokenHash!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data!: IJumpCodeData;
|
||||
|
||||
public toPublicObject(): IJumpCodePublic {
|
||||
return {
|
||||
id: this.id,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { JumpCode } from './classes.jumpcode.js';
|
||||
|
||||
type IReqCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['request'];
|
||||
type IResCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['response'];
|
||||
|
||||
interface IClaimJumpCodeRequest {
|
||||
jumpCode?: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
interface IClaimJumpCodeResponse {
|
||||
accepted: boolean;
|
||||
message?: string;
|
||||
nodeId?: string;
|
||||
sparkNodeToken?: string;
|
||||
cloudlyUrl?: string;
|
||||
coreflowJumpCode?: string;
|
||||
}
|
||||
|
||||
export class CloudlyJumpManager {
|
||||
public cloudlyRef: Cloudly;
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
public CJumpCode = plugins.smartdata.setDefaultManagerForDoc(this, JumpCode);
|
||||
|
||||
public get db() {
|
||||
return this.cloudlyRef.mongodbConnector.smartdataDb;
|
||||
}
|
||||
|
||||
private defaultTtlMs = 1000 * 60 * 30;
|
||||
private maxTtlMs = 1000 * 60 * 60 * 24;
|
||||
|
||||
constructor(cloudlyRefArg: Cloudly) {
|
||||
this.cloudlyRef = cloudlyRefArg;
|
||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand>('createNodeJumpCommand', async (requestDataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(
|
||||
{ identity: requestDataArg.identity },
|
||||
[this.cloudlyRef.authManager.adminIdentityGuard],
|
||||
);
|
||||
return await this.createNodeJumpCommand(requestDataArg);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
logger.log('info', 'Jump manager started');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
logger.log('info', 'Jump manager stopped');
|
||||
}
|
||||
|
||||
public async createNodeJumpCommand(optionsArg: IReqCreateNodeJumpCommand): Promise<IResCreateNodeJumpCommand> {
|
||||
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
|
||||
id: optionsArg.clusterId,
|
||||
});
|
||||
if (!cluster) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Cluster ${optionsArg.clusterId} not found`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const ttlMs = this.normalizeTtl(optionsArg.ttlMs);
|
||||
const jumpCode = this.createJumpCode();
|
||||
const jumpCodeDoc = new this.CJumpCode({
|
||||
id: await this.CJumpCode.getNewId(),
|
||||
tokenHash: this.hashSecret(jumpCode),
|
||||
data: {
|
||||
clusterId: cluster.id,
|
||||
createdBy: optionsArg.identity.userId,
|
||||
role: optionsArg.role || 'worker',
|
||||
nodeType: optionsArg.nodeType || 'baremetal',
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
},
|
||||
});
|
||||
await jumpCodeDoc.save();
|
||||
|
||||
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCode)}`;
|
||||
const setupUrl = `${jumpUrl}/setup.sh`;
|
||||
return {
|
||||
jumpCode,
|
||||
jumpUrl,
|
||||
setupUrl,
|
||||
command: `curl -fsSL '${jumpUrl}' | sudo bash`,
|
||||
expiresAt: jumpCodeDoc.data.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
public async handleJumpHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||
const jumpCode = this.getCodeFromContext(ctxArg);
|
||||
if (this.shouldRenderHtml(ctxArg)) {
|
||||
return await this.createLandingPageResponse(jumpCode);
|
||||
}
|
||||
return await this.createSetupScriptResponse(jumpCode);
|
||||
}
|
||||
|
||||
public async handleSetupScriptHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||
return await this.createSetupScriptResponse(this.getCodeFromContext(ctxArg));
|
||||
}
|
||||
|
||||
public async handleClaimHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
|
||||
try {
|
||||
const requestData = await this.readJsonBody<IClaimJumpCodeRequest>(ctxArg);
|
||||
const response = await this.claimJumpCode(requestData);
|
||||
return this.createJsonResponse(200, response);
|
||||
} catch (error) {
|
||||
return this.createJsonResponse(400, {
|
||||
accepted: false,
|
||||
message: (error as Error).message,
|
||||
} satisfies IClaimJumpCodeResponse);
|
||||
}
|
||||
}
|
||||
|
||||
public async claimJumpCode(requestDataArg: IClaimJumpCodeRequest): Promise<IClaimJumpCodeResponse> {
|
||||
if (!requestDataArg.jumpCode) {
|
||||
throw new Error('Jump code is missing');
|
||||
}
|
||||
|
||||
const jumpCodeDoc = await this.getJumpCodeByCode(requestDataArg.jumpCode);
|
||||
if (!jumpCodeDoc) {
|
||||
throw new Error('Jump code is invalid');
|
||||
}
|
||||
if (jumpCodeDoc.data.consumedAt) {
|
||||
throw new Error('Jump code has already been used');
|
||||
}
|
||||
if (jumpCodeDoc.data.expiresAt <= Date.now()) {
|
||||
throw new Error('Jump code has expired');
|
||||
}
|
||||
|
||||
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
|
||||
id: jumpCodeDoc.data.clusterId,
|
||||
});
|
||||
if (!cluster) {
|
||||
throw new Error('Jump code references a missing cluster');
|
||||
}
|
||||
|
||||
const clusterUser = await this.cloudlyRef.authManager.CUser.getInstance({
|
||||
id: cluster.data.userId,
|
||||
});
|
||||
const coreflowJumpCode = clusterUser?.data.tokens?.find((tokenArg) => tokenArg.expiresAt > Date.now())?.token;
|
||||
if (!coreflowJumpCode) {
|
||||
throw new Error('Cluster runtime token is missing or expired');
|
||||
}
|
||||
|
||||
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,
|
||||
status: 'initializing',
|
||||
role: jumpCodeDoc.data.role,
|
||||
joinedAt: now,
|
||||
lastHealthCheck: now,
|
||||
sshKeys: [],
|
||||
requiredDebianPackages: [],
|
||||
};
|
||||
await node.save();
|
||||
|
||||
cluster.data.nodes = [
|
||||
...(cluster.data.nodes || []).filter((nodeArg) => nodeArg.id !== node.id),
|
||||
await node.createSavableObject(),
|
||||
];
|
||||
await cluster.save();
|
||||
|
||||
jumpCodeDoc.data = {
|
||||
...jumpCodeDoc.data,
|
||||
consumedAt: now,
|
||||
consumedByNodeId: node.id,
|
||||
};
|
||||
await jumpCodeDoc.save();
|
||||
|
||||
return {
|
||||
accepted: true,
|
||||
nodeId: node.id,
|
||||
sparkNodeToken,
|
||||
cloudlyUrl: cluster.data.cloudlyUrl || `${this.getPublicCloudlyUrl()}/`,
|
||||
coreflowJumpCode,
|
||||
};
|
||||
}
|
||||
|
||||
private async createLandingPageResponse(jumpCodeArg: string) {
|
||||
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
|
||||
let clusterName = 'Unknown cluster';
|
||||
let isUsable = false;
|
||||
if (jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now()) {
|
||||
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
|
||||
id: jumpCodeDoc.data.clusterId,
|
||||
});
|
||||
clusterName = cluster?.data.name || jumpCodeDoc.data.clusterId;
|
||||
isUsable = true;
|
||||
}
|
||||
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCodeArg)}`;
|
||||
const command = `curl -fsSL '${jumpUrl}' | sudo bash`;
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cloudly Jump</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d1117; color: #f0f6fc; }
|
||||
main { max-width: 760px; margin: 10vh auto; padding: 32px; }
|
||||
.card { border: 1px solid #30363d; border-radius: 18px; background: #161b22; padding: 28px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
|
||||
.label { color: #8b949e; font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
h1 { margin: 8px 0 12px; font-size: 34px; }
|
||||
p { color: #c9d1d9; line-height: 1.55; }
|
||||
pre { white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787; }
|
||||
.status { display: inline-block; margin-top: 16px; padding: 6px 10px; border-radius: 999px; background: ${isUsable ? '#17391f' : '#3d1d1d'}; color: ${isUsable ? '#7ee787' : '#ff7b72'}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<div class="label">Cloudly Jump</div>
|
||||
<h1>Connect System</h1>
|
||||
<p>Cluster: <strong>${this.escapeHtml(clusterName)}</strong></p>
|
||||
<p>Run this command on the Linux system you want to connect:</p>
|
||||
<pre>${this.escapeHtml(command)}</pre>
|
||||
<div class="status">${isUsable ? 'Ready to use' : 'This jump code is invalid, expired, or already used'}</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
return new Response(html, {
|
||||
status: isUsable ? 200 : 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createSetupScriptResponse(jumpCodeArg: string) {
|
||||
if (!jumpCodeArg || !(await this.isJumpCodeUsable(jumpCodeArg))) {
|
||||
return new Response('jump code is invalid, expired, or already used\n', {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(this.createSetupScript(jumpCodeArg), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sh; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createSetupScript(jumpCodeArg: string) {
|
||||
const claimUrl = `${this.getPublicCloudlyUrl()}/jump/v1/claim`;
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Cloudly jump setup must run as root. Re-run with sudo." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export JUMP_CODE='${this.escapeShellValue(jumpCodeArg)}'
|
||||
export CLAIM_URL='${this.escapeShellValue(claimUrl)}'
|
||||
|
||||
echo "Preparing system for Cloudly jump..."
|
||||
apt-get update
|
||||
apt-get install -y --force-yes curl ca-certificates git
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
curl -sSL https://get.docker.com/ | sh
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
curl -sL https://deb.nodesource.com/setup_18.x | bash
|
||||
apt-get install -y --force-yes nodejs
|
||||
fi
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
fi
|
||||
|
||||
export PNPM_HOME="\${PNPM_HOME:-/root/.local/share/pnpm}"
|
||||
export PATH="\${PNPM_HOME}:\${PATH}"
|
||||
|
||||
pnpm install -g @serve.zone/spark
|
||||
|
||||
REQUEST_BODY="$(node -e 'process.stdout.write(JSON.stringify({ jumpCode: process.env.JUMP_CODE, hostname: require("os").hostname() }))')"
|
||||
CLAIM_RESPONSE="$(curl -fsSL -X POST "\${CLAIM_URL}" -H 'content-type: application/json' --data "\${REQUEST_BODY}")"
|
||||
|
||||
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}" --nodeId="\${SPARK_NODE_ID}" --nodeToken="\${SPARK_NODE_TOKEN}"
|
||||
|
||||
echo "Cloudly jump completed. This system is now connected."
|
||||
`;
|
||||
}
|
||||
|
||||
private async getJumpCodeByCode(jumpCodeArg: string) {
|
||||
const jumpCodes = await this.CJumpCode.getInstances({
|
||||
tokenHash: this.hashSecret(jumpCodeArg),
|
||||
});
|
||||
return jumpCodes[0] || null;
|
||||
}
|
||||
|
||||
private async isJumpCodeUsable(jumpCodeArg: string) {
|
||||
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
|
||||
return Boolean(jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now());
|
||||
}
|
||||
|
||||
private getCodeFromContext(ctxArg: plugins.typedserver.IRequestContext) {
|
||||
return ctxArg.params.code || ctxArg.url.pathname.split('/').filter(Boolean)[1] || '';
|
||||
}
|
||||
|
||||
private shouldRenderHtml(ctxArg: plugins.typedserver.IRequestContext) {
|
||||
const acceptHeader = ctxArg.headers.get('accept') || '';
|
||||
const userAgent = ctxArg.headers.get('user-agent') || '';
|
||||
return acceptHeader.includes('text/html') && !/(curl|wget|httpie|fetch)/i.test(userAgent);
|
||||
}
|
||||
|
||||
private createJumpCode() {
|
||||
return plugins.crypto.randomBytes(12).toString('base64url');
|
||||
}
|
||||
|
||||
private normalizeTtl(ttlMsArg?: number) {
|
||||
if (!ttlMsArg || !Number.isFinite(ttlMsArg)) {
|
||||
return this.defaultTtlMs;
|
||||
}
|
||||
return Math.min(Math.max(ttlMsArg, 1000 * 60), this.maxTtlMs);
|
||||
}
|
||||
|
||||
private hashSecret(secretArg: string) {
|
||||
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
|
||||
}
|
||||
|
||||
private getPublicCloudlyUrl() {
|
||||
const sslMode = this.cloudlyRef.config.data.sslMode;
|
||||
const protocol = sslMode === 'none' ? 'http' : 'https';
|
||||
const port = String(this.cloudlyRef.config.data.publicPort || (protocol === 'https' ? '443' : '80'));
|
||||
const includePort = !((protocol === 'https' && port === '443') || (protocol === 'http' && port === '80'));
|
||||
return `${protocol}://${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${port}` : ''}`;
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private escapeHtml(valueArg: string) {
|
||||
return valueArg
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
private escapeShellValue(valueArg: string) {
|
||||
return valueArg.replaceAll("'", "'\\''");
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -8,9 +8,10 @@ export { path, crypto, stream, fsPromises };
|
||||
|
||||
// @apiglobal scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export { typedrequest, typedsocket };
|
||||
export { typedrequest, typedrequestInterfaces, typedsocket };
|
||||
|
||||
// @apiclient.xyz scope
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
@@ -86,5 +87,6 @@ export {
|
||||
|
||||
// @servezone scope
|
||||
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
||||
import * as servezoneAppstore from '@serve.zone/appstore';
|
||||
|
||||
export { servezoneInterfaces };
|
||||
export { servezoneAppstore, servezoneInterfaces };
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/cloudly',
|
||||
version: '5.6.0',
|
||||
version: '6.4.0',
|
||||
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
|
||||
}
|
||||
|
||||
+509
-37
@@ -11,6 +11,158 @@ export const loginStatePart: plugins.smartstate.StatePart<unknown, ILoginState>
|
||||
'persistent'
|
||||
);
|
||||
|
||||
export interface IUiState {
|
||||
activeView: string;
|
||||
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[];
|
||||
}
|
||||
|
||||
export type TAppStoreUpgradeStatus = 'running' | 'success' | 'failed';
|
||||
export type TAppStoreUpgradeStep =
|
||||
| 'queued'
|
||||
| 'validating'
|
||||
| 'migration'
|
||||
| 'applying'
|
||||
| 'updating-service'
|
||||
| 'pushing-config'
|
||||
| 'complete'
|
||||
| 'failed';
|
||||
|
||||
export interface IAppStoreUpgradeChange {
|
||||
field: string;
|
||||
currentValue: string;
|
||||
targetValue: string;
|
||||
}
|
||||
|
||||
export interface IAppStoreUpgradePreview {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
appTemplateId: string;
|
||||
fromVersion: string;
|
||||
targetVersion: string;
|
||||
resolvedTargetVersion: string;
|
||||
hasMigration: boolean;
|
||||
requiresManualReview: boolean;
|
||||
changes: IAppStoreUpgradeChange[];
|
||||
warnings: string[];
|
||||
blockers: string[];
|
||||
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
|
||||
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
|
||||
}
|
||||
|
||||
export interface IAppStoreUpgradeOperation {
|
||||
id: string;
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
appTemplateId: string;
|
||||
fromVersion: string;
|
||||
targetVersion: string;
|
||||
status: TAppStoreUpgradeStatus;
|
||||
step: TAppStoreUpgradeStep;
|
||||
progressLines: string[];
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
completedAt?: number;
|
||||
service?: plugins.interfaces.data.IService;
|
||||
}
|
||||
|
||||
export interface IAppStoreState {
|
||||
apps: plugins.interfaces.appstore.IAppStoreApp[];
|
||||
upgradeableServices: Array<plugins.interfaces.appstore.IUpgradeableAppStoreService & { serviceId?: string }>;
|
||||
upgradeOperations: IAppStoreUpgradeOperation[];
|
||||
}
|
||||
|
||||
export interface IHostedRuntimeState {
|
||||
isHosted: boolean;
|
||||
loading: boolean;
|
||||
unavailableReason?: string;
|
||||
upgradeState: plugins.interfaces.data.IHostedAppUpgradeState | null;
|
||||
}
|
||||
|
||||
const emptyDataState: IDataState = {
|
||||
secretGroups: [],
|
||||
secretBundles: [],
|
||||
clusters: [],
|
||||
externalRegistries: [],
|
||||
images: [],
|
||||
services: [],
|
||||
deployments: [],
|
||||
domains: [],
|
||||
dnsEntries: [],
|
||||
tasks: [],
|
||||
taskExecutions: [],
|
||||
mails: [],
|
||||
logs: [],
|
||||
s3: [],
|
||||
dbs: [],
|
||||
backups: [],
|
||||
};
|
||||
|
||||
const emptyAppStoreState: IAppStoreState = {
|
||||
apps: [],
|
||||
upgradeableServices: [],
|
||||
upgradeOperations: [],
|
||||
};
|
||||
|
||||
const emptyHostedRuntimeState: IHostedRuntimeState = {
|
||||
isHosted: false,
|
||||
loading: false,
|
||||
upgradeState: null,
|
||||
};
|
||||
|
||||
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'];
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
};
|
||||
|
||||
const getInitialSubview = (): string | null => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[1] ?? null;
|
||||
};
|
||||
|
||||
export const uiStatePart = await appstate.getStatePart<IUiState>(
|
||||
'ui',
|
||||
{
|
||||
activeView: getInitialView(),
|
||||
activeSubview: getInitialSubview(),
|
||||
},
|
||||
);
|
||||
|
||||
export const loginAction = loginStatePart.createAction<{ username: string; password: string }>(
|
||||
async (statePartArg, payloadArg) => {
|
||||
const currentState = statePartArg.getState() || { identity: null };
|
||||
@@ -22,7 +174,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
|
||||
@@ -40,53 +192,37 @@ 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 });
|
||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
} 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'
|
||||
);
|
||||
|
||||
export const appStoreStatePart = await appstate.getStatePart<IAppStoreState>(
|
||||
'appstore',
|
||||
{ ...emptyAppStoreState },
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const hostedRuntimeStatePart = await appstate.getStatePart<IHostedRuntimeState>(
|
||||
'hostedRuntime',
|
||||
{ ...emptyHostedRuntimeState },
|
||||
'soft',
|
||||
);
|
||||
|
||||
// Shared API client instance (used by UI actions)
|
||||
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
|
||||
identity: plugins.interfaces.data.IIdentity | null;
|
||||
@@ -97,6 +233,166 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({
|
||||
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
|
||||
}) as TCloudlyApiClientWithNullableIdentity;
|
||||
|
||||
const upsertUpgradeOperation = (
|
||||
operationsArg: IAppStoreUpgradeOperation[],
|
||||
operationArg: IAppStoreUpgradeOperation,
|
||||
) => {
|
||||
const operations = operationsArg.filter((existingOperation) => existingOperation.id !== operationArg.id);
|
||||
operations.unshift(operationArg);
|
||||
return operations.slice(0, 25);
|
||||
};
|
||||
|
||||
const upsertService = (
|
||||
servicesArg: plugins.interfaces.data.IService[] = [],
|
||||
serviceArg: plugins.interfaces.data.IService,
|
||||
) => {
|
||||
const services = servicesArg.filter((existingService) => existingService.id !== serviceArg.id);
|
||||
services.unshift(serviceArg);
|
||||
return services;
|
||||
};
|
||||
|
||||
apiClient.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<any>(
|
||||
'pushAppStoreUpgradeProgress',
|
||||
async (dataArg: { operation: IAppStoreUpgradeOperation }) => {
|
||||
const appStoreState = appStoreStatePart.getState() || {
|
||||
apps: [],
|
||||
upgradeableServices: [],
|
||||
upgradeOperations: [],
|
||||
};
|
||||
appStoreStatePart.setState({
|
||||
...appStoreState,
|
||||
upgradeOperations: upsertUpgradeOperation(appStoreState.upgradeOperations, dataArg.operation),
|
||||
upgradeableServices: dataArg.operation.status === 'success'
|
||||
? appStoreState.upgradeableServices.filter((serviceArg) => {
|
||||
return serviceArg.serviceId !== dataArg.operation.serviceId && serviceArg.serviceName !== dataArg.operation.serviceName;
|
||||
})
|
||||
: appStoreState.upgradeableServices,
|
||||
});
|
||||
if (dataArg.operation.service) {
|
||||
const currentDataState = dataState.getState() || {};
|
||||
dataState.setState({
|
||||
...currentDataState,
|
||||
services: upsertService(currentDataState.services, dataArg.operation.service),
|
||||
});
|
||||
}
|
||||
return {};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let identityExpiryTimer: number | undefined;
|
||||
let identityInvalidationRunning = false;
|
||||
|
||||
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 });
|
||||
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||
hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState });
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
} 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() || {};
|
||||
@@ -583,3 +879,179 @@ export const addClusterAction = dataState.createAction(
|
||||
return await context.dispatch(getAllDataAction, null);
|
||||
}
|
||||
);
|
||||
|
||||
const getIdentityForRequest = () => {
|
||||
const identity = loginStatePart.getState()?.identity ?? null;
|
||||
if (!identity) {
|
||||
throw new Error('No Cloudly identity is available');
|
||||
}
|
||||
return identity;
|
||||
};
|
||||
|
||||
let hostedRuntimePollTimer: number | undefined;
|
||||
|
||||
function clearHostedRuntimeUpgradePoll() {
|
||||
if (hostedRuntimePollTimer) {
|
||||
window.clearTimeout(hostedRuntimePollTimer);
|
||||
hostedRuntimePollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleHostedRuntimeUpgradePoll = (stateArg: IHostedRuntimeState) => {
|
||||
clearHostedRuntimeUpgradePoll();
|
||||
if (stateArg.upgradeState?.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
hostedRuntimePollTimer = window.setTimeout(() => {
|
||||
void hostedRuntimeStatePart.dispatchAction(fetchHostedRuntimeUpgradeStatusAction, null);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
export const fetchHostedRuntimeUpgradeStatusAction = hostedRuntimeStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState };
|
||||
statePartArg.setState({ ...currentState, loading: true });
|
||||
try {
|
||||
const request = new plugins.typedrequest.TypedRequest<plugins.interfaces.requests.hostedapp.IReq_Admin_GetHostedAppParentUpgradeStatus>(
|
||||
'/typedrequest',
|
||||
'getHostedAppParentUpgradeStatus',
|
||||
);
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
const nextState: IHostedRuntimeState = {
|
||||
isHosted: response.isHosted,
|
||||
loading: false,
|
||||
unavailableReason: response.unavailableReason,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
} catch (error) {
|
||||
const nextState: IHostedRuntimeState = {
|
||||
...currentState,
|
||||
loading: false,
|
||||
unavailableReason: getErrorText(error) || 'Could not load hosted runtime status.',
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const startHostedRuntimeParentUpgradeAction = hostedRuntimeStatePart.createAction<{
|
||||
targetVersion?: string;
|
||||
} | null>(
|
||||
async (statePartArg, payloadArg) => {
|
||||
const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState };
|
||||
statePartArg.setState({ ...currentState, loading: true });
|
||||
try {
|
||||
const request = new plugins.typedrequest.TypedRequest<plugins.interfaces.requests.hostedapp.IReq_Admin_StartHostedAppParentUpgrade>(
|
||||
'/typedrequest',
|
||||
'startHostedAppParentUpgrade',
|
||||
);
|
||||
const response = await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
targetVersion: payloadArg?.targetVersion,
|
||||
});
|
||||
const nextState: IHostedRuntimeState = {
|
||||
isHosted: response.isHosted,
|
||||
loading: false,
|
||||
unavailableReason: response.unavailableReason,
|
||||
upgradeState: response.upgradeState,
|
||||
};
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
return nextState;
|
||||
} catch (error) {
|
||||
const nextState: IHostedRuntimeState = {
|
||||
...currentState,
|
||||
loading: false,
|
||||
unavailableReason: getErrorText(error) || 'Could not start hosted runtime upgrade.',
|
||||
};
|
||||
statePartArg.setState(nextState);
|
||||
scheduleHostedRuntimeUpgradePoll(nextState);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
return {
|
||||
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||
apps: response.apps || [],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getUpgradeableAppStoreServices');
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
return {
|
||||
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||
upgradeableServices: response.services || [],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradeOperations');
|
||||
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||
return {
|
||||
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||
upgradeOperations: response.operations || [],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const startAppStoreServiceUpgradeAction = appStoreStatePart.createAction<{
|
||||
serviceId: string;
|
||||
targetVersion: string;
|
||||
}>(
|
||||
async (statePartArg, payloadArg) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'startAppStoreServiceUpgrade');
|
||||
const response = await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
serviceId: payloadArg.serviceId,
|
||||
targetVersion: payloadArg.targetVersion,
|
||||
});
|
||||
const currentState = statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] };
|
||||
return {
|
||||
...currentState,
|
||||
upgradeOperations: upsertUpgradeOperation(currentState.upgradeOperations, response.operation),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreConfig');
|
||||
return await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
appId: appIdArg,
|
||||
version: versionArg,
|
||||
}) as {
|
||||
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
|
||||
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
|
||||
};
|
||||
};
|
||||
|
||||
export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradePreview');
|
||||
const response = await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
serviceId: serviceIdArg,
|
||||
targetVersion: targetVersionArg,
|
||||
});
|
||||
return response.preview as IAppStoreUpgradePreview;
|
||||
};
|
||||
|
||||
export const installAppStoreApp = async (installArg: plugins.interfaces.appstore.IAppStoreInstallRequest) => {
|
||||
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'installAppStoreApp');
|
||||
const response = await request.fire({
|
||||
identity: getIdentityForRequest(),
|
||||
install: installArg,
|
||||
});
|
||||
return response.service as plugins.interfaces.data.IService;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { commitinfo } from '../00_commitinfo_data.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import * as appstate from '../appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -18,6 +19,7 @@ import { CloudlyViewDbs } from './views/dbs/index.js';
|
||||
import { CloudlyViewDeployments } from './views/deployments/index.js';
|
||||
import { CloudlyViewDns } from './views/dns/index.js';
|
||||
import { CloudlyViewDomains } from './views/domains/index.js';
|
||||
import { CloudlyViewAppStore } from './views/appstore/index.js';
|
||||
import { CloudlyViewImages } from './views/images/index.js';
|
||||
import { CloudlyViewLogs } from './views/logs/index.js';
|
||||
import { CloudlyViewMails } from './views/mails/index.js';
|
||||
@@ -36,6 +38,11 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
interface ICloudlyView extends plugins.deesCatalog.IView {
|
||||
slug?: string;
|
||||
subViews?: ICloudlyView[];
|
||||
}
|
||||
|
||||
@customElement('cloudly-dashboard')
|
||||
export class CloudlyDashboard extends DeesElement {
|
||||
@state() private accessor identity: plugins.interfaces.data.IIdentity | null = null;
|
||||
@@ -44,75 +51,109 @@ export class CloudlyDashboard extends DeesElement {
|
||||
secretBundles: [],
|
||||
clusters: [],
|
||||
};
|
||||
@state() private accessor uiState: appstate.IUiState = {
|
||||
activeView: 'overview',
|
||||
activeSubview: null,
|
||||
};
|
||||
|
||||
// Keep view tabs stable across renders to preserve active selection
|
||||
private readonly viewTabs: plugins.deesCatalog.IView[] = [
|
||||
private readonly viewTabs: ICloudlyView[] = [
|
||||
{
|
||||
slug: 'overview',
|
||||
name: 'Overview',
|
||||
iconName: 'lucide:LayoutDashboard',
|
||||
element: CloudlyViewOverview,
|
||||
},
|
||||
{
|
||||
slug: 'platform',
|
||||
name: 'Platform',
|
||||
iconName: 'lucide:Settings',
|
||||
subViews: [
|
||||
{ name: 'Settings', iconName: 'lucide:Settings', element: CloudlyViewSettings },
|
||||
{ name: 'BaseOS', iconName: 'lucide:HardDriveDownload', element: CloudlyViewBaseOs },
|
||||
{ name: 'Fleet', iconName: 'lucide:Truck', element: CloudlyViewBackups },
|
||||
{ slug: 'settings', name: 'Settings', iconName: 'lucide:Settings', element: CloudlyViewSettings },
|
||||
{ slug: 'baseos', name: 'BaseOS', iconName: 'lucide:HardDriveDownload', element: CloudlyViewBaseOs },
|
||||
{ slug: 'fleet', name: 'Fleet', iconName: 'lucide:Truck', element: CloudlyViewBackups },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'runtime',
|
||||
name: 'Runtime',
|
||||
iconName: 'lucide:Network',
|
||||
subViews: [
|
||||
{ name: 'Clusters', iconName: 'lucide:Network', element: CloudlyViewClusters },
|
||||
{ name: 'Services', iconName: 'lucide:Layers', element: CloudlyViewServices },
|
||||
{ name: 'Images', iconName: 'lucide:Image', element: CloudlyViewImages },
|
||||
{ name: 'Deployments', iconName: 'lucide:Rocket', element: CloudlyViewDeployments },
|
||||
{ name: 'Tasks', iconName: 'lucide:ListChecks', element: CloudlyViewTasks },
|
||||
{ slug: 'clusters', name: 'Clusters', iconName: 'lucide:Network', element: CloudlyViewClusters },
|
||||
{ slug: 'appstore', name: 'App Store', iconName: 'lucide:Store', element: CloudlyViewAppStore },
|
||||
{ slug: 'services', name: 'Services', iconName: 'lucide:Layers', element: CloudlyViewServices },
|
||||
{ slug: 'images', name: 'Images', iconName: 'lucide:Image', element: CloudlyViewImages },
|
||||
{ slug: 'deployments', name: 'Deployments', iconName: 'lucide:Rocket', element: CloudlyViewDeployments },
|
||||
{ slug: 'tasks', name: 'Tasks', iconName: 'lucide:ListChecks', element: CloudlyViewTasks },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'registry',
|
||||
name: 'Registry & Build',
|
||||
iconName: 'lucide:Package',
|
||||
subViews: [
|
||||
{ name: 'ExternalRegistries', iconName: 'lucide:Package', element: CloudlyViewExternalRegistries },
|
||||
{ name: 'Testing & Building', iconName: 'lucide:HardHat', element: CloudlyViewServices },
|
||||
{ slug: 'externalregistries', name: 'ExternalRegistries', iconName: 'lucide:Package', element: CloudlyViewExternalRegistries },
|
||||
{ slug: 'testing', name: 'Testing & Building', iconName: 'lucide:HardHat', element: CloudlyViewServices },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'secrets',
|
||||
name: 'Secrets',
|
||||
iconName: 'lucide:ShieldCheck',
|
||||
subViews: [
|
||||
{ name: 'SecretGroups', iconName: 'lucide:ShieldCheck', element: CloudlyViewSecretGroups },
|
||||
{ name: 'SecretBundles', iconName: 'lucide:LockKeyhole', element: CloudlyViewSecretBundles },
|
||||
{ slug: 'secretgroups', name: 'SecretGroups', iconName: 'lucide:ShieldCheck', element: CloudlyViewSecretGroups },
|
||||
{ slug: 'secretbundles', name: 'SecretBundles', iconName: 'lucide:LockKeyhole', element: CloudlyViewSecretBundles },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'domains',
|
||||
name: 'Domains & Messaging',
|
||||
iconName: 'lucide:Globe2',
|
||||
subViews: [
|
||||
{ name: 'Domains', iconName: 'lucide:Globe2', element: CloudlyViewDomains },
|
||||
{ name: 'DNS', iconName: 'lucide:Globe', element: CloudlyViewDns },
|
||||
{ name: 'Mails', iconName: 'lucide:Mail', element: CloudlyViewMails },
|
||||
{ slug: 'domains', name: 'Domains', iconName: 'lucide:Globe2', element: CloudlyViewDomains },
|
||||
{ slug: 'dns', name: 'DNS', iconName: 'lucide:Globe', element: CloudlyViewDns },
|
||||
{ slug: 'mails', name: 'Mails', iconName: 'lucide:Mail', element: CloudlyViewMails },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'storage',
|
||||
name: 'Storage',
|
||||
iconName: 'lucide:Database',
|
||||
subViews: [
|
||||
{ name: 's3', iconName: 'lucide:Cloud', element: CloudlyViewS3 },
|
||||
{ name: 'DBs', iconName: 'lucide:Database', element: CloudlyViewDbs },
|
||||
{ name: 'Backups', iconName: 'lucide:Save', element: CloudlyViewBackups },
|
||||
{ slug: 's3', name: 's3', iconName: 'lucide:Cloud', element: CloudlyViewS3 },
|
||||
{ slug: 'dbs', name: 'DBs', iconName: 'lucide:Database', element: CloudlyViewDbs },
|
||||
{ slug: 'backups', name: 'Backups', iconName: 'lucide:Save', element: CloudlyViewBackups },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'logs',
|
||||
name: 'Logs',
|
||||
iconName: 'lucide:FileText',
|
||||
element: CloudlyViewLogs,
|
||||
},
|
||||
];
|
||||
|
||||
private slugFor(view: ICloudlyView): string {
|
||||
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
private findParent(view: ICloudlyView): ICloudlyView | undefined {
|
||||
return this.viewTabs.find((viewTab) => viewTab.subViews?.includes(view));
|
||||
}
|
||||
|
||||
private findViewBySlug(viewSlug: string, subviewSlug: string | null): ICloudlyView | undefined {
|
||||
const topLevelView = this.viewTabs.find((view) => this.slugFor(view) === viewSlug);
|
||||
if (!topLevelView) return undefined;
|
||||
if (subviewSlug && topLevelView.subViews) {
|
||||
return topLevelView.subViews.find((subview) => this.slugFor(subview) === subviewSlug) ?? topLevelView;
|
||||
}
|
||||
return topLevelView;
|
||||
}
|
||||
|
||||
private get currentViewTab(): ICloudlyView {
|
||||
return this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
document.title = `cloudly v${commitinfo.version}`;
|
||||
@@ -122,6 +163,35 @@ export class CloudlyDashboard extends DeesElement {
|
||||
this.data = dataArg;
|
||||
});
|
||||
this.rxSubscriptions.push(subcription);
|
||||
|
||||
const uiSubscription = appstate.uiStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((uiState) => {
|
||||
this.uiState = uiState;
|
||||
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 {
|
||||
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||
if (!appDash) return;
|
||||
|
||||
const targetView = this.findViewBySlug(viewSlug, subviewSlug);
|
||||
if (!targetView || appDash.selectedView === targetView) return;
|
||||
|
||||
appDash.loadView(targetView);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -146,6 +216,7 @@ export class CloudlyDashboard extends DeesElement {
|
||||
<dees-simple-login name="cloudly v${commitinfo.version}">
|
||||
<dees-simple-appdash name="cloudly v${commitinfo.version}"
|
||||
.viewTabs=${this.viewTabs}
|
||||
.selectedView=${this.currentViewTab}
|
||||
></dees-simple-appdash>
|
||||
</dees-simple-login>
|
||||
</div>
|
||||
@@ -155,14 +226,37 @@ export class CloudlyDashboard extends DeesElement {
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
simpleLogin.addEventListener('login', (eventArg: Event) => {
|
||||
const loginEvent = eventArg as CustomEvent;
|
||||
console.log(loginEvent.detail);
|
||||
this.login(loginEvent.detail.data.username, loginEvent.detail.data.password);
|
||||
});
|
||||
|
||||
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (eventArg: Event) => {
|
||||
const view = (eventArg as CustomEvent).detail.view as ICloudlyView;
|
||||
const parent = this.findParent(view);
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
if (parent) {
|
||||
const parentSlug = this.slugFor(parent);
|
||||
const subviewSlug = this.slugFor(view);
|
||||
if (currentState?.activeView === parentSlug && currentState?.activeSubview === subviewSlug) {
|
||||
return;
|
||||
}
|
||||
appRouter.navigateToView(parentSlug, subviewSlug);
|
||||
} else {
|
||||
const slug = this.slugFor(view);
|
||||
if (currentState?.activeView === slug && !currentState?.activeSubview) {
|
||||
return;
|
||||
}
|
||||
appRouter.navigateToView(slug);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.addEventListener('contextmenu', (eventArg) => {
|
||||
plugins.deesCatalog.DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'About',
|
||||
iconName: 'mugHot',
|
||||
iconName: 'lucide:Coffee',
|
||||
action: async () => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'About',
|
||||
@@ -185,11 +279,17 @@ export class CloudlyDashboard extends DeesElement {
|
||||
// lets deal with initial state
|
||||
const domtools = await this.domtoolsPromise;
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
console.log(loginState);
|
||||
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();
|
||||
}
|
||||
@@ -202,7 +302,6 @@ export class CloudlyDashboard extends DeesElement {
|
||||
|
||||
private async login(username: string, password: string) {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
console.log(`attempting to login...`);
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
const form = simpleLogin.shadowRoot.querySelector('dees-form') as any;
|
||||
form.setStatus('pending', 'Logging in...');
|
||||
@@ -211,7 +310,6 @@ export class CloudlyDashboard extends DeesElement {
|
||||
password,
|
||||
});
|
||||
if (state?.identity) {
|
||||
console.log('got jwt');
|
||||
this.identity = state.identity;
|
||||
form.setStatus('success', 'Logged in!');
|
||||
await simpleLogin.switchToSlottedContent();
|
||||
@@ -223,5 +321,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as shared from '../../shared/index.js';
|
||||
import * as appstate from '../../../appstate.js';
|
||||
import { appRouter } from '../../../router.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
type TEditableEnvVar = {
|
||||
key: string;
|
||||
value: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
platformInjected?: boolean;
|
||||
};
|
||||
|
||||
@customElement('cloudly-view-appstore')
|
||||
export class CloudlyViewAppStore extends DeesElement {
|
||||
@state()
|
||||
private accessor appStoreState: appstate.IAppStoreState = {
|
||||
apps: [],
|
||||
upgradeableServices: [],
|
||||
upgradeOperations: [],
|
||||
};
|
||||
|
||||
@state()
|
||||
private accessor currentView: 'grid' | 'detail' = 'grid';
|
||||
|
||||
@state()
|
||||
private accessor selectedApp: plugins.interfaces.appstore.IAppStoreApp | null = null;
|
||||
|
||||
@state()
|
||||
private accessor selectedAppMeta: plugins.interfaces.appstore.IAppStoreAppMeta | null = null;
|
||||
|
||||
@state()
|
||||
private accessor selectedAppConfig: plugins.interfaces.appstore.IAppStoreVersionConfig | null = null;
|
||||
|
||||
@state()
|
||||
private accessor configLoadError = '';
|
||||
|
||||
@state()
|
||||
private accessor selectedVersion = '';
|
||||
|
||||
@state()
|
||||
private accessor editableEnvVars: TEditableEnvVar[] = [];
|
||||
|
||||
@state()
|
||||
private accessor serviceName = '';
|
||||
|
||||
@state()
|
||||
private accessor serviceDomain = '';
|
||||
|
||||
@state()
|
||||
private accessor deployMode = false;
|
||||
|
||||
@state()
|
||||
private accessor loading = false;
|
||||
|
||||
private configRequestToken = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; margin-bottom: 14px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.title { margin: 0; color: var(--ci-shade-7, #e4e4e7); font-size: 24px; font-weight: 700; }
|
||||
.subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; line-height: 1.5; }
|
||||
.section-title { color: var(--ci-shade-7, #e4e4e7); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
||||
.badge { display: inline-flex; padding: 3px 9px; border-radius: 999px; background: rgba(59, 130, 246, 0.16); color: #60a5fa; font-size: 12px; margin: 0 6px 6px 0; }
|
||||
.button { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 7px; padding: 9px 13px; font-size: 13px; cursor: pointer; background: var(--ci-shade-1, #09090b); color: var(--ci-shade-7, #e4e4e7); }
|
||||
.button.primary { background: var(--ci-color-primary, #2563eb); border-color: var(--ci-color-primary, #2563eb); color: white; }
|
||||
.button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.actions { display: flex; gap: 10px; align-items: center; margin-top: 14px; }
|
||||
.field { display: grid; gap: 6px; margin-top: 12px; }
|
||||
.field label { color: var(--ci-shade-5, #a1a1aa); font-size: 12px; font-weight: 600; }
|
||||
input, select { width: 100%; box-sizing: border-box; background: var(--ci-shade-2, #27272a); border: 1px solid var(--ci-shade-3, #3f3f46); border-radius: 6px; padding: 9px 10px; color: var(--ci-shade-7, #e4e4e7); }
|
||||
.env-table { width: 100%; border-collapse: collapse; }
|
||||
.env-table th, .env-table td { text-align: left; padding: 7px 8px 7px 0; border-bottom: 1px solid var(--ci-shade-2, #27272a); vertical-align: top; }
|
||||
.env-key, .mono { font-family: monospace; color: var(--ci-shade-6, #d4d4d8); overflow-wrap: anywhere; }
|
||||
.muted { color: var(--ci-shade-4, #71717a); font-size: 12px; }
|
||||
.warning { margin-top: 10px; padding: 10px 12px; border-radius: 7px; background: rgba(245, 158, 11, 0.12); color: #fbbf24; font-size: 12px; }
|
||||
.operation { display: grid; gap: 7px; }
|
||||
.operation-log { max-height: 120px; overflow: auto; white-space: pre-wrap; font-family: monospace; font-size: 12px; color: var(--ci-shade-5, #a1a1aa); background: var(--ci-shade-0, #030305); border-radius: 6px; padding: 10px; }
|
||||
@media (max-width: 760px) { .header { flex-direction: column; } .actions { flex-direction: column; align-items: stretch; } }
|
||||
`,
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.appStoreStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((stateArg) => {
|
||||
this.appStoreState = stateArg;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
const loginSubscription = appstate.loginStatePart
|
||||
.select((stateArg) => stateArg.identity)
|
||||
.subscribe((identityArg) => {
|
||||
if (identityArg) {
|
||||
void this.refreshAppStoreData();
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.refreshAppStoreData();
|
||||
}
|
||||
|
||||
private async refreshAppStoreData() {
|
||||
if (!appstate.loginStatePart.getState()?.identity) {
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled([
|
||||
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreTemplatesAction, null),
|
||||
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
||||
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null),
|
||||
]);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.currentView === 'detail') {
|
||||
return this.renderDetailView();
|
||||
}
|
||||
return this.renderGridView();
|
||||
}
|
||||
|
||||
private renderGridView(): TemplateResult {
|
||||
return html`
|
||||
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
||||
${this.renderOperations()}
|
||||
<dees-table
|
||||
.heading1=${'App Store Apps'}
|
||||
.heading2=${'Install workload services that follow a serve.zone App Store template'}
|
||||
.data=${this.appStoreState.apps}
|
||||
.displayFunction=${(appArg: plugins.interfaces.appstore.IAppStoreApp) => ({
|
||||
Name: appArg.name,
|
||||
Category: html`<span class="badge">${appArg.category}</span>`,
|
||||
Version: appArg.latestVersion,
|
||||
Source: appArg.source?.type || 'curated',
|
||||
Tags: appArg.tags?.join(', ') || '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'lucide:Eye',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, false),
|
||||
},
|
||||
{
|
||||
name: 'Install',
|
||||
iconName: 'lucide:Download',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, true),
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOperations(): TemplateResult | '' {
|
||||
const operations = this.appStoreState.upgradeOperations
|
||||
.slice(0, 3);
|
||||
if (operations.length === 0) return '';
|
||||
return html`
|
||||
<div class="card">
|
||||
<div class="section-title">Recent Upgrade Operations</div>
|
||||
${operations.map((operationArg) => html`
|
||||
<div class="operation">
|
||||
<div class="mono">${operationArg.serviceName}: ${operationArg.fromVersion} -> ${operationArg.targetVersion} (${operationArg.status}/${operationArg.step})</div>
|
||||
<div class="operation-log">${operationArg.progressLines.slice(-6).join('\n')}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDetailView(): TemplateResult {
|
||||
const app = this.selectedApp;
|
||||
const meta = this.selectedAppMeta;
|
||||
const config = this.selectedAppConfig;
|
||||
if (this.configLoadError) {
|
||||
return html`
|
||||
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
||||
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back to App Store</button>
|
||||
<div class="card" style="margin-top: 14px;">
|
||||
<div class="section-title">Could not load app details</div>
|
||||
<div class="warning">${this.configLoadError}</div>
|
||||
<div class="actions">
|
||||
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back</button>
|
||||
${this.selectedApp ? html`<button class="button primary" @click=${async () => {
|
||||
this.loading = true;
|
||||
await this.fetchVersionConfig(this.selectedApp!.id, this.selectedVersion || this.selectedApp!.latestVersion);
|
||||
this.loading = false;
|
||||
}}>Retry</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.loading || !app || !config) {
|
||||
return html`<cloudly-sectionheading>App Store</cloudly-sectionheading><div class="card">Loading app details...</div>`;
|
||||
}
|
||||
const platformRequirements = config.platformRequirements || {};
|
||||
const enabledRequirements = Object.entries(platformRequirements).filter(([, enabled]) => enabled);
|
||||
const volumes = this.getConfigVolumes(config);
|
||||
const publishedPorts = config.publishedPorts || [];
|
||||
return html`
|
||||
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
||||
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back to App Store</button>
|
||||
<div class="card" style="margin-top: 14px;">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2 class="title">${app.name}</h2>
|
||||
<div class="subtitle">${app.description}</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<span class="badge">${app.category}</span>
|
||||
${app.tags?.map((tagArg) => html`<span class="badge">${tagArg}</span>`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mono">${config.image}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="section-title">Version</div>
|
||||
<select @change=${(eventArg: Event) => this.changeVersion((eventArg.target as HTMLSelectElement).value)}>
|
||||
${(meta?.versions || [this.selectedVersion]).map((versionArg) => html`
|
||||
<option value=${versionArg} ?selected=${versionArg === this.selectedVersion}>${versionArg}${versionArg === app.latestVersion ? ' (latest)' : ''}</option>
|
||||
`)}
|
||||
</select>
|
||||
${config.minCloudlyVersion ? html`<div class="muted" style="margin-top: 8px;">Requires Cloudly >= ${config.minCloudlyVersion}</div>` : ''}
|
||||
</div>
|
||||
|
||||
${enabledRequirements.length ? html`
|
||||
<div class="card">
|
||||
<div class="section-title">Platform Requirements</div>
|
||||
${enabledRequirements.map(([key]) => html`<span class="badge">${key}</span>`)}
|
||||
<div class="muted">Cloudly currently provisions MongoDB and S3 requirements through platform bindings.</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${(volumes.length || publishedPorts.length) ? html`
|
||||
<div class="card">
|
||||
<div class="section-title">Deployment Footprint</div>
|
||||
${volumes.map((volumeArg) => html`<div class="mono">Volume: ${volumeArg.source || volumeArg.name || 'managed'} -> ${volumeArg.mountPath}</div>`)}
|
||||
${publishedPorts.map((portArg) => html`<div class="mono">Published port: ${this.formatPublishedPort(portArg)}</div>`)}
|
||||
${publishedPorts.length ? html`<div class="warning">This app publishes raw host ports outside the HTTP proxy.</div>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.editableEnvVars.length ? html`
|
||||
<div class="card">
|
||||
<div class="section-title">Environment</div>
|
||||
<table class="env-table">
|
||||
<thead><tr><th>Key</th><th>Value</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
${this.editableEnvVars.map((envVarArg, indexArg) => html`
|
||||
<tr>
|
||||
<td class="env-key">${envVarArg.key}${envVarArg.required ? html` <span class="badge">required</span>` : ''}</td>
|
||||
<td><input .value=${envVarArg.value} ?disabled=${envVarArg.platformInjected || !this.deployMode} @input=${(eventArg: Event) => this.updateEnvVar(indexArg, (eventArg.target as HTMLInputElement).value)} /></td>
|
||||
<td class="muted">${envVarArg.description}${envVarArg.platformInjected ? ' Auto-injected by platform.' : ''}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.deployMode ? html`
|
||||
<div class="card">
|
||||
<div class="section-title">Install Service</div>
|
||||
<div class="field"><label>Service name</label><input .value=${this.serviceName} @input=${(eventArg: Event) => { this.serviceName = (eventArg.target as HTMLInputElement).value; }} /></div>
|
||||
<div class="field"><label>Domain</label><input .value=${this.serviceDomain} @input=${(eventArg: Event) => { this.serviceDomain = this.normalizeDomain((eventArg.target as HTMLInputElement).value); }} /></div>
|
||||
<div class="muted" style="margin-top: 8px;">Domain is required when the template uses SERVICE_DOMAIN.</div>
|
||||
<div class="actions">
|
||||
<button class="button" @click=${() => { this.deployMode = false; }}>Cancel</button>
|
||||
<button class="button primary" @click=${() => this.installSelectedApp()}>Install ${this.selectedVersion}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="actions">
|
||||
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back</button>
|
||||
<button class="button primary" @click=${() => { this.deployMode = true; }}>Install this App</button>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private async openApp(appArg: plugins.interfaces.appstore.IAppStoreApp, deployModeArg: boolean) {
|
||||
this.selectedApp = appArg;
|
||||
this.selectedAppMeta = null;
|
||||
this.selectedAppConfig = null;
|
||||
this.configLoadError = '';
|
||||
this.selectedVersion = appArg.latestVersion;
|
||||
this.serviceName = appArg.id;
|
||||
this.serviceDomain = '';
|
||||
this.deployMode = deployModeArg;
|
||||
this.loading = true;
|
||||
this.currentView = 'detail';
|
||||
await this.fetchVersionConfig(appArg.id, appArg.latestVersion);
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async changeVersion(versionArg: string) {
|
||||
if (!this.selectedApp || this.selectedVersion === versionArg) return;
|
||||
this.selectedVersion = versionArg;
|
||||
this.loading = true;
|
||||
await this.fetchVersionConfig(this.selectedApp.id, versionArg);
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async fetchVersionConfig(appIdArg: string, versionArg: string): Promise<boolean> {
|
||||
const requestToken = ++this.configRequestToken;
|
||||
this.configLoadError = '';
|
||||
this.selectedAppConfig = null;
|
||||
try {
|
||||
const response = await appstate.getAppStoreConfig(appIdArg, versionArg);
|
||||
if (requestToken !== this.configRequestToken) {
|
||||
return false;
|
||||
}
|
||||
this.selectedAppMeta = response.appMeta;
|
||||
this.selectedAppConfig = response.config;
|
||||
this.editableEnvVars = (response.config.envVars || []).map((envVarArg) => ({
|
||||
key: envVarArg.key,
|
||||
value: envVarArg.value || '',
|
||||
description: envVarArg.description || '',
|
||||
required: envVarArg.required,
|
||||
platformInjected: Boolean(envVarArg.value?.includes('${') && !envVarArg.value.includes('${SERVICE_DOMAIN}')),
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (requestToken === this.configRequestToken) {
|
||||
this.configLoadError = (error as Error).message;
|
||||
this.editableEnvVars = [];
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to load app config: ${(error as Error).message}`, type: 'error' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private updateEnvVar(indexArg: number, valueArg: string) {
|
||||
const envVars = [...this.editableEnvVars];
|
||||
envVars[indexArg] = { ...envVars[indexArg], value: valueArg };
|
||||
this.editableEnvVars = envVars;
|
||||
}
|
||||
|
||||
private async installSelectedApp() {
|
||||
if (!this.selectedApp || !this.selectedAppConfig) return;
|
||||
const missingEnvVars = this.editableEnvVars.filter((envVarArg) => envVarArg.required && !envVarArg.platformInjected && !envVarArg.value.trim());
|
||||
if (missingEnvVars.length) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Missing env vars: ${missingEnvVars.map((envVarArg) => envVarArg.key).join(', ')}`, type: 'error' });
|
||||
return;
|
||||
}
|
||||
const needsDomain = (this.selectedAppConfig.envVars || []).some((envVarArg) => envVarArg.value?.includes('${SERVICE_DOMAIN}'));
|
||||
if (needsDomain && !this.serviceDomain) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'A domain is required for this app.', type: 'error' });
|
||||
return;
|
||||
}
|
||||
const envVars: Record<string, string> = {};
|
||||
for (const envVar of this.editableEnvVars) {
|
||||
if (envVar.key && envVar.value) {
|
||||
envVars[envVar.key] = envVar.value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await appstate.installAppStoreApp({
|
||||
appId: this.selectedApp.id,
|
||||
version: this.selectedVersion,
|
||||
serviceName: this.serviceName || this.selectedApp.id,
|
||||
domain: this.serviceDomain || undefined,
|
||||
envVars,
|
||||
});
|
||||
await Promise.allSettled([
|
||||
appstate.dataState.dispatchAction(appstate.getAllDataAction, null),
|
||||
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
||||
]);
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'App Store service installed', type: 'success' });
|
||||
appRouter.navigateToView('runtime', 'services');
|
||||
} catch (error) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Install failed: ${(error as Error).message}`, type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
private getConfigVolumes(configArg: plugins.interfaces.appstore.IAppStoreVersionConfig) {
|
||||
return (configArg.volumes || []).map((volumeArg) => {
|
||||
if (typeof volumeArg === 'string') {
|
||||
return { mountPath: volumeArg };
|
||||
}
|
||||
return volumeArg;
|
||||
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
|
||||
}
|
||||
|
||||
private formatPublishedPort(portArg: plugins.interfaces.appstore.IAppStorePublishedPort): string {
|
||||
const protocol = portArg.protocol || 'tcp';
|
||||
const target = portArg.targetPortEnd ? `${portArg.targetPort}-${portArg.targetPortEnd}` : String(portArg.targetPort);
|
||||
const publishedStart = portArg.publishedPort || portArg.targetPort;
|
||||
const publishedEnd = portArg.publishedPortEnd || (portArg.targetPortEnd ? publishedStart + (portArg.targetPortEnd - portArg.targetPort) : undefined);
|
||||
const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart);
|
||||
return `${portArg.hostIp || '0.0.0.0'}:${published}/${protocol} -> ${target}/${protocol}`;
|
||||
}
|
||||
|
||||
private normalizeDomain(valueArg: string) {
|
||||
return valueArg.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'cloudly-view-appstore': CloudlyViewAppStore;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const sourcePresetArchitectures: Record<TBaseOsImageSourcePreset, string> = {
|
||||
'balena-raspberrypi4-64': 'rpi',
|
||||
};
|
||||
|
||||
@customElement('cloudly-view-baseos')
|
||||
@customElement('cloudly-view-base-os')
|
||||
export class CloudlyViewBaseOs extends DeesElement {
|
||||
@state() private accessor builds: TBaseOsImageBuild[] = [];
|
||||
@state() private accessor isLoading = false;
|
||||
@@ -300,6 +300,6 @@ export class CloudlyViewBaseOs extends DeesElement {
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'cloudly-view-baseos': CloudlyViewBaseOs;
|
||||
'cloudly-view-base-os': CloudlyViewBaseOs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,51 @@ export class CloudlyViewClusters extends DeesElement {
|
||||
this.rxSubscriptions.push(subecription);
|
||||
}
|
||||
|
||||
private async createJumpCommand(clusterArg: plugins.interfaces.data.ICluster) {
|
||||
const identity = appstate.loginStatePart.getState()?.identity;
|
||||
if (!identity) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Login required to create a jump code', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appstate.apiClient.identity = identity;
|
||||
const apiClient = appstate.apiClient as any;
|
||||
const response = apiClient.node?.createNodeJumpCommand
|
||||
? await apiClient.node.createNodeJumpCommand({ clusterId: clusterArg.id })
|
||||
: await apiClient.typedsocketClient
|
||||
.createTypedRequest('createNodeJumpCommand')
|
||||
.fire({ identity, clusterId: clusterArg.id });
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Connect System',
|
||||
content: html`
|
||||
<div style="display: grid; gap: 16px; min-width: min(680px, 80vw);">
|
||||
<div>
|
||||
Connect a Linux system to <strong>${clusterArg.data.name}</strong> by running this command as an administrator.
|
||||
</div>
|
||||
<pre style="white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787;">${response.command}</pre>
|
||||
<div style="color: #9aa4b2; font-size: 13px;">
|
||||
Jump URL: ${response.jumpUrl}<br>
|
||||
Expires: ${new Date(response.expiresAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'copy command',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(response.command);
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Jump command copied', type: 'success' });
|
||||
},
|
||||
},
|
||||
{ name: 'close', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
} catch (error: any) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to create jump code: ${error.message}`, type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
@@ -97,6 +142,14 @@ export class CloudlyViewClusters extends DeesElement {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'connect system',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.createJumpCommand(actionDataArg.item as plugins.interfaces.data.ICluster);
|
||||
},
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
|
||||
@customElement('cloudly-view-externalregistries')
|
||||
@customElement('cloudly-view-external-registries')
|
||||
export class CloudlyViewExternalRegistries extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = { secretGroups: [], secretBundles: [], externalRegistries: [] } as any;
|
||||
@@ -114,4 +114,4 @@ export class CloudlyViewExternalRegistries extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-externalregistries': CloudlyViewExternalRegistries; } }
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-external-registries': CloudlyViewExternalRegistries; } }
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as shared from '../../shared/index.js';
|
||||
|
||||
import { DeesElement, customElement, html, state, css, cssManager } from '@design.estate/dees-element';
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
|
||||
@@ -10,39 +18,84 @@ export class CloudlyViewImages extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = {} as any;
|
||||
|
||||
@state()
|
||||
private accessor currentView: 'list' | 'detail' = 'list';
|
||||
|
||||
@state()
|
||||
private accessor selectedImage: plugins.interfaces.data.IImage | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
appstate.dataState
|
||||
const subscription = appstate.dataState
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((dataArg) => {
|
||||
this.data = dataArg;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css``,
|
||||
css`
|
||||
.detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.detail-title { margin: 0; font-size: 26px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); }
|
||||
.detail-subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; overflow-wrap: anywhere; }
|
||||
.back-button { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 7px; padding: 9px 13px; font-size: 13px; cursor: pointer; background: var(--ci-shade-1, #09090b); color: var(--ci-shade-7, #e4e4e7); }
|
||||
.detail-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
|
||||
.spaced-table, .spaced-card { margin-top: 14px; }
|
||||
.details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 14px; }
|
||||
.section-title { font-size: 14px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 10px; }
|
||||
.kv-list { display: grid; gap: 8px; }
|
||||
.kv-row { display: grid; grid-template-columns: 150px 1fr; gap: 10px; font-size: 13px; }
|
||||
.kv-key { color: var(--ci-shade-4, #71717a); }
|
||||
.kv-value { color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; }
|
||||
.image-name { font-weight: 700; color: var(--ci-shade-7, #e4e4e7); }
|
||||
.empty-state { color: var(--ci-shade-4, #71717a); font-size: 13px; padding: 12px 0; }
|
||||
.source-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||
.source-upload { background: rgba(59, 130, 246, 0.16); color: #60a5fa; }
|
||||
.source-registry { background: rgba(34, 197, 94, 0.16); color: #22c55e; }
|
||||
.source-unknown { background: rgba(161, 161, 170, 0.16); color: #a1a1aa; }
|
||||
dees-statsgrid { margin-bottom: 18px; }
|
||||
@media (max-width: 900px) { .details-grid { grid-template-columns: 1fr; } .detail-header { flex-direction: column; } }
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
public render(): TemplateResult {
|
||||
if (this.currentView === 'detail') {
|
||||
return this.renderDetailView();
|
||||
}
|
||||
return this.renderListView();
|
||||
}
|
||||
|
||||
private renderListView(): TemplateResult {
|
||||
return html`
|
||||
<cloudly-sectionheading>Images</cloudly-sectionheading>
|
||||
<dees-table
|
||||
heading1="Images"
|
||||
heading2="an image is needed for running a service"
|
||||
.data=${this.data.images}
|
||||
.data=${this.data.images || []}
|
||||
.displayFunction=${(image: plugins.interfaces.data.IImage) => {
|
||||
return { id: image.id, name: image.data.name, description: image.data.description, versions: image.data.versions.length };
|
||||
const latestVersion = this.getLatestImageVersion(image);
|
||||
return {
|
||||
Name: html`<span class="image-name">${image.data.name}</span>`,
|
||||
Description: image.data.description,
|
||||
Location: this.getLocationLabel(image),
|
||||
Versions: image.data.versions?.length || 0,
|
||||
'Total Size': this.formatBytes(this.getImageTotalSize(image)),
|
||||
Latest: latestVersion?.versionString || '-',
|
||||
'Last Push': this.formatDate(image.data.lastPushEvent?.pushedAt),
|
||||
'Used By': this.getServicesUsingImage(image).length,
|
||||
};
|
||||
}}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'create Image',
|
||||
name: 'Create Image',
|
||||
type: ['header', 'footer'],
|
||||
iconName: 'plus',
|
||||
actionFunc: async () => {
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'create new Image',
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Create Image',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .label=${'name'} .key=${'data.name'} .value=${''}></dees-input-text>
|
||||
@@ -62,60 +115,19 @@ export class CloudlyViewImages extends DeesElement {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
name: 'Details',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
iconName: 'penToSquare',
|
||||
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: 'clockRotateLeft',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
const historyArray: Array<{ environment: string; value: string; }> = [];
|
||||
for (const environment of Object.keys(dataArg.item.data.environments)) {
|
||||
for (const historyItem of dataArg.item.data.environments[environment].history) {
|
||||
historyArray.push({ environment, value: historyItem.value });
|
||||
}
|
||||
}
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `history for ${dataArg.item.data.key}`,
|
||||
content: html`<dees-table .data=${historyArray} .dataActions=${[{ name: 'delete', iconName: 'trash', type: ['contextmenu', 'inRow'], actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg<(typeof historyArray)[0]>) => { console.log('delete', itemArg); }, }] as plugins.deesCatalog.ITableAction[]}></dees-table>`,
|
||||
menuOptions: [ { name: 'close', action: async (modalArg: any) => { await modalArg.destroy(); } } ],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
name: 'Delete',
|
||||
iconName: 'trash',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (itemArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.IImage>) => {
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Delete Image "${itemArg.item.data.name}"`,
|
||||
content: html`
|
||||
<div style="text-align:center">Do you really want to delete the image?</div>
|
||||
@@ -132,6 +144,171 @@ export class CloudlyViewImages extends DeesElement {
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDetailView(): TemplateResult {
|
||||
const image = this.getActiveImage();
|
||||
if (!image) {
|
||||
return html`
|
||||
<cloudly-sectionheading>Image Details</cloudly-sectionheading>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Images</button>
|
||||
`;
|
||||
}
|
||||
|
||||
const versions = this.getSortedImageVersions(image);
|
||||
const latestVersion = this.getLatestImageVersion(image);
|
||||
const lastPushEvent = image.data.lastPushEvent;
|
||||
const location = image.data.location;
|
||||
const servicesUsingImage = this.getServicesUsingImage(image);
|
||||
|
||||
return html`
|
||||
<cloudly-sectionheading>Image Details</cloudly-sectionheading>
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2 class="detail-title">${image.data.name}</h2>
|
||||
<div class="detail-subtitle">${image.data.description || 'No description configured'}</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Images</button>
|
||||
</div>
|
||||
|
||||
<dees-statsgrid .tiles=${this.getImageStatsTiles(image)} .minTileWidth=${220} .gap=${12}></dees-statsgrid>
|
||||
|
||||
<dees-table
|
||||
.heading1=${'Image Versions'}
|
||||
.heading2=${versions.length ? 'Stored image versions and registry metadata' : 'No versions recorded'}
|
||||
.data=${versions}
|
||||
.displayFunction=${(versionArg: plugins.interfaces.data.IImage['data']['versions'][number]) => ({
|
||||
Version: versionArg.versionString,
|
||||
Source: this.renderSourceBadge(versionArg.source),
|
||||
Size: this.formatBytes(versionArg.size),
|
||||
Digest: versionArg.digest || '-',
|
||||
Repository: versionArg.registryRepository || '-',
|
||||
Tag: versionArg.registryTag || '-',
|
||||
Storage: versionArg.storagePath || '-',
|
||||
Created: this.formatDate(versionArg.createdAt),
|
||||
})}
|
||||
></dees-table>
|
||||
|
||||
${servicesUsingImage.length ? html`
|
||||
<dees-table
|
||||
class="spaced-table"
|
||||
.heading1=${'Service Usage'}
|
||||
.heading2=${'Services currently configured with this image ID'}
|
||||
.data=${servicesUsingImage}
|
||||
.displayFunction=${(serviceArg: plugins.interfaces.data.IService) => ({
|
||||
Name: serviceArg.data.name,
|
||||
Version: serviceArg.data.imageVersion || '-',
|
||||
Category: serviceArg.data.serviceCategory || 'workload',
|
||||
Strategy: serviceArg.data.deploymentStrategy || 'custom',
|
||||
Domains: serviceArg.data.domains?.map((domainArg) => domainArg.name).join(', ') || '-',
|
||||
Deployments: serviceArg.data.deploymentIds?.length || 0,
|
||||
})}
|
||||
></dees-table>
|
||||
` : html`
|
||||
<div class="detail-card spaced-card">
|
||||
<div class="section-title">Services Using This Image</div>
|
||||
<div class="empty-state">No services currently reference this image.</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-card">
|
||||
<div class="section-title">Registry Source</div>
|
||||
<div class="kv-list">
|
||||
<div class="kv-row"><span class="kv-key">Image ID</span><span class="kv-value">${image.id}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Name</span><span class="kv-value">${image.data.name}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Description</span><span class="kv-value">${image.data.description || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Internal</span><span class="kv-value">${location?.internal === false ? 'no' : 'yes'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">External Registry</span><span class="kv-value">${location?.externalRegistryId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">External Tag</span><span class="kv-value">${location?.externalImageTag || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">External Ref</span><span class="kv-value">${location?.externalImageRef || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Latest Created</span><span class="kv-value">${this.formatDate(latestVersion?.createdAt)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<div class="section-title">Last Push</div>
|
||||
<div class="kv-list">
|
||||
<div class="kv-row"><span class="kv-key">Repository</span><span class="kv-value">${lastPushEvent?.repository || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Tag</span><span class="kv-value">${lastPushEvent?.tag || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Digest</span><span class="kv-value">${lastPushEvent?.digest || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Image URL</span><span class="kv-value">${lastPushEvent?.imageUrl || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Pushed At</span><span class="kv-value">${this.formatDate(lastPushEvent?.pushedAt)}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Actor</span><span class="kv-value">${lastPushEvent?.actorUserId || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getActiveImage(): plugins.interfaces.data.IImage | null {
|
||||
if (!this.selectedImage) {
|
||||
return null;
|
||||
}
|
||||
return this.data.images?.find((imageArg) => imageArg.id === this.selectedImage!.id) || this.selectedImage;
|
||||
}
|
||||
|
||||
private openImageDetail(imageArg: plugins.interfaces.data.IImage) {
|
||||
this.selectedImage = imageArg;
|
||||
this.currentView = 'detail';
|
||||
}
|
||||
|
||||
private getImageTotalSize(imageArg: plugins.interfaces.data.IImage): number {
|
||||
return (imageArg.data.versions || []).reduce((sumArg, versionArg) => sumArg + (versionArg.size || 0), 0);
|
||||
}
|
||||
|
||||
private getSortedImageVersions(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IImage['data']['versions'] {
|
||||
return [...(imageArg.data.versions || [])].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
}
|
||||
|
||||
private getLatestImageVersion(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IImage['data']['versions'][number] | undefined {
|
||||
return this.getSortedImageVersions(imageArg)[0];
|
||||
}
|
||||
|
||||
private getServicesUsingImage(imageArg: plugins.interfaces.data.IImage): plugins.interfaces.data.IService[] {
|
||||
return (this.data.services || []).filter((serviceArg) => serviceArg.data.imageId === imageArg.id);
|
||||
}
|
||||
|
||||
private getImageStatsTiles(imageArg: plugins.interfaces.data.IImage) {
|
||||
const latestVersion = this.getLatestImageVersion(imageArg);
|
||||
const totalSize = this.getImageTotalSize(imageArg);
|
||||
const servicesUsingImage = this.getServicesUsingImage(imageArg);
|
||||
return [
|
||||
{ id: 'versions', title: 'Versions', value: imageArg.data.versions?.length || 0, type: 'number' as const, icon: 'lucide:Tags', description: 'Recorded image versions' },
|
||||
{ id: 'size', title: 'Total Size', value: this.formatBytes(totalSize), type: 'text' as const, icon: 'lucide:HardDrive', description: 'Stored archive size' },
|
||||
{ id: 'latest', title: 'Latest Version', value: latestVersion?.versionString || '-', type: 'text' as const, icon: 'lucide:GitBranch', description: this.formatDate(latestVersion?.createdAt) },
|
||||
{ id: 'usage', title: 'Used By', value: servicesUsingImage.length, type: 'number' as const, icon: 'lucide:Layers', description: 'Configured services' },
|
||||
];
|
||||
}
|
||||
|
||||
private getLocationLabel(imageArg: plugins.interfaces.data.IImage): string {
|
||||
const location = imageArg.data.location;
|
||||
if (!location || location.internal) {
|
||||
return 'Internal registry';
|
||||
}
|
||||
return location.externalImageRef || location.externalImageTag || 'External registry';
|
||||
}
|
||||
|
||||
private formatBytes(sizeArg?: number): string {
|
||||
if (!sizeArg) {
|
||||
return sizeArg === 0 ? '0 B' : '-';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = sizeArg;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size = size / 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
private formatDate(timestampArg?: number): string {
|
||||
return timestampArg ? new Date(timestampArg).toLocaleString() : '-';
|
||||
}
|
||||
|
||||
private renderSourceBadge(sourceArg?: 'upload' | 'registry'): TemplateResult {
|
||||
const source = sourceArg || 'unknown';
|
||||
return html`<span class="source-badge source-${source}">${source}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
|
||||
@customElement('cloudly-view-secretbundles')
|
||||
@customElement('cloudly-view-secret-bundles')
|
||||
export class CloudlyViewSecretBundles extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = {} as any;
|
||||
@@ -63,7 +63,7 @@ export class CloudlyViewSecretBundles extends DeesElement {
|
||||
<div style="font-size: 0.8em; color: red; text-align:center; padding: 16px; margin-top: 24px; border: 1px solid #444; font-family: Intel One Mono; font-size: 16px;">${actionDataArg.item.id}</div>
|
||||
`, menuOptions: [ { name: 'cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'delete', action: async (modalArg: any) => { appstate.dataState.dispatchAction(appstate.deleteSecretBundleAction, { configBundleId: actionDataArg.item.id, }); await modalArg.destroy(); } } ] });
|
||||
} },
|
||||
{ name: 'edit', iconName: 'penToSquare', type: ['doubleClick', 'contextmenu', 'inRow'], actionFunc: async () => {
|
||||
{ name: 'edit', iconName: 'lucide:SquarePen', type: ['doubleClick', 'contextmenu', 'inRow'], actionFunc: async () => {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Edit SecretBundle', content: html`<dees-form><dees-input-text .label=${'purpose'}></dees-input-text></dees-form>`, menuOptions: [ { name: 'save', action: async (modalArg: any) => {} }, { name: 'cancel', action: async (modalArg: any) => { modalArg.destroy(); } } ] });
|
||||
} },
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
@@ -72,4 +72,4 @@ export class CloudlyViewSecretBundles extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secretbundles': CloudlyViewSecretBundles; } }
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secret-bundles': CloudlyViewSecretBundles; } }
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DeesElement, customElement, html, state, css, cssManager } from '@desig
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
|
||||
@customElement('cloudly-view-secretsgroups')
|
||||
@customElement('cloudly-view-secret-groups')
|
||||
export class CloudlyViewSecretGroups extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = {} as any;
|
||||
@@ -46,7 +46,7 @@ export class CloudlyViewSecretGroups extends DeesElement {
|
||||
</dees-form>
|
||||
`, menuOptions: [ { name: 'cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'save', action: async (modalArg: any) => { const deesForm = modalArg.shadowRoot.querySelector('dees-form'); const formData = await deesForm.collectFormData(); const environments: plugins.interfaces.data.ISecretGroup['data']['environments'] = {}; for (const itemArg of formData['environments'] as any[]) { environments[itemArg.environment] = { value: itemArg.value, history: [], lastUpdated: Date.now(), }; } await appstate.dataState.dispatchAction(appstate.createSecretGroupAction, { data: { name: formData['data.name'] as string, description: formData['data.description'] as string, key: formData['data.key'] as string, environments, tags: [], }, }); await modalArg.destroy(); } } ] });
|
||||
} },
|
||||
{ name: 'edit', type: ['contextmenu', 'inRow', 'doubleClick'], iconName: 'penToSquare', actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
{ name: 'edit', 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`
|
||||
@@ -60,7 +60,7 @@ export class CloudlyViewSecretGroups extends DeesElement {
|
||||
</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); } } ] });
|
||||
} },
|
||||
{ name: 'history', iconName: 'clockRotateLeft', type: ['contextmenu', 'inRow'], actionFunc: async (dataArg: plugins.deesCatalog.ITableActionDataArg<plugins.interfaces.data.ISecretGroup>) => {
|
||||
{ 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(); } } ] });
|
||||
} },
|
||||
@@ -73,4 +73,4 @@ export class CloudlyViewSecretGroups extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secretsgroups': CloudlyViewSecretGroups; } }
|
||||
declare global { interface HTMLElementTagNameMap { 'cloudly-view-secret-groups': CloudlyViewSecretGroups; } }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as shared from '../../shared/index.js';
|
||||
import { DeploymentExecutionEnvironment } from '../../../environments/deployment-environment.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
@@ -17,14 +19,54 @@ export class CloudlyViewServices extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = {} as any;
|
||||
|
||||
@state()
|
||||
private accessor currentView: 'list' | 'detail' | 'workspace' = 'list';
|
||||
|
||||
@state()
|
||||
private accessor selectedService: plugins.interfaces.data.IService | null = null;
|
||||
|
||||
@state()
|
||||
private accessor serviceDeployments: plugins.interfaces.data.IDeployment[] = [];
|
||||
|
||||
@state()
|
||||
private accessor deploymentsLoading = false;
|
||||
|
||||
@state()
|
||||
private accessor upgradeInfo: any = null;
|
||||
|
||||
@state()
|
||||
private accessor appStoreState: appstate.IAppStoreState = {
|
||||
apps: [],
|
||||
upgradeableServices: [],
|
||||
upgradeOperations: [],
|
||||
};
|
||||
|
||||
@state()
|
||||
private accessor workspaceEnvironment: DeploymentExecutionEnvironment | null = null;
|
||||
|
||||
@state()
|
||||
private accessor workspaceDeployment: any = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.dataState
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((dataArg) => {
|
||||
this.data = dataArg;
|
||||
if (this.selectedService) {
|
||||
const updatedService = dataArg.services?.find((serviceArg) => serviceArg.id === this.selectedService!.id);
|
||||
if (updatedService) {
|
||||
this.selectedService = updatedService;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
const appStoreSubscription = appstate.appStoreStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((stateArg) => {
|
||||
this.appStoreState = stateArg;
|
||||
});
|
||||
this.rxSubscriptions.push(appStoreSubscription);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -36,6 +78,32 @@ 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; }
|
||||
.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; }
|
||||
.back-button, .primary-button, .danger-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); }
|
||||
.primary-button { background: var(--ci-color-primary, #2563eb); border-color: var(--ci-color-primary, #2563eb); color: white; }
|
||||
.danger-button { color: #ef4444; border-color: rgba(239, 68, 68, 0.35); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.summary-card, .detail-card, .update-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
|
||||
.summary-label { font-size: 12px; color: var(--ci-shade-4, #71717a); margin-bottom: 6px; }
|
||||
.summary-value { font-size: 20px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; }
|
||||
.section-title { font-size: 14px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 10px; }
|
||||
.details-grid { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 14px; margin-top: 14px; }
|
||||
.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; }
|
||||
.status-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||
.status-running { background: rgba(34, 197, 94, 0.16); color: #22c55e; }
|
||||
.status-starting, .status-scheduled { background: rgba(59, 130, 246, 0.16); color: #60a5fa; }
|
||||
.status-stopped { background: rgba(161, 161, 170, 0.16); color: #a1a1aa; }
|
||||
.status-failed { background: rgba(239, 68, 68, 0.16); color: #ef4444; }
|
||||
.update-card { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; border-color: rgba(59, 130, 246, 0.35); background: linear-gradient(135deg, rgba(59, 130, 246, 0.10), rgba(139, 92, 246, 0.10)); }
|
||||
.workspace-shell { display: grid; grid-template-rows: auto 1fr; height: calc(100vh - 120px); min-height: 560px; }
|
||||
.workspace-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
dees-workspace { min-height: 0; }
|
||||
@media (max-width: 900px) { .summary-grid, .details-grid { grid-template-columns: 1fr; } .detail-header { flex-direction: column; } }
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -57,7 +125,17 @@ export class CloudlyViewServices extends DeesElement {
|
||||
return html`<span class="strategy-badge">${strategy}</span>`;
|
||||
}
|
||||
|
||||
public render() {
|
||||
public render(): TemplateResult {
|
||||
if (this.currentView === 'workspace') {
|
||||
return this.renderWorkspaceView();
|
||||
}
|
||||
if (this.currentView === 'detail') {
|
||||
return this.renderDetailView();
|
||||
}
|
||||
return this.renderListView();
|
||||
}
|
||||
|
||||
private renderListView(): TemplateResult {
|
||||
return html`
|
||||
<cloudly-sectionheading>Services</cloudly-sectionheading>
|
||||
<dees-table
|
||||
@@ -81,6 +159,14 @@ export class CloudlyViewServices extends DeesElement {
|
||||
};
|
||||
}}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'eye',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openServiceDetail(actionDataArg.item as plugins.interfaces.data.IService);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add Service',
|
||||
iconName: 'plus',
|
||||
@@ -216,6 +302,407 @@ export class CloudlyViewServices extends DeesElement {
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDetailView(): TemplateResult {
|
||||
const service = this.selectedService;
|
||||
if (!service) {
|
||||
return html`
|
||||
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
||||
`;
|
||||
}
|
||||
|
||||
const runningDeployments = this.serviceDeployments.filter((deploymentArg) => deploymentArg.status === 'running').length;
|
||||
const desiredReplicas = service.data.maxReplicas || service.data.scaleFactor || 1;
|
||||
const domains = service.data.domains || [];
|
||||
const volumes = service.data.volumes || [];
|
||||
const serviceData = service.data as plugins.interfaces.data.IService['data'] & {
|
||||
appTemplateId?: string;
|
||||
appTemplateVersion?: string;
|
||||
};
|
||||
const upgradeOperation = this.getUpgradeOperationForService(service);
|
||||
const upgradeInfo = this.getUpgradeInfoForService(service);
|
||||
|
||||
return html`
|
||||
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2 class="detail-title">${service.data.name}</h2>
|
||||
<div class="detail-subtitle">${service.data.description || 'No description configured'}</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
||||
</div>
|
||||
|
||||
${upgradeOperation ? this.renderUpgradeOperation(upgradeOperation) : ''}
|
||||
|
||||
${upgradeInfo ? html`
|
||||
<div class="update-card">
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom: 3px;">App catalog update available</div>
|
||||
<div class="detail-subtitle">${upgradeInfo.appTemplateId}: ${upgradeInfo.currentVersion} -> ${upgradeInfo.latestVersion}</div>
|
||||
</div>
|
||||
<button
|
||||
class="primary-button"
|
||||
?disabled=${upgradeOperation?.status === 'running'}
|
||||
@click=${() => this.startUpgradeForService(service)}
|
||||
>${upgradeOperation?.status === 'running' ? 'Upgrading...' : 'Upgrade'}</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Running Deployments</div>
|
||||
<div class="summary-value">${runningDeployments}/${desiredReplicas}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Image</div>
|
||||
<div class="summary-value" style="font-size: 15px;">${service.data.imageId}:${service.data.imageVersion}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Strategy</div>
|
||||
<div class="summary-value" style="font-size: 16px;">${service.data.deploymentStrategy}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Category</div>
|
||||
<div class="summary-value" style="font-size: 16px;">${service.data.serviceCategory}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.deploymentsLoading ? html`<div class="detail-subtitle">Loading deployments...</div>` : html`
|
||||
<dees-table
|
||||
.heading1=${'Live Deployments'}
|
||||
.heading2=${this.serviceDeployments.length ? 'Docker Swarm tasks reported by connected Coreflows' : 'No live deployments reported'}
|
||||
.data=${this.serviceDeployments}
|
||||
.displayFunction=${(deploymentArg: any) => ({
|
||||
Status: this.renderStatusBadge(deploymentArg.status),
|
||||
Node: deploymentArg.nodeName || deploymentArg.nodeId || '-',
|
||||
Slot: deploymentArg.slot || '-',
|
||||
Version: deploymentArg.version || service.data.imageVersion,
|
||||
Container: deploymentArg.containerId ? deploymentArg.containerId.slice(0, 12) : '-',
|
||||
CPU: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}%` : '-',
|
||||
Memory: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.memoryUsedMB} MB` : '-',
|
||||
Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
await this.loadDeploymentsForService(service);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'lucide:Eye',
|
||||
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.showDeploymentDetailsModal(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Open IDE',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openDeploymentWorkspace(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Restart',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.restartDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Kill Container',
|
||||
iconName: 'skull',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.confirmKillDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`}
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-card">
|
||||
<div class="section-title">Service Configuration</div>
|
||||
<div class="kv-list">
|
||||
<div class="kv-row"><span class="kv-key">Service ID</span><span class="kv-value">${service.id}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Image ID</span><span class="kv-value">${service.data.imageId}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Image Version</span><span class="kv-value">${service.data.imageVersion}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Web Port</span><span class="kv-value">${service.data.ports?.web || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Deploy on Push</span><span class="kv-value">${service.data.deployOnPush === false ? 'disabled' : 'enabled'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">App Template</span><span class="kv-value">${serviceData.appTemplateId ? `${serviceData.appTemplateId}@${serviceData.appTemplateVersion}` : '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Registry Target</span><span class="kv-value">${service.data.registryTarget?.imageUrl || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<div class="section-title">Routes, Volumes, Secrets</div>
|
||||
<div class="kv-list">
|
||||
<div class="kv-row"><span class="kv-key">Domains</span><span class="kv-value">${domains.length ? domains.map((domainArg) => domainArg.name).join(', ') : '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Volumes</span><span class="kv-value">${volumes.length ? volumes.map((volumeArg) => volumeArg.mountPath).join(', ') : '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Secret Bundle</span><span class="kv-value">${service.data.secretBundleId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Extra Bundles</span><span class="kv-value">${service.data.additionalSecretBundleIds?.length || 0}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Env Keys</span><span class="kv-value">${Object.keys(service.data.environment || {}).join(', ') || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspaceView(): TemplateResult {
|
||||
return html`
|
||||
<cloudly-sectionheading>Deployment IDE</cloudly-sectionheading>
|
||||
<div class="workspace-shell">
|
||||
<div class="workspace-toolbar">
|
||||
<div>
|
||||
<div class="section-title">${this.selectedService?.data.name || 'Deployment'} workspace</div>
|
||||
<div class="detail-subtitle">${this.workspaceDeployment?.containerId || this.workspaceDeployment?.id || ''}</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'detail'; }}>Back to Deployments</button>
|
||||
</div>
|
||||
${this.workspaceEnvironment
|
||||
? html`<dees-workspace .executionEnvironment=${this.workspaceEnvironment}></dees-workspace>`
|
||||
: html`<div class="detail-subtitle">Workspace is not available.</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getUpgradeOperationForService(serviceArg: plugins.interfaces.data.IService): appstate.IAppStoreUpgradeOperation | null {
|
||||
return this.appStoreState.upgradeOperations.find((operationArg) => {
|
||||
return operationArg.serviceId === serviceArg.id || operationArg.serviceName === serviceArg.data.name;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
private getUpgradeInfoForService(serviceArg: plugins.interfaces.data.IService): any | null {
|
||||
const operation = this.getUpgradeOperationForService(serviceArg);
|
||||
if (operation?.status === 'success') {
|
||||
return null;
|
||||
}
|
||||
const liveUpgradeInfo = this.appStoreState.upgradeableServices.find((upgradeArg) => {
|
||||
return upgradeArg.serviceId === serviceArg.id || upgradeArg.serviceName === serviceArg.data.name;
|
||||
});
|
||||
if (liveUpgradeInfo) {
|
||||
return liveUpgradeInfo;
|
||||
}
|
||||
if (this.upgradeInfo?.serviceId === serviceArg.id || this.upgradeInfo?.serviceName === serviceArg.data.name) {
|
||||
return this.upgradeInfo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderUpgradeOperation(operationArg: appstate.IAppStoreUpgradeOperation): TemplateResult {
|
||||
const color = operationArg.status === 'failed' ? '#f87171' : '#60a5fa';
|
||||
return html`
|
||||
<div class="update-card" style="border-color: ${color}; background: var(--ci-shade-1, #09090b); display: block;">
|
||||
<div style="display: flex; justify-content: space-between; gap: 16px; align-items: flex-start;">
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom: 3px;">Upgrade ${operationArg.fromVersion} -> ${operationArg.targetVersion}</div>
|
||||
<div class="detail-subtitle">${operationArg.status} / ${operationArg.step}${operationArg.error ? `: ${operationArg.error}` : ''}</div>
|
||||
</div>
|
||||
<span style="color: ${color}; font-size: 12px; text-transform: uppercase;">${operationArg.status}</span>
|
||||
</div>
|
||||
<div style="margin-top: 12px; padding: 10px 12px; background: var(--ci-shade-0, #030305); border-radius: 6px; color: var(--ci-shade-5, #a1a1aa); font-family: monospace; font-size: 12px; line-height: 1.5; max-height: 130px; overflow: auto; white-space: pre-wrap;">${operationArg.progressLines.slice(-8).join('\n')}</div>
|
||||
${operationArg.warnings.length ? html`<div style="margin-top: 10px; color: #fbbf24; font-size: 12px;">${operationArg.warnings.join(' | ')}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(statusArg: string): TemplateResult {
|
||||
return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`;
|
||||
}
|
||||
|
||||
private formatDate(timestampArg?: number): string {
|
||||
return timestampArg ? new Date(timestampArg).toLocaleString() : '-';
|
||||
}
|
||||
|
||||
private formatResourceUsage(deploymentArg: plugins.interfaces.data.IDeployment): string {
|
||||
if (!deploymentArg.resourceUsage) {
|
||||
return '-';
|
||||
}
|
||||
return `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}% CPU / ${deploymentArg.resourceUsage.memoryUsedMB} MB`;
|
||||
}
|
||||
|
||||
private async showDeploymentDetailsModal(deploymentArg: plugins.interfaces.data.IDeployment) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Deployment Details',
|
||||
content: html`
|
||||
<div class="kv-list" style="min-width: 520px;">
|
||||
<div class="kv-row"><span class="kv-key">Deployment ID</span><span class="kv-value">${deploymentArg.id}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Service</span><span class="kv-value">${deploymentArg.serviceName || this.selectedService?.data.name || deploymentArg.serviceId}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Status</span><span class="kv-value">${deploymentArg.status}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Health</span><span class="kv-value">${deploymentArg.healthStatus || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Node</span><span class="kv-value">${deploymentArg.nodeName || deploymentArg.nodeId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Slot</span><span class="kv-value">${deploymentArg.slot || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Desired State</span><span class="kv-value">${deploymentArg.desiredState || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Container ID</span><span class="kv-value">${deploymentArg.containerId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Task ID</span><span class="kv-value">${deploymentArg.taskId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Docker Service ID</span><span class="kv-value">${deploymentArg.dockerServiceId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Version</span><span class="kv-value">${deploymentArg.version || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Image</span><span class="kv-value">${deploymentArg.usedImageId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Resources</span><span class="kv-value">${this.formatResourceUsage(deploymentArg)}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Deployed At</span><span class="kv-value">${this.formatDate(deploymentArg.deployedAt)}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Updated At</span><span class="kv-value">${this.formatDate(deploymentArg.updatedAt)}</span></div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Close', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async openServiceDetail(serviceArg: plugins.interfaces.data.IService) {
|
||||
this.selectedService = serviceArg;
|
||||
this.serviceDeployments = [];
|
||||
this.upgradeInfo = null;
|
||||
this.currentView = 'detail';
|
||||
await Promise.all([
|
||||
this.loadDeploymentsForService(serviceArg),
|
||||
this.loadUpgradeInfo(serviceArg),
|
||||
]);
|
||||
}
|
||||
|
||||
private async loadDeploymentsForService(serviceArg: plugins.interfaces.data.IService) {
|
||||
this.deploymentsLoading = true;
|
||||
try {
|
||||
const response = await this.fireTypedRequest('getDeploymentsByService', {
|
||||
serviceId: serviceArg.id,
|
||||
}) as { deployments: plugins.interfaces.data.IDeployment[] };
|
||||
this.serviceDeployments = response.deployments || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load service deployments:', error);
|
||||
this.serviceDeployments = [];
|
||||
} finally {
|
||||
this.deploymentsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
|
||||
try {
|
||||
await Promise.all([
|
||||
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
||||
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null),
|
||||
]);
|
||||
this.upgradeInfo = this.getUpgradeInfoForService(serviceArg);
|
||||
} catch {
|
||||
this.upgradeInfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async startUpgradeForService(serviceArg: plugins.interfaces.data.IService) {
|
||||
const upgradeInfo = this.getUpgradeInfoForService(serviceArg);
|
||||
if (!upgradeInfo?.latestVersion) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const preview = await appstate.getAppStoreUpgradePreview(serviceArg.id, upgradeInfo.latestVersion);
|
||||
if (preview.blockers.length > 0) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: preview.blockers.join('; '), type: 'error' });
|
||||
return;
|
||||
}
|
||||
let upgradeStarting = false;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: `Upgrade ${serviceArg.data.name}`,
|
||||
content: html`
|
||||
<div style="width: min(720px, calc(100vw - 48px)); max-width: 100%;">
|
||||
<div class="detail-subtitle" style="margin-bottom: 12px;">${preview.fromVersion} -> ${preview.resolvedTargetVersion}</div>
|
||||
<div style="display: grid; gap: 8px;">
|
||||
${preview.changes.map((changeArg) => html`
|
||||
<div style="display: grid; grid-template-columns: minmax(120px, 0.35fr) 1fr; gap: 10px; font-size: 13px;">
|
||||
<span style="color: var(--ci-shade-4, #71717a);">${changeArg.field}</span>
|
||||
<span style="color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere;">${changeArg.currentValue} -> ${changeArg.targetValue}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
${preview.warnings.length ? html`<div style="margin-top: 12px; color: #fbbf24; font-size: 12px;">${preview.warnings.join(' | ')}</div>` : ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Start Upgrade',
|
||||
action: async (modalArg: any) => {
|
||||
if (upgradeStarting) {
|
||||
return;
|
||||
}
|
||||
upgradeStarting = true;
|
||||
try {
|
||||
await appstate.appStoreStatePart.dispatchAction(appstate.startAppStoreServiceUpgradeAction, {
|
||||
serviceId: serviceArg.id,
|
||||
targetVersion: preview.resolvedTargetVersion,
|
||||
});
|
||||
await modalArg.destroy();
|
||||
} catch (error) {
|
||||
upgradeStarting = false;
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
private async restartDeployment(deploymentArg: plugins.interfaces.data.IDeployment) {
|
||||
await this.fireTypedRequest('restartDeployment', { deploymentId: deploymentArg.id });
|
||||
if (this.selectedService) {
|
||||
await this.loadDeploymentsForService(this.selectedService);
|
||||
}
|
||||
}
|
||||
|
||||
private async confirmKillDeployment(deploymentArg: plugins.interfaces.data.IDeployment) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Kill Deployment Container',
|
||||
content: html`
|
||||
<div style="text-align: center; max-width: 520px;">
|
||||
This kills the running container for deployment <strong>${deploymentArg.id}</strong>.
|
||||
Docker Swarm may create a replacement task if the service still desires a replica.
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{ name: 'Kill Container', action: async (modalArg: any) => {
|
||||
await this.fireTypedRequest('killDeployment', { deploymentId: deploymentArg.id });
|
||||
await modalArg.destroy();
|
||||
if (this.selectedService) {
|
||||
await this.loadDeploymentsForService(this.selectedService);
|
||||
}
|
||||
} },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async openDeploymentWorkspace(deploymentArg: any) {
|
||||
const identity = appstate.loginStatePart.getState()?.identity;
|
||||
if (!identity) return;
|
||||
const environment = new DeploymentExecutionEnvironment(deploymentArg.id, identity);
|
||||
await environment.init();
|
||||
this.workspaceDeployment = deploymentArg;
|
||||
this.workspaceEnvironment = environment;
|
||||
this.currentView = 'workspace';
|
||||
}
|
||||
|
||||
private async fireTypedRequest(methodArg: string, dataArg: Record<string, unknown>) {
|
||||
const identity = appstate.loginStatePart.getState()?.identity;
|
||||
if (!identity) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
|
||||
'/typedrequest',
|
||||
methodArg,
|
||||
);
|
||||
return await typedRequest.fire({
|
||||
identity,
|
||||
...dataArg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -23,8 +23,29 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
@state()
|
||||
private accessor testResults: {[key: string]: {success: boolean; message: string}} = {};
|
||||
|
||||
@state()
|
||||
private accessor hostedRuntime: appstate.IHostedRuntimeState = {
|
||||
isHosted: false,
|
||||
loading: false,
|
||||
upgradeState: null,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hostedRuntimeSubscription = appstate.hostedRuntimeStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((stateArg) => {
|
||||
this.hostedRuntime = stateArg;
|
||||
});
|
||||
this.rxSubscriptions.push(hostedRuntimeSubscription);
|
||||
const loginSubscription = appstate.loginStatePart
|
||||
.select((stateArg) => stateArg.identity)
|
||||
.subscribe((identityArg) => {
|
||||
if (identityArg) {
|
||||
void this.refreshHostedRuntimeStatus();
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
@@ -41,10 +62,24 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
dees-panel { margin-bottom: 16px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-grid.single { grid-template-columns: 1fr; }
|
||||
.runtime-panel { display: grid; gap: 16px; }
|
||||
.runtime-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.runtime-card { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 8px; padding: 12px; background: var(--ci-shade-1, #09090b); }
|
||||
.runtime-label { color: var(--ci-shade-4, #71717a); font-size: 12px; margin-bottom: 6px; }
|
||||
.runtime-value { color: var(--ci-shade-7, #e4e4e7); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.runtime-message { color: var(--ci-shade-5, #a1a1aa); font-size: 13px; line-height: 1.5; }
|
||||
.runtime-message.error { color: #ef4444; }
|
||||
.runtime-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
@media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 768px) { .runtime-grid { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
];
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.refreshHostedRuntimeStatus();
|
||||
}
|
||||
|
||||
private async loadSettings() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
@@ -102,6 +137,124 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
return html`<dees-badge .type=${result.success ? 'success' : 'error'} .text=${result.success ? 'Connected' : 'Failed'}></dees-badge>`;
|
||||
}
|
||||
|
||||
private async refreshHostedRuntimeStatus() {
|
||||
if (!appstate.loginStatePart.getState()?.identity) {
|
||||
return;
|
||||
}
|
||||
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.fetchHostedRuntimeUpgradeStatusAction, null);
|
||||
}
|
||||
|
||||
private getHostedRuntimeBadgeType() {
|
||||
const status = this.hostedRuntime.upgradeState?.status;
|
||||
if (!this.hostedRuntime.isHosted) return 'info';
|
||||
if (status === 'failed') return 'error';
|
||||
if (status === 'upToDate' || status === 'success') return 'success';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
private getHostedRuntimeStatusText() {
|
||||
if (!this.hostedRuntime.isHosted) return 'Not hosted';
|
||||
const status = this.hostedRuntime.upgradeState?.status || 'unknown';
|
||||
switch (status) {
|
||||
case 'upToDate': return 'Up to date';
|
||||
case 'available': return 'Update available';
|
||||
case 'running': return 'Upgrade running';
|
||||
case 'success': return 'Upgrade complete';
|
||||
case 'failed': return 'Upgrade failed';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private getHostedRuntimeMessage() {
|
||||
if (!this.hostedRuntime.isHosted) {
|
||||
return this.hostedRuntime.unavailableReason || 'This Cloudly instance is not running as a managed hosted app.';
|
||||
}
|
||||
if (this.hostedRuntime.unavailableReason) {
|
||||
return this.hostedRuntime.unavailableReason;
|
||||
}
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
if (upgradeState?.status === 'available') {
|
||||
return `Parent host can upgrade Cloudly from ${upgradeState.currentVersion || 'current'} to ${upgradeState.targetVersion || upgradeState.latestVersion}.`;
|
||||
}
|
||||
if (upgradeState?.status === 'running') {
|
||||
return 'The parent host is upgrading this Cloudly service. Status refreshes automatically.';
|
||||
}
|
||||
if (upgradeState?.status === 'failed') {
|
||||
return upgradeState.error || 'The last parent-hosted upgrade failed.';
|
||||
}
|
||||
return 'Parent hosted runtime status is available. No upgrade action is currently required.';
|
||||
}
|
||||
|
||||
private async startHostedRuntimeUpgrade() {
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
const targetVersion = upgradeState?.targetVersion || upgradeState?.latestVersion;
|
||||
if (!targetVersion) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'No hosted runtime upgrade target is available.', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
let upgradeStarting = false;
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Upgrade Hosted Cloudly',
|
||||
content: html`
|
||||
<div style="width: min(560px, calc(100vw - 48px)); max-width: 100%; color: var(--ci-shade-5, #a1a1aa); line-height: 1.5;">
|
||||
The parent host will upgrade this Cloudly app from ${upgradeState?.currentVersion || 'current'} to ${targetVersion} using its hosted app lifecycle controls.
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Start Upgrade',
|
||||
action: async (modalArg: any) => {
|
||||
if (upgradeStarting) return;
|
||||
upgradeStarting = true;
|
||||
try {
|
||||
await appstate.hostedRuntimeStatePart.dispatchAction(appstate.startHostedRuntimeParentUpgradeAction, { targetVersion });
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Hosted runtime upgrade started.', type: 'success' });
|
||||
await modalArg.destroy();
|
||||
} catch (error) {
|
||||
upgradeStarting = false;
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private renderHostedRuntimePanel() {
|
||||
const upgradeState = this.hostedRuntime.upgradeState;
|
||||
const canStartUpgrade = this.hostedRuntime.isHosted && upgradeState?.status === 'available' && !this.hostedRuntime.loading;
|
||||
return html`
|
||||
<dees-panel .title=${'Hosted Runtime'} .subtitle=${'Manage this Cloudly instance through its parent serve.zone host'} .variant=${'outline'}>
|
||||
<div class="runtime-panel">
|
||||
<div class="runtime-grid">
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Runtime</div>
|
||||
<div class="runtime-value">${this.hostedRuntime.isHosted ? 'Managed hosted app' : 'Standalone'}</div>
|
||||
</div>
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Upgrade Status</div>
|
||||
<div class="runtime-value"><dees-badge .type=${this.getHostedRuntimeBadgeType()} .text=${this.getHostedRuntimeStatusText()}></dees-badge></div>
|
||||
</div>
|
||||
<div class="runtime-card">
|
||||
<div class="runtime-label">Version</div>
|
||||
<div class="runtime-value">${upgradeState?.currentVersion || '-'}${upgradeState?.latestVersion ? ` / ${upgradeState.latestVersion}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=${`runtime-message ${this.hostedRuntime.unavailableReason || upgradeState?.status === 'failed' ? 'error' : ''}`}>
|
||||
${this.getHostedRuntimeMessage()}
|
||||
</div>
|
||||
${upgradeState?.warnings?.length ? html`<div class="runtime-message">${upgradeState.warnings.join(' | ')}</div>` : ''}
|
||||
<div class="runtime-actions">
|
||||
<dees-button .text=${this.hostedRuntime.loading ? 'Refreshing...' : 'Refresh Status'} .type=${'secondary'} .disabled=${this.hostedRuntime.loading} @click=${() => this.refreshHostedRuntimeStatus()}></dees-button>
|
||||
<dees-button .text=${upgradeState?.status === 'running' ? 'Upgrade Running' : 'Start Parent Upgrade'} .type=${'primary'} .disabled=${!canStartUpgrade} @click=${() => this.startHostedRuntimeUpgrade()}></dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.isLoading && Object.keys(this.settings).length === 0) {
|
||||
return html`<div class="loading-container"><dees-spinner></dees-spinner></div>`;
|
||||
@@ -109,6 +262,7 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
return html`
|
||||
<cloudly-sectionheading>Settings</cloudly-sectionheading>
|
||||
<div class="settings-container">
|
||||
${this.renderHostedRuntimePanel()}
|
||||
<dees-form @formData=${(e: CustomEvent) => { this.saveSettings((e.detail as any).data); }}>
|
||||
<dees-panel .title=${'Hetzner Cloud'} .subtitle=${'Configure Hetzner Cloud API access'} .variant=${'outline'}>
|
||||
<div class="test-status">
|
||||
|
||||
@@ -266,32 +266,30 @@ export class CloudlyViewTasks extends DeesElement {
|
||||
|
||||
<cloudly-sectionheading>Execution History</cloudly-sectionheading>
|
||||
|
||||
<dees-panel .title=${'Recent Executions'} .subtitle=${'History of task runs and their outcomes'} .variant=${'outline'}>
|
||||
<dees-table
|
||||
.heading1=${'Task Executions'}
|
||||
.heading2=${'History of task runs and their outcomes'}
|
||||
.data=${this.data.taskExecutions || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
return {
|
||||
Task: itemArg.data.taskName,
|
||||
Status: html`<span class="status-badge status-${itemArg.data.status}">${itemArg.data.status}</span>`,
|
||||
'Started At': formatDate(itemArg.data.startedAt),
|
||||
Duration: itemArg.data.duration ? formatDuration(itemArg.data.duration) : '-',
|
||||
'Triggered By': itemArg.data.triggeredBy,
|
||||
Logs: itemArg.data.logs?.length || 0,
|
||||
} as any;
|
||||
}}
|
||||
.actionFunction=${async (itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
const actions: any[] = [
|
||||
{ name: 'View Details', iconName: 'lucide:Eye', type: ['inRow'], actionFunc: async () => { this.selectedExecution = itemArg; } }
|
||||
];
|
||||
if (itemArg.data.status === 'running') {
|
||||
actions.push({ name: 'Cancel', iconName: 'lucide:SquareX', type: ['inRow'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: itemArg.id }); await this.loadExecutionsWithFilter(); } });
|
||||
}
|
||||
return actions;
|
||||
}}
|
||||
></dees-table>
|
||||
</dees-panel>
|
||||
<dees-table
|
||||
.heading1=${'Task Executions'}
|
||||
.heading2=${'History of task runs and their outcomes'}
|
||||
.data=${this.data.taskExecutions || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
return {
|
||||
Task: itemArg.data.taskName,
|
||||
Status: html`<span class="status-badge status-${itemArg.data.status}">${itemArg.data.status}</span>`,
|
||||
'Started At': formatDate(itemArg.data.startedAt),
|
||||
Duration: itemArg.data.duration ? formatDuration(itemArg.data.duration) : '-',
|
||||
'Triggered By': itemArg.data.triggeredBy,
|
||||
Logs: itemArg.data.logs?.length || 0,
|
||||
} as any;
|
||||
}}
|
||||
.actionFunction=${async (itemArg: plugins.interfaces.data.ITaskExecution) => {
|
||||
const actions: any[] = [
|
||||
{ name: 'View Details', iconName: 'lucide:Eye', type: ['inRow'], actionFunc: async () => { this.selectedExecution = itemArg; } }
|
||||
];
|
||||
if (itemArg.data.status === 'running') {
|
||||
actions.push({ name: 'Cancel', iconName: 'lucide:SquareX', type: ['inRow'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.cancelTask, { executionId: itemArg.id }); await this.loadExecutionsWithFilter(); } });
|
||||
}
|
||||
return actions;
|
||||
}}
|
||||
></dees-table>
|
||||
|
||||
${this.selectedExecution ? html`
|
||||
<cloudly-sectionheading>Execution Details</cloudly-sectionheading>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
type IExecutionEnvironment = import('@design.estate/dees-catalog').IExecutionEnvironment;
|
||||
type IFileEntry = import('@design.estate/dees-catalog').IFileEntry;
|
||||
type IFileWatcher = import('@design.estate/dees-catalog').IFileWatcher;
|
||||
type IProcessHandle = import('@design.estate/dees-catalog').IProcessHandle;
|
||||
|
||||
type TTypedRequestShape = {
|
||||
method: string;
|
||||
request: Record<string, unknown>;
|
||||
response: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class DeploymentExecutionEnvironment implements IExecutionEnvironment {
|
||||
public readonly type = 'backend' as const;
|
||||
private readyState = false;
|
||||
|
||||
constructor(
|
||||
private deploymentId: string,
|
||||
private identity: plugins.interfaces.data.IIdentity,
|
||||
) {}
|
||||
|
||||
get ready(): boolean {
|
||||
return this.readyState;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceExists', { path: '/' }) as { exists: boolean };
|
||||
if (!result.exists) {
|
||||
throw new Error(`Cannot access deployment filesystem for ${this.deploymentId}`);
|
||||
}
|
||||
this.readyState = true;
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.readyState = false;
|
||||
}
|
||||
|
||||
public async readFile(pathArg: string): Promise<string> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceReadFile', { path: pathArg }) as { content: string };
|
||||
return result.content;
|
||||
}
|
||||
|
||||
public async writeFile(pathArg: string, contentsArg: string): Promise<void> {
|
||||
await this.fireRequest('deploymentWorkspaceWriteFile', { path: pathArg, content: contentsArg });
|
||||
}
|
||||
|
||||
public async readDir(pathArg: string): Promise<IFileEntry[]> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceReadDir', { path: pathArg }) as { entries: IFileEntry[] };
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
public async mkdir(pathArg: string): Promise<void> {
|
||||
await this.fireRequest('deploymentWorkspaceMkdir', { path: pathArg });
|
||||
}
|
||||
|
||||
public async rm(pathArg: string, optionsArg?: { recursive?: boolean }): Promise<void> {
|
||||
await this.fireRequest('deploymentWorkspaceRm', {
|
||||
path: pathArg,
|
||||
recursive: optionsArg?.recursive,
|
||||
});
|
||||
}
|
||||
|
||||
public async exists(pathArg: string): Promise<boolean> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceExists', { path: pathArg }) as { exists: boolean };
|
||||
return result.exists;
|
||||
}
|
||||
|
||||
public watch(
|
||||
_pathArg: string,
|
||||
_callbackArg: (eventArg: 'rename' | 'change', filenameArg: string | null) => void,
|
||||
_optionsArg?: { recursive?: boolean },
|
||||
): IFileWatcher {
|
||||
return { stop: () => {} };
|
||||
}
|
||||
|
||||
public async spawn(commandArg: string, argsArg: string[] = []): Promise<IProcessHandle> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceExec', {
|
||||
command: commandArg,
|
||||
args: argsArg,
|
||||
}) as { stdout?: string; stderr?: string; exitCode: number };
|
||||
|
||||
const output = new ReadableStream<string>({
|
||||
start(controllerArg) {
|
||||
if (result.stdout) controllerArg.enqueue(result.stdout);
|
||||
if (result.stderr) controllerArg.enqueue(result.stderr);
|
||||
controllerArg.close();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
output,
|
||||
input: new WritableStream<string>(),
|
||||
exit: Promise.resolve(result.exitCode),
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
private async fireRequest(methodArg: string, dataArg: Record<string, unknown>) {
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<TTypedRequestShape>(
|
||||
'/typedrequest',
|
||||
methodArg,
|
||||
);
|
||||
return await typedRequest.fire({
|
||||
identity: this.identity,
|
||||
deploymentId: this.deploymentId,
|
||||
...dataArg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import * as plugins from './plugins.js';
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
import './elements/index.js';
|
||||
import { appRouter } from './router.js';
|
||||
|
||||
appRouter.init();
|
||||
|
||||
plugins.deesElement.render(html`
|
||||
<cloudly-dashboard></cloudly-dashboard>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.deesDomtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
const flatViews = ['overview', 'logs'] as const;
|
||||
|
||||
const subviewMap: Record<string, readonly string[]> = {
|
||||
platform: ['settings', 'baseos', 'fleet'] as const,
|
||||
runtime: ['clusters', 'appstore', 'services', 'images', 'deployments', 'tasks'] as const,
|
||||
registry: ['externalregistries', 'testing'] as const,
|
||||
secrets: ['secretgroups', 'secretbundles'] as const,
|
||||
domains: ['domains', 'dns', 'mails'] as const,
|
||||
storage: ['s3', 'dbs', 'backups'] as const,
|
||||
};
|
||||
|
||||
const defaultSubview: Record<string, string> = {
|
||||
platform: 'settings',
|
||||
runtime: 'clusters',
|
||||
registry: 'externalregistries',
|
||||
secrets: 'secretgroups',
|
||||
domains: 'domains',
|
||||
storage: 's3',
|
||||
};
|
||||
|
||||
export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;
|
||||
|
||||
export function isValidView(view: string): boolean {
|
||||
return (validTopLevelViews as readonly string[]).includes(view);
|
||||
}
|
||||
|
||||
export function isValidSubview(view: string, subview: string): boolean {
|
||||
return subviewMap[view]?.includes(subview) ?? false;
|
||||
}
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
private initialized = false;
|
||||
private suppressStateUpdate = false;
|
||||
|
||||
constructor() {
|
||||
this.router = new SmartRouter({ debug: false });
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
if (this.initialized) return;
|
||||
this.setupRoutes();
|
||||
this.setupStateSync();
|
||||
this.handleInitialRoute();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
for (const view of flatViews) {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view, null);
|
||||
});
|
||||
}
|
||||
|
||||
for (const view of Object.keys(subviewMap)) {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.navigateTo(`/${view}/${defaultSubview[view]}`);
|
||||
});
|
||||
|
||||
for (const subview of subviewMap[view]) {
|
||||
this.router.on(`/${view}/${subview}`, async () => {
|
||||
this.updateViewState(view, subview);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.router.on('/', async () => {
|
||||
this.navigateTo('/overview');
|
||||
});
|
||||
}
|
||||
|
||||
private setupStateSync(): void {
|
||||
appstate.uiStatePart.select().subscribe((uiState) => {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const expectedPath = uiState.activeSubview
|
||||
? `/${uiState.activeView}/${uiState.activeSubview}`
|
||||
: `/${uiState.activeView}`;
|
||||
|
||||
if (currentPath !== expectedPath) {
|
||||
this.suppressStateUpdate = true;
|
||||
this.router.pushUrl(expectedPath);
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleInitialRoute(): void {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (!path || path === '/') {
|
||||
this.router.pushUrl('/overview');
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
const subview = segments[1];
|
||||
|
||||
if (!isValidView(view)) {
|
||||
this.router.pushUrl('/overview');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subviewMap[view]) {
|
||||
if (subview && isValidSubview(view, subview)) {
|
||||
this.updateViewState(view, subview);
|
||||
} else {
|
||||
this.router.pushUrl(`/${view}/${defaultSubview[view]}`);
|
||||
}
|
||||
} else {
|
||||
this.updateViewState(view, null);
|
||||
}
|
||||
}
|
||||
|
||||
private updateViewState(view: string, subview: string | null): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.uiStatePart.getState()!;
|
||||
if (currentState.activeView !== view || currentState.activeSubview !== subview) {
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeView: view,
|
||||
activeSubview: subview,
|
||||
});
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
public navigateTo(path: string): void {
|
||||
this.router.pushUrl(path);
|
||||
}
|
||||
|
||||
public navigateToView(view: string, subview?: string): void {
|
||||
if (!isValidView(view)) {
|
||||
this.navigateTo('/overview');
|
||||
return;
|
||||
}
|
||||
|
||||
if (subview && isValidSubview(view, subview)) {
|
||||
this.navigateTo(`/${view}/${subview}`);
|
||||
} else if (subviewMap[view]) {
|
||||
this.navigateTo(`/${view}/${defaultSubview[view]}`);
|
||||
} else {
|
||||
this.navigateTo(`/${view}`);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.router.destroy();
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const appRouter = new AppRouter();
|
||||
Reference in New Issue
Block a user