From b8dccac68d92875d337bcfff8ae3116c5c238088 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 22 May 2026 13:45:21 +0000 Subject: [PATCH] feat(ui): group dashboard navigation into sectioned routes and align view layouts with dcrouter --- changelog.md | 16 +++ package.json | 2 +- ts_web/elements/sipproxy-app.ts | 120 ++++++++++++++------ ts_web/elements/sipproxy-view-calls.ts | 2 + ts_web/elements/sipproxy-view-contacts.ts | 4 +- ts_web/elements/sipproxy-view-ivr.ts | 2 + ts_web/elements/sipproxy-view-log.ts | 8 +- ts_web/elements/sipproxy-view-overview.ts | 22 +--- ts_web/elements/sipproxy-view-phone.ts | 2 + ts_web/elements/sipproxy-view-providers.ts | 2 + ts_web/elements/sipproxy-view-routes.ts | 2 + ts_web/elements/sipproxy-view-voicemail.ts | 2 + ts_web/router.ts | 124 ++++++++++++++------- 13 files changed, 215 insertions(+), 93 deletions(-) diff --git a/changelog.md b/changelog.md index 9c260a8..76f0b7f 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,22 @@ ## Pending +### Features + +- group dashboard navigation and align view layout with dcrouter (ui) + - replace flat SipRouter dashboard tabs with grouped Overview, Telephony, Configuration, and System sections + - move dashboard URLs to grouped route paths such as `/telephony/calls` and `/configuration/providers` + - add consistent view headings and shared layout spacing across dashboard views +- group dashboard navigation into sectioned routes and align view layouts with dcrouter (ui) + - replace flat dashboard tabs with grouped Overview, Telephony, Configuration, and System navigation + - move routing to nested view/subview paths such as /telephony/calls and /configuration/providers + - add consistent headings and shared spacing across dashboard views + - update contact actions and app navigation syncing to use grouped routes + - switch restartBackground to the populate helper for loading local SmartData and SmartBucket config before restarting + +### Fixes + +- load local SmartData/SmartBucket config before restarting the development background server (dev) ## 2026-05-22 - 1.27.1 diff --git a/package.json b/package.json index e448898..bafde3a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:docker": "tsdocker build --verbose", "release:docker": "tsdocker push --verbose", "start": "tsx ts/sipproxy.ts", - "restartBackground": "pnpm run buildRust && pnpm run bundle; test -f .server.pid && kill $(cat .server.pid) 2>/dev/null; sleep 1; rm -f sip_trace.log proxy.out && nohup tsx ts/sipproxy.ts > proxy.out 2>&1 & echo $! > .server.pid; sleep 2; cat proxy.out" + "restartBackground": "tsx .nogit/populate.ts --restart-background" }, "dependencies": { "@design.estate/dees-catalog": "^3.81.0", diff --git a/ts_web/elements/sipproxy-app.ts b/ts_web/elements/sipproxy-app.ts index c4ec890..de2d49f 100644 --- a/ts_web/elements/sipproxy-app.ts +++ b/ts_web/elements/sipproxy-app.ts @@ -1,7 +1,8 @@ import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js'; import { deesCatalog } from '../plugins.js'; +import type { IView } from '@design.estate/dees-catalog'; import { NotificationManager } from '../state/notification-manager.js'; -import { appRouter } from '../router.js'; +import { appRouter, type TSubviewSlug, type TViewSlug } from '../router.js'; import { SipproxyViewOverview } from './sipproxy-view-overview.js'; import { SipproxyViewCalls } from './sipproxy-view-calls.js'; import { SipproxyViewPhone } from './sipproxy-view-phone.js'; @@ -12,25 +13,56 @@ import { SipproxyViewRoutes } from './sipproxy-view-routes.js'; import { SipproxyViewVoicemail } from './sipproxy-view-voicemail.js'; import { SipproxyViewIvr } from './sipproxy-view-ivr.js'; -const VIEW_TABS = [ - { name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview }, - { name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls }, - { name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone }, - { name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes }, - { name: 'Voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail }, - { name: 'IVR', iconName: 'lucide:ListTree', element: SipproxyViewIvr }, - { name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts }, - { name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders }, - { name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog }, -]; +interface ITabbedView extends IView { + slug?: string; + subViews?: ITabbedView[]; +} -// Map slug -> tab for routing. -const SLUG_TO_TAB = new Map(VIEW_TABS.map((t) => [t.name.toLowerCase(), t])); +const VIEW_TABS: ITabbedView[] = [ + { + name: 'Overview', + slug: 'overview', + iconName: 'lucide:layoutDashboard', + subViews: [ + { name: 'Stats', slug: 'stats', iconName: 'lucide:activity', element: SipproxyViewOverview }, + ], + }, + { + name: 'Telephony', + slug: 'telephony', + iconName: 'lucide:phoneCall', + subViews: [ + { name: 'Calls', slug: 'calls', iconName: 'lucide:phone', element: SipproxyViewCalls }, + { name: 'Phone', slug: 'phone', iconName: 'lucide:headset', element: SipproxyViewPhone }, + { name: 'Routes', slug: 'routes', iconName: 'lucide:route', element: SipproxyViewRoutes }, + { name: 'Voicemail', slug: 'voicemail', iconName: 'lucide:voicemail', element: SipproxyViewVoicemail }, + { name: 'IVR', slug: 'ivr', iconName: 'lucide:ListTree', element: SipproxyViewIvr }, + ], + }, + { + name: 'Configuration', + slug: 'configuration', + iconName: 'lucide:settings', + subViews: [ + { name: 'Contacts', slug: 'contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts }, + { name: 'Providers', slug: 'providers', iconName: 'lucide:server', element: SipproxyViewProviders }, + ], + }, + { + name: 'System', + slug: 'system', + iconName: 'lucide:serverCog', + subViews: [ + { name: 'Log', slug: 'log', iconName: 'lucide:scrollText', element: SipproxyViewLog }, + ], + }, +]; @customElement('sipproxy-app') export class SipproxyApp extends DeesElement { private notificationManager = new NotificationManager(); private appdash: InstanceType | null = null; + private viewTabs = VIEW_TABS; public static styles = [ cssManager.defaultStyles, @@ -42,24 +74,49 @@ export class SipproxyApp extends DeesElement { private suppressViewSelectEvent = false; + private slugFor(view: ITabbedView): string { + return view.slug ?? view.name.toLowerCase().replace(/\s+/g, ''); + } + + private findParent(view: ITabbedView): ITabbedView | undefined { + return this.viewTabs.find((tab) => tab.subViews?.includes(view)); + } + + private findViewBySlug(viewSlug: string, subviewSlug: string): ITabbedView | undefined { + const top = this.viewTabs.find((tab) => this.slugFor(tab) === viewSlug); + if (!top) return undefined; + return top.subViews?.find((subview) => this.slugFor(subview) === subviewSlug) ?? top; + } + + private get currentViewTab(): ITabbedView { + const currentRoute = appRouter.getCurrentRoute(); + return this.findViewBySlug(currentRoute.view, currentRoute.subview) ?? this.viewTabs[0].subViews![0]; + } + async firstUpdated() { this.appdash = this.shadowRoot?.querySelector('dees-simple-appdash') as InstanceType; if (this.appdash) { this.notificationManager.init(this.appdash); - // Listen for user tab selections — sync URL. + // Listen for user tab selections and sync grouped URLs. this.appdash.addEventListener('view-select', ((e: CustomEvent) => { if (this.suppressViewSelectEvent) return; - const viewName: string = e.detail?.view?.name || e.detail?.name || ''; - const slug = viewName.toLowerCase(); - if (slug && slug !== appRouter.getCurrentView()) { - appRouter.navigateTo(slug as any, true); + const view = e.detail?.view as ITabbedView | undefined; + if (!view) return; + + const parent = this.findParent(view); + if (!parent) return; + + const parentSlug = this.slugFor(parent) as TViewSlug; + const subviewSlug = this.slugFor(view) as TSubviewSlug; + if (!appRouter.isCurrentRoute(parentSlug, subviewSlug)) { + appRouter.navigateToView(parentSlug, subviewSlug, true); } }) as EventListener); // Wire up router -> appdash (for browser back/forward). - appRouter.setNavigateHandler((view) => { - const tab = SLUG_TO_TAB.get(view); + appRouter.setNavigateHandler((view, subview) => { + const tab = this.findViewBySlug(view, subview); if (tab && this.appdash) { this.suppressViewSelectEvent = true; this.appdash.loadView(tab); @@ -67,21 +124,17 @@ export class SipproxyApp extends DeesElement { } }); - // Deep link: if URL isn't "overview", navigate to the right tab. - const initial = appRouter.getCurrentView(); - if (initial !== 'overview') { - const tab = SLUG_TO_TAB.get(initial); - if (tab) { - this.suppressViewSelectEvent = true; - this.appdash.loadView(tab); - this.suppressViewSelectEvent = false; - } + const initialTab = this.currentViewTab; + if (initialTab) { + this.suppressViewSelectEvent = true; + this.appdash.loadView(initialTab); + this.suppressViewSelectEvent = false; } } } - disconnectedCallback() { - super.disconnectedCallback(); + public async disconnectedCallback() { + await super.disconnectedCallback(); this.notificationManager.destroy(); } @@ -89,7 +142,8 @@ export class SipproxyApp extends DeesElement { return html` `; } diff --git a/ts_web/elements/sipproxy-view-calls.ts b/ts_web/elements/sipproxy-view-calls.ts index be6a517..854ae26 100644 --- a/ts_web/elements/sipproxy-view-calls.ts +++ b/ts_web/elements/sipproxy-view-calls.ts @@ -942,6 +942,8 @@ export class SipproxyViewCalls extends DeesElement { ]; return html` + Calls +
diff --git a/ts_web/elements/sipproxy-view-contacts.ts b/ts_web/elements/sipproxy-view-contacts.ts index d8e0c66..1386bfc 100644 --- a/ts_web/elements/sipproxy-view-contacts.ts +++ b/ts_web/elements/sipproxy-view-contacts.ts @@ -268,7 +268,7 @@ export class SipproxyViewContacts extends DeesElement { type: ['inRow'] as any, actionFunc: async ({ item }: { item: IContact }) => { appState.selectContact(item); - appRouter.navigateTo('phone' as any); + appRouter.navigateToView('telephony', 'phone'); }, }, { @@ -343,6 +343,8 @@ export class SipproxyViewContacts extends DeesElement { ]; return html` + Contacts +
diff --git a/ts_web/elements/sipproxy-view-ivr.ts b/ts_web/elements/sipproxy-view-ivr.ts index 4074142..4e120a1 100644 --- a/ts_web/elements/sipproxy-view-ivr.ts +++ b/ts_web/elements/sipproxy-view-ivr.ts @@ -624,6 +624,8 @@ export class SipproxyViewIvr extends DeesElement { const menus = ivr.menus || []; return html` + IVR +
Log + d.connected).length; return html` + Stats + -
Devices
+ Devices Phone +
${this.renderDialer()} ${this.renderPhoneStatus()} diff --git a/ts_web/elements/sipproxy-view-providers.ts b/ts_web/elements/sipproxy-view-providers.ts index 81f51c0..146dc2d 100644 --- a/ts_web/elements/sipproxy-view-providers.ts +++ b/ts_web/elements/sipproxy-view-providers.ts @@ -780,6 +780,8 @@ export class SipproxyViewProviders extends DeesElement { const providers = this.appData.providers || []; return html` + Providers +
Route Management +
diff --git a/ts_web/elements/sipproxy-view-voicemail.ts b/ts_web/elements/sipproxy-view-voicemail.ts index 1fced3c..4a99bf0 100644 --- a/ts_web/elements/sipproxy-view-voicemail.ts +++ b/ts_web/elements/sipproxy-view-voicemail.ts @@ -458,6 +458,8 @@ export class SipproxyViewVoicemail extends DeesElement { public render(): TemplateResult { return html` + Voicemail +
void) | null = null; - private suppressPush = false; + private currentRoute: IRouteState = DEFAULT_ROUTE; + private onNavigate: ((view: TViewSlug, subview: TSubviewSlug) => void) | null = null; + private initialized = false; init(): void { - // Parse initial URL. - const path = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview'; - if (VIEWS.includes(path as TViewSlug)) { - this.currentView = path as TViewSlug; - } + if (this.initialized) return; + + this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true); // Handle browser back/forward. window.addEventListener('popstate', () => { - const p = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview'; - if (VIEWS.includes(p as TViewSlug)) { - this.suppressPush = true; - this.navigateTo(p as TViewSlug); - this.suppressPush = false; - } + this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true); }); + + this.initialized = true; } - setNavigateHandler(handler: (view: TViewSlug) => void): void { + setNavigateHandler(handler: (view: TViewSlug, subview: TSubviewSlug) => void): void { this.onNavigate = handler; } - navigateTo(view: TViewSlug, skipCallback = false): void { - this.currentView = view; - if (!this.suppressPush) { - const url = `/${view}`; - if (location.pathname !== url) { + navigateToView(view: TViewSlug, subview?: TSubviewSlug, skipCallback = false): void { + const targetSubview = subview && isValidSubview(view, subview) ? subview : DEFAULT_SUBVIEW[view]; + this.applyRoute({ view, subview: targetSubview }, skipCallback, false); + } + + getCurrentRoute(): IRouteState { + return this.currentRoute; + } + + isCurrentRoute(view: TViewSlug, subview: TSubviewSlug): boolean { + return this.currentRoute.view === view && this.currentRoute.subview === subview; + } + + private parsePath(pathname: string): IRouteState | null { + const segments = pathname.split('/').filter(Boolean); + if (segments.length === 0) return DEFAULT_ROUTE; + if (segments.length > 2) return null; + + const [viewSegment, subviewSegment] = segments; + if (!isValidView(viewSegment)) return null; + + const targetSubview = subviewSegment ?? DEFAULT_SUBVIEW[viewSegment]; + if (!isValidSubview(viewSegment, targetSubview)) return null; + + return { + view: viewSegment, + subview: targetSubview, + }; + } + + private applyRoute(route: IRouteState, skipCallback: boolean, replace: boolean): void { + this.currentRoute = route; + + const url = `/${route.view}/${route.subview}`; + if (location.pathname !== url) { + if (replace) { + history.replaceState(null, '', url); + } else { history.pushState(null, '', url); } } + if (!skipCallback) { - this.onNavigate?.(view); + this.onNavigate?.(route.view, route.subview); } } - - /** Called when the user selects a tab in dees-simple-appdash. */ - onViewSelect(viewName: string): void { - const slug = viewName.toLowerCase().replace(/\s+/g, '-'); - const mapped = VIEWS.find((v) => v === slug || viewName.toLowerCase().startsWith(v)); - if (mapped) { - this.navigateTo(mapped); - } - } - - getCurrentView(): TViewSlug { - return this.currentView; - } } export const appRouter = new AppRouter(); -export type { TViewSlug }; +export type { IRouteState, TSubviewSlug, TViewSlug };