feat(cluster): Add cluster setupMode (manual|hetzner|aws|digitalocean) with conditional Hetzner auto-provisioning; UI and dashboard improvements; dependency upgrades

This commit is contained in:
2025-09-05 16:07:46 +00:00
parent 330797ab1a
commit eefaa55e13
13 changed files with 392 additions and 314 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 2025-09-05 - 5.1.0 - feat(cluster)
Add cluster setupMode (manual|hetzner|aws|digitalocean) with conditional Hetzner auto-provisioning; UI and dashboard improvements; dependency upgrades
- Introduce optional setupMode on cluster configs and requests (ICluster.data.setupMode, createCluster request) to allow 'manual' | 'hetzner' | 'aws' | 'digitalocean'.
- ClusterManager: default setupMode to 'manual' when creating clusters and only trigger serverManager.ensureServerInfrastructure() for 'hetzner' clusters.
- ServerManager: skip provisioning for clusters not configured with setupMode 'hetzner' and log skipped clusters.
- Web UI: add a 'Setup Mode' dropdown when creating a cluster so users can choose auto-provisioning provider; ensure the add-cluster action passes setupMode.
- Web UI: dashboard enhancements — add icons to view tabs and replace cluster overview with a stats grid (including total clusters, total servers, images, services, deployments, secret groups/bundles, DNS, DBs, backups, mails, s3). The overview now computes total servers across clusters.
- Package dependency bumps (devDependencies and dependencies) to keep libs up-to-date (examples: @git.zone/tsbuild, @git.zone/tstest, @api.global/typedserver, @apiclient.xyz/docker, @design.estate/dees-catalog, @push.rocks/smartlog, @push.rocks/smartrequest, @push.rocks/taskbuffer, etc.).
- Add .claude/settings.local.json with local Claude permissions (editor/automation config).
## 2025-08-18 - 5.0.6 - fix(connector.letsencrypt)
Improve Let's Encrypt integration and certificate handling; fix coreflow certificate response; add local assistant permissions config

View File

@@ -22,24 +22,24 @@
"docs": "tsdoc aidoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.7",
"@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsdoc": "^1.5.1",
"@git.zone/tspublish": "^1.10.3",
"@git.zone/tstest": "^2.3.5",
"@git.zone/tstest": "^2.3.6",
"@git.zone/tswatch": "^2.2.1",
"@types/node": "^22.0.0"
},
"dependencies": {
"@api.global/typedrequest": "3.1.10",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^3.0.77",
"@api.global/typedserver": "^3.0.79",
"@api.global/typedsocket": "^3.0.1",
"@apiclient.xyz/cloudflare": "^6.4.1",
"@apiclient.xyz/docker": "^1.3.0",
"@apiclient.xyz/docker": "^1.3.5",
"@apiclient.xyz/hetznercloud": "^1.2.0",
"@apiclient.xyz/slack": "^3.0.9",
"@design.estate/dees-catalog": "^1.10.10",
"@design.estate/dees-catalog": "^1.10.12",
"@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.1.2",
"@git.zone/tsrun": "^1.3.3",
@@ -59,19 +59,19 @@
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjson": "^5.0.19",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.8",
"@push.rocks/smartlog": "^3.1.9",
"@push.rocks/smartlog-destination-clickhouse": "^1.0.13",
"@push.rocks/smartlog-interfaces": "^3.0.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^4.2.2",
"@push.rocks/smartrequest": "^4.3.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartssh": "^2.0.1",
"@push.rocks/smartstate": "^2.0.26",
"@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smartstring": "^4.0.15",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^3.0.2",
"@push.rocks/taskbuffer": "^3.1.10",
"@push.rocks/webjwt": "^1.0.9",
"@tsclass/tsclass": "^9.2.0"
},

491
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.0.6',
version: '5.1.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}

View File

@@ -24,11 +24,13 @@ export class ClusterManager {
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IRequest_CreateCluster>(
new plugins.typedrequest.TypedHandler('createCluster', async (dataArg) => {
// TODO: guards
const setupMode = dataArg.setupMode || 'manual'; // Default to manual if not specified
const cluster = await this.createCluster({
id: plugins.smartunique.uniSimple('cluster'),
data: {
userId: null, // this is created by the createCluster method
name: dataArg.clusterName,
setupMode: setupMode,
acmeInfo: null,
cloudlyUrl: `https://${this.cloudlyRef.config.data.publicUrl}:${this.cloudlyRef.config.data.publicPort}/`,
servers: [],
@@ -36,7 +38,12 @@ export class ClusterManager {
},
});
console.log(await cluster.createSavableObject());
this.cloudlyRef.serverManager.ensureServerInfrastructure();
// Only auto-provision servers if setupMode is 'hetzner'
if (setupMode === 'hetzner') {
this.cloudlyRef.serverManager.ensureServerInfrastructure();
}
return {
cluster: await cluster.createSavableObject(),
};

View File

@@ -55,6 +55,12 @@ export class CloudlyServerManager {
// get all clusters
const allClusters = await this.cloudlyRef.clusterManager.getAllClusters();
for (const cluster of allClusters) {
// Skip clusters that are not set up for Hetzner auto-provisioning
if (cluster.data.setupMode !== 'hetzner') {
console.log(`Skipping server provisioning for cluster ${cluster.id} - setupMode is ${cluster.data.setupMode || 'manual'}`);
continue;
}
// get existing servers
const servers = await this.getServersByCluster(cluster);

View File

@@ -18,6 +18,11 @@ export interface ICluster {
*/
cloudlyUrl?: string;
/**
* Cluster setup mode - manual by default, or auto-provision with cloud provider
*/
setupMode?: 'manual' | 'hetzner' | 'aws' | 'digitalocean';
/**
* what servers are expected to be part of the cluster
*/

View File

@@ -41,6 +41,7 @@ export interface IRequest_CreateCluster extends plugins.typedrequestInterfaces.i
request: {
identity: userInterfaces.IIdentity;
clusterName: string;
setupMode?: 'manual' | 'hetzner' | 'aws' | 'digitalocean';
};
response: {
cluster: clusterInterfaces.ICluster;

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '5.0.6',
version: '5.1.0',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}

View File

@@ -245,6 +245,7 @@ export const addClusterAction = dataState.createAction(
statePartArg,
payloadArg: {
clusterName: string;
setupMode?: 'manual' | 'hetzner' | 'aws' | 'digitalocean';
}
) => {
let currentState = statePartArg.getState();

View File

@@ -76,66 +76,82 @@ export class CloudlyDashboard extends DeesElement {
.viewTabs=${[
{
name: 'Overview',
iconName: 'lucide:LayoutDashboard',
element: CloudlyViewOverview,
},
{
name: 'SecretGroups',
iconName: 'lucide:ShieldCheck',
element: CloudlyViewSecretGroups,
},
{
name: 'SecretBundles',
iconName: 'lucide:LockKeyhole',
element: CloudlyViewSecretBundles,
},
{
name: 'Clusters',
iconName: 'lucide:Network',
element: CloudlyViewClusters,
},
{
name: 'ExternalRegistries',
iconName: 'lucide:Package',
element: CloudlyViewExternalRegistries,
},
{
name: 'Images',
iconName: 'lucide:Image',
element: CloudlyViewImages,
},
{
name: 'Services',
iconName: 'lucide:Layers',
element: CloudlyViewServices,
},
{
name: 'Testing & Building',
iconName: 'lucide:HardHat',
element: CloudlyViewServices,
},
{
name: 'Deployments',
iconName: 'lucide:Rocket',
element: CloudlyViewDeployments,
},
{
name: 'DNS',
iconName: 'lucide:Globe',
element: CloudlyViewDns,
},
{
name: 'Mails',
iconName: 'lucide:Mail',
element: CloudlyViewMails,
},
{
name: 'Logs',
iconName: 'lucide:FileText',
element: CloudlyViewLogs,
},
{
name: 's3',
iconName: 'lucide:Cloud',
element: CloudlyViewS3,
},
{
name: 'DBs',
iconName: 'lucide:Database',
element: CloudlyViewDbs,
},
{
name: 'Backups',
iconName: 'lucide:Save',
element: CloudlyViewBackups,
},
{
name: 'Fleet',
iconName: 'lucide:Truck',
element: CloudlyViewBackups,
}
] as plugins.deesCatalog.IView[]}

View File

@@ -68,6 +68,18 @@ export class CloudlyViewClusters extends DeesElement {
.description=${'a descriptive name for the cluster'}
.value=${''}
></dees-input-text>
<dees-input-dropdown
.key=${'setupMode'}
.label=${'Setup Mode'}
.description=${'How the cluster infrastructure should be managed'}
.options=${[
{option: 'manual', key: 'manual', description: 'Manual Setup - Add your own servers manually'},
{option: 'hetzner', key: 'hetzner', description: 'Hetzner Cloud - Auto-provision servers on Hetzner'},
{option: 'aws', key: 'aws', description: 'AWS - Auto-provision on Amazon Web Services (coming soon)', disabled: true},
{option: 'digitalocean', key: 'digitalocean', description: 'DigitalOcean - Auto-provision on DigitalOcean (coming soon)', disabled: true}
]}
.selectedOption=${'manual'}
></dees-input-dropdown>
</dees-form>
`,
menuOptions: [
@@ -76,6 +88,7 @@ export class CloudlyViewClusters extends DeesElement {
action: async (modalArg) => {
const data: {
clusterName: string;
setupMode: 'manual' | 'hetzner' | 'aws' | 'digitalocean';
} = (await modalArg.shadowRoot
.querySelector('dees-form')
.collectFormData()) as any;

View File

@@ -1,4 +1,3 @@
import * as plugins from '../plugins.js';
import * as shared from '../elements/shared/index.js';
import {
@@ -34,34 +33,124 @@ export class CloudlyViewOverview extends DeesElement {
cssManager.defaultStyles,
shared.viewHostCss,
css`
.clusterGrid {
display: grid;
grid-template-columns: ${cssManager.cssGridColumns(3, 8)};
grid-gap: 16px;
margin-bottom: 40px;
dees-statsgrid {
margin-top: 24px;
}
`,
];
public render() {
// Calculate total servers across all clusters
const totalServers = this.data.clusters?.reduce((sum, cluster) =>
sum + (cluster.data.servers?.length || 0), 0) || 0;
// Create tiles for the stats grid
const statsTiles = [
{
id: 'clusters',
title: 'Total Clusters',
value: this.data.clusters?.length || 0,
type: 'number' as const,
iconName: 'lucide:Network',
description: 'Active clusters'
},
{
id: 'servers',
title: 'Total Servers',
value: totalServers,
type: 'number' as const,
iconName: 'lucide:Server',
description: 'Connected servers'
},
{
id: 'services',
title: 'Services',
value: this.data.services?.length || 0,
type: 'number' as const,
iconName: 'lucide:Layers',
description: 'Deployed services'
},
{
id: 'deployments',
title: 'Deployments',
value: this.data.deployments?.length || 0,
type: 'number' as const,
iconName: 'lucide:Rocket',
description: 'Active deployments'
},
{
id: 'secretGroups',
title: 'Secret Groups',
value: this.data.secretGroups?.length || 0,
type: 'number' as const,
iconName: 'lucide:ShieldCheck',
description: 'Configured secret groups'
},
{
id: 'secretBundles',
title: 'Secret Bundles',
value: this.data.secretBundles?.length || 0,
type: 'number' as const,
iconName: 'lucide:LockKeyhole',
description: 'Available secret bundles'
},
{
id: 'images',
title: 'Images',
value: this.data.images?.length || 0,
type: 'number' as const,
iconName: 'lucide:Image',
description: 'Container images'
},
{
id: 'dns',
title: 'DNS Zones',
value: this.data.dns?.length || 0,
type: 'number' as const,
iconName: 'lucide:Globe',
description: 'Managed DNS zones'
},
{
id: 'databases',
title: 'Databases',
value: this.data.dbs?.length || 0,
type: 'number' as const,
iconName: 'lucide:Database',
description: 'Database instances'
},
{
id: 'backups',
title: 'Backups',
value: this.data.backups?.length || 0,
type: 'number' as const,
iconName: 'lucide:Save',
description: 'Available backups'
},
{
id: 'mails',
title: 'Mail Domains',
value: this.data.mails?.length || 0,
type: 'number' as const,
iconName: 'lucide:Mail',
description: 'Mail configurations'
},
{
id: 's3',
title: 'S3 Buckets',
value: this.data.s3?.length || 0,
type: 'number' as const,
iconName: 'lucide:Cloud',
description: 'Storage buckets'
}
];
return html`
<cloudly-sectionheading>Overview</cloudly-sectionheading>
${this.data.clusters.length === 0 ? html`
You need to create at least one cluster to see an overview.
`: html``}
${this.data.clusters.map(
(clusterArg) => html`
<dees-label .label=${'cluster: ' + clusterArg.data.name}></dees-label>
<div class="clusterGrid">
<dees-chart-area .label=${'System Usage'}></dees-chart-area>
<dees-chart-area .label=${'Internet Traffic'}></dees-chart-area>
<dees-chart-area .label=${'Requests'}></dees-chart-area>
<dees-chart-area .label=${'WebSocket Connections'}></dees-chart-area>
<dees-chart-log class="services" .label=${'Deployed Services'}></dees-chart-log>
<dees-chart-log class="eventLog" .label=${'Event Log'}></dees-chart-log>
</div>
`
)}
<dees-statsgrid
.tiles=${statsTiles}
.minTileWidth=${250}
.gap=${16}
></dees-statsgrid>
`;
}
}