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
+5
View File
@@ -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
+1
View File
@@ -71,6 +71,7 @@ export class CloudlyServer {
: {}),
injectReload: true,
serveDir: paths.distServeDir,
spaFallback: true,
watch: true,
compression: {
enabled: true,
+27
View File
@@ -11,6 +11,33 @@ export const loginStatePart: plugins.smartstate.StatePart<unknown, ILoginState>
'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<IUiState>(
'ui',
{
activeView: getInitialView(),
activeSubview: getInitialSubview(),
},
);
export const loginAction = loginStatePart.createAction<{ username: string; password: string }>(
async (statePartArg, payloadArg) => {
const currentState = statePartArg.getState() || { identity: null };
+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, [
{
+3
View File
@@ -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>
+160
View File
@@ -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', '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();