From d0b15ab51bfcff1a9651459a8e511880bbaaffe2 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 21 May 2026 16:16:00 +0000 Subject: [PATCH] feat(web): add dashboard SPA routing --- changelog.md | 5 + ts/classes.server.ts | 1 + ts_web/appstate.ts | 27 +++++ ts_web/elements/cloudly-dashboard.ts | 120 ++++++++++++++++---- ts_web/index.ts | 3 + ts_web/router.ts | 160 +++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 ts_web/router.ts diff --git a/changelog.md b/changelog.md index afc5e8e..5c3e021 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ ## Pending +### 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 diff --git a/ts/classes.server.ts b/ts/classes.server.ts index bc4db5b..d4a4439 100644 --- a/ts/classes.server.ts +++ b/ts/classes.server.ts @@ -71,6 +71,7 @@ export class CloudlyServer { : {}), injectReload: true, serveDir: paths.distServeDir, + spaFallback: true, watch: true, compression: { enabled: true, diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 45e1407..5b30578 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -11,6 +11,33 @@ export const loginStatePart: plugins.smartstate.StatePart 'persistent' ); +export interface IUiState { + activeView: string; + activeSubview: string | null; +} + +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( + 'ui', + { + activeView: getInitialView(), + activeSubview: getInitialSubview(), + }, +); + export const loginAction = loginStatePart.createAction<{ username: string; password: string }>( async (statePartArg, payloadArg) => { const currentState = statePartArg.getState() || { identity: null }; diff --git a/ts_web/elements/cloudly-dashboard.ts b/ts_web/elements/cloudly-dashboard.ts index 208e2df..16c4f1c 100644 --- a/ts_web/elements/cloudly-dashboard.ts +++ b/ts_web/elements/cloudly-dashboard.ts @@ -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 { @@ -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, [ { diff --git a/ts_web/index.ts b/ts_web/index.ts index 5cdd4cd..6c95f1f 100644 --- a/ts_web/index.ts +++ b/ts_web/index.ts @@ -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` diff --git a/ts_web/router.ts b/ts_web/router.ts new file mode 100644 index 0000000..9f44ebf --- /dev/null +++ b/ts_web/router.ts @@ -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 = { + platform: ['settings', 'baseos', 'fleet'] as const, + runtime: ['clusters', '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 = { + 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; + 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();