feat(web): add dashboard SPA routing

This commit is contained in:
2026-05-21 16:16:00 +00:00
parent 50bcbe0f45
commit d0b15ab51b
6 changed files with 297 additions and 19 deletions
+101 -19
View File
@@ -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,
@@ -36,6 +37,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 +50,108 @@ 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: '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 +161,24 @@ 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);
}
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 +203,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>
@@ -158,6 +216,30 @@ export class CloudlyDashboard extends DeesElement {
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, [
{