Compare commits

..

6 Commits

Author SHA1 Message Date
jkunz 2fcb7d88b3 v1.28.0
Docker (tags) / release (push) Failing after 4s
2026-05-22 13:46:19 +00:00
jkunz c7cf13a107 docs(changelog): remove duplicate pending notes 2026-05-22 13:45:56 +00:00
jkunz b8dccac68d feat(ui): group dashboard navigation into sectioned routes and align view layouts with dcrouter 2026-05-22 13:45:21 +00:00
jkunz 60fbb4be2b v1.27.1
Docker (tags) / release (push) Failing after 4s
2026-05-22 10:39:25 +00:00
jkunz fd90c9c73e docs(changelog): remove duplicate pending entry 2026-05-22 10:39:01 +00:00
jkunz 690e19eff8 fix(docker): install fax build and runtime libraries for Docker images and update release tooling dependencies 2026-05-22 10:38:25 +00:00
17 changed files with 864 additions and 139 deletions
+7 -1
View File
@@ -8,9 +8,14 @@ FROM code.foss.global/host.today/ht-docker-node:lts AS build
# prebuilt-binary download path doesn't apply. # prebuilt-binary download path doesn't apply.
# - pkg-config : used by audiopus_sys and other *-sys crates to locate libs # - pkg-config : used by audiopus_sys and other *-sys crates to locate libs
# on the native target (safe no-op if they vendor their own). # on the native target (safe no-op if they vendor their own).
# - libtiff-dev/libjpeg-dev : required by spandsp-sys fax/T.38 bindings.
# - libclang-dev: required by bindgen-based Rust build scripts.
# These are normally pre-installed on dev machines but not in ht-docker-node:lts. # These are normally pre-installed on dev machines but not in ht-docker-node:lts.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \ cmake \
libclang-dev \
libjpeg-dev \
libtiff-dev \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -51,7 +56,8 @@ RUN rm -rf .pnpm-store
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
# gcompat + libstdc++ let the glibc-linked proxy-engine binary run on Alpine. # gcompat + libstdc++ let the glibc-linked proxy-engine binary run on Alpine.
RUN apk add --no-cache gcompat libstdc++ # tiff + libjpeg-turbo provide the fax engine's dynamic image codec libs.
RUN apk add --no-cache gcompat libstdc++ libjpeg-turbo tiff
WORKDIR /app WORKDIR /app
COPY --from=build /app /app COPY --from=build /app /app
+20
View File
@@ -3,6 +3,26 @@
## Pending ## Pending
## 2026-05-22 - 1.28.0
### Features
- 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
## 2026-05-22 - 1.27.1
### Fixes
- install fax build and runtime libraries for Docker images and update release tooling dependencies (docker)
- add libclang, libjpeg, and libtiff development packages required for Rust bindgen and fax-related native builds
- add libjpeg-turbo and tiff runtime libraries to the Alpine production image for the fax engine
- bump build and release tooling packages including @git.zone/tsbundle, @git.zone/tsdocker, @git.zone/tsrust, @git.zone/tswatch, and esbuild
## 2026-05-21 - 1.27.0 ## 2026-05-21 - 1.27.0
### Features ### Features
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "siprouter", "name": "siprouter",
"version": "1.27.0", "version": "1.28.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -10,7 +10,7 @@
"build:docker": "tsdocker build --verbose", "build:docker": "tsdocker build --verbose",
"release:docker": "tsdocker push --verbose", "release:docker": "tsdocker push --verbose",
"start": "tsx ts/sipproxy.ts", "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": { "dependencies": {
"@design.estate/dees-catalog": "^3.81.0", "@design.estate/dees-catalog": "^3.81.0",
@@ -23,13 +23,13 @@
"ws": "^8.20.0" "ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbundle": "^2.10.1", "@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.2.5", "@git.zone/tsdocker": "^2.3.0",
"@git.zone/tsrust": "^1.3.3", "@git.zone/tsrust": "^1.3.4",
"@git.zone/tswatch": "^3.3.3", "@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.8.0", "@types/node": "^25.8.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"esbuild": "^0.27.7" "esbuild": "^0.28.0"
}, },
"pnpm": { "pnpm": {
"ignoredBuiltDependencies": [ "ignoredBuiltDependencies": [
+630 -37
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.27.0', version: '1.28.0',
description: 'undefined' description: 'undefined'
} }
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.27.0', version: '1.28.0',
description: 'undefined' description: 'undefined'
} }
+87 -33
View File
@@ -1,7 +1,8 @@
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js'; import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
import { deesCatalog } from '../plugins.js'; import { deesCatalog } from '../plugins.js';
import type { IView } from '@design.estate/dees-catalog';
import { NotificationManager } from '../state/notification-manager.js'; 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 { SipproxyViewOverview } from './sipproxy-view-overview.js';
import { SipproxyViewCalls } from './sipproxy-view-calls.js'; import { SipproxyViewCalls } from './sipproxy-view-calls.js';
import { SipproxyViewPhone } from './sipproxy-view-phone.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 { SipproxyViewVoicemail } from './sipproxy-view-voicemail.js';
import { SipproxyViewIvr } from './sipproxy-view-ivr.js'; import { SipproxyViewIvr } from './sipproxy-view-ivr.js';
const VIEW_TABS = [ interface ITabbedView extends IView {
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview }, slug?: string;
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls }, subViews?: ITabbedView[];
{ 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 },
];
// Map slug -> tab for routing. const VIEW_TABS: ITabbedView[] = [
const SLUG_TO_TAB = new Map(VIEW_TABS.map((t) => [t.name.toLowerCase(), t])); {
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') @customElement('sipproxy-app')
export class SipproxyApp extends DeesElement { export class SipproxyApp extends DeesElement {
private notificationManager = new NotificationManager(); private notificationManager = new NotificationManager();
private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null; private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null;
private viewTabs = VIEW_TABS;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
@@ -42,24 +74,49 @@ export class SipproxyApp extends DeesElement {
private suppressViewSelectEvent = false; 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() { async firstUpdated() {
this.appdash = this.shadowRoot?.querySelector('dees-simple-appdash') as InstanceType<typeof deesCatalog.DeesSimpleAppDash>; this.appdash = this.shadowRoot?.querySelector('dees-simple-appdash') as InstanceType<typeof deesCatalog.DeesSimpleAppDash>;
if (this.appdash) { if (this.appdash) {
this.notificationManager.init(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) => { this.appdash.addEventListener('view-select', ((e: CustomEvent) => {
if (this.suppressViewSelectEvent) return; if (this.suppressViewSelectEvent) return;
const viewName: string = e.detail?.view?.name || e.detail?.name || ''; const view = e.detail?.view as ITabbedView | undefined;
const slug = viewName.toLowerCase(); if (!view) return;
if (slug && slug !== appRouter.getCurrentView()) {
appRouter.navigateTo(slug as any, true); 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); }) as EventListener);
// Wire up router -> appdash (for browser back/forward). // Wire up router -> appdash (for browser back/forward).
appRouter.setNavigateHandler((view) => { appRouter.setNavigateHandler((view, subview) => {
const tab = SLUG_TO_TAB.get(view); const tab = this.findViewBySlug(view, subview);
if (tab && this.appdash) { if (tab && this.appdash) {
this.suppressViewSelectEvent = true; this.suppressViewSelectEvent = true;
this.appdash.loadView(tab); 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 initialTab = this.currentViewTab;
const initial = appRouter.getCurrentView(); if (initialTab) {
if (initial !== 'overview') { this.suppressViewSelectEvent = true;
const tab = SLUG_TO_TAB.get(initial); this.appdash.loadView(initialTab);
if (tab) { this.suppressViewSelectEvent = false;
this.suppressViewSelectEvent = true;
this.appdash.loadView(tab);
this.suppressViewSelectEvent = false;
}
} }
} }
} }
disconnectedCallback() { public async disconnectedCallback() {
super.disconnectedCallback(); await super.disconnectedCallback();
this.notificationManager.destroy(); this.notificationManager.destroy();
} }
@@ -89,7 +142,8 @@ export class SipproxyApp extends DeesElement {
return html` return html`
<dees-simple-appdash <dees-simple-appdash
.name=${'SipRouter'} .name=${'SipRouter'}
.viewTabs=${VIEW_TABS} .viewTabs=${this.viewTabs}
.selectedView=${this.currentViewTab}
></dees-simple-appdash> ></dees-simple-appdash>
`; `;
} }
+2
View File
@@ -942,6 +942,8 @@ export class SipproxyViewCalls extends DeesElement {
]; ];
return html` return html`
<dees-heading level="3">Calls</dees-heading>
<div class="view-section"> <div class="view-section">
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid> <dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
</div> </div>
+3 -1
View File
@@ -268,7 +268,7 @@ export class SipproxyViewContacts extends DeesElement {
type: ['inRow'] as any, type: ['inRow'] as any,
actionFunc: async ({ item }: { item: IContact }) => { actionFunc: async ({ item }: { item: IContact }) => {
appState.selectContact(item); appState.selectContact(item);
appRouter.navigateTo('phone' as any); appRouter.navigateToView('telephony', 'phone');
}, },
}, },
{ {
@@ -343,6 +343,8 @@ export class SipproxyViewContacts extends DeesElement {
]; ];
return html` return html`
<dees-heading level="3">Contacts</dees-heading>
<div class="view-section"> <div class="view-section">
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid> <dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
</div> </div>
+2
View File
@@ -624,6 +624,8 @@ export class SipproxyViewIvr extends DeesElement {
const menus = ivr.menus || []; const menus = ivr.menus || [];
return html` return html`
<dees-heading level="3">IVR</dees-heading>
<div class="view-section"> <div class="view-section">
<dees-statsgrid <dees-statsgrid
.tiles=${this.getStatsTiles()} .tiles=${this.getStatsTiles()}
+5 -3
View File
@@ -1,6 +1,6 @@
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js'; import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
import { deesCatalog } from '../plugins.js';
import { appState, type IAppState } from '../state/appstate.js'; import { appState, type IAppState } from '../state/appstate.js';
import { viewHostCss } from './shared/index.js';
@customElement('sipproxy-view-log') @customElement('sipproxy-view-log')
export class SipproxyViewLog extends DeesElement { export class SipproxyViewLog extends DeesElement {
@@ -11,9 +11,9 @@ export class SipproxyViewLog extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
viewHostCss,
css` css`
:host { display: block; padding: 1rem; height: 100%; } dees-chart-log { height: calc(100vh - 140px); }
dees-chart-log { height: calc(100vh - 120px); }
`, `,
]; ];
@@ -58,6 +58,8 @@ export class SipproxyViewLog extends DeesElement {
public render(): TemplateResult { public render(): TemplateResult {
return html` return html`
<dees-heading level="3">Log</dees-heading>
<dees-chart-log <dees-chart-log
label="SIP Trace Log" label="SIP Trace Log"
mode="structured" mode="structured"
+5 -17
View File
@@ -11,22 +11,8 @@ export class SipproxyViewOverview extends DeesElement {
cssManager.defaultStyles, cssManager.defaultStyles,
viewHostCss, viewHostCss,
css` css`
:host { dees-statsgrid {
display: block; margin-bottom: 32px;
padding: 24px;
}
.section-heading {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
margin: 32px 0 12px;
}
.section-heading:first-of-type {
margin-top: 24px;
} }
`, `,
]; ];
@@ -141,13 +127,15 @@ export class SipproxyViewOverview extends DeesElement {
const onlineCount = allDevices.filter((d) => d.connected).length; const onlineCount = allDevices.filter((d) => d.connected).length;
return html` return html`
<dees-heading level="3">Stats</dees-heading>
<dees-statsgrid <dees-statsgrid
.tiles=${tiles} .tiles=${tiles}
.minTileWidth=${220} .minTileWidth=${220}
.gap=${16} .gap=${16}
></dees-statsgrid> ></dees-statsgrid>
<div class="section-heading">Devices</div> <dees-heading level="hr">Devices</dees-heading>
<dees-table <dees-table
heading1="Devices" heading1="Devices"
heading2="${onlineCount} of ${allDevices.length} online" heading2="${onlineCount} of ${allDevices.length} online"
+2
View File
@@ -617,6 +617,8 @@ export class SipproxyViewPhone extends DeesElement {
public render(): TemplateResult { public render(): TemplateResult {
return html` return html`
<dees-heading level="3">Phone</dees-heading>
<div class="phone-layout"> <div class="phone-layout">
${this.renderDialer()} ${this.renderDialer()}
${this.renderPhoneStatus()} ${this.renderPhoneStatus()}
@@ -780,6 +780,8 @@ export class SipproxyViewProviders extends DeesElement {
const providers = this.appData.providers || []; const providers = this.appData.providers || [];
return html` return html`
<dees-heading level="3">Providers</dees-heading>
<div class="view-section"> <div class="view-section">
<dees-statsgrid <dees-statsgrid
.tiles=${this.getStatsTiles()} .tiles=${this.getStatsTiles()}
+2
View File
@@ -339,6 +339,8 @@ export class SipproxyViewRoutes extends DeesElement {
]; ];
return html` return html`
<dees-heading level="3">Route Management</dees-heading>
<div class="view-section"> <div class="view-section">
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid> <dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
</div> </div>
@@ -458,6 +458,8 @@ export class SipproxyViewVoicemail extends DeesElement {
public render(): TemplateResult { public render(): TemplateResult {
return html` return html`
<dees-heading level="3">Voicemail</dees-heading>
<div class="view-section"> <div class="view-section">
<dees-table <dees-table
heading1="Voiceboxes" heading1="Voiceboxes"
+86 -38
View File
@@ -1,64 +1,112 @@
/** /**
* URL router for the SipRouter dashboard. * URL router for the SipRouter dashboard.
* Maps URL paths to views in dees-simple-appdash. * Maps grouped URL paths to views in dees-simple-appdash.
*/ */
const VIEWS = ['overview', 'calls', 'phone', 'routes', 'voicemail', 'ivr', 'contacts', 'providers', 'log'] as const; const SUBVIEW_MAP = {
type TViewSlug = (typeof VIEWS)[number]; overview: ['stats'],
telephony: ['calls', 'phone', 'routes', 'voicemail', 'ivr'],
configuration: ['contacts', 'providers'],
system: ['log'],
} as const;
const DEFAULT_SUBVIEW = {
overview: 'stats',
telephony: 'calls',
configuration: 'contacts',
system: 'log',
} as const satisfies { [key in TViewSlug]: TSubviewSlug };
const DEFAULT_ROUTE: IRouteState = {
view: 'overview',
subview: 'stats',
};
type TViewSlug = keyof typeof SUBVIEW_MAP;
type TSubviewSlug = (typeof SUBVIEW_MAP)[TViewSlug][number];
interface IRouteState {
view: TViewSlug;
subview: TSubviewSlug;
}
function isValidView(view: string): view is TViewSlug {
return Object.prototype.hasOwnProperty.call(SUBVIEW_MAP, view);
}
function isValidSubview(view: TViewSlug, subview: string): subview is TSubviewSlug {
return (SUBVIEW_MAP[view] as readonly string[]).includes(subview);
}
class AppRouter { class AppRouter {
private currentView: TViewSlug = 'overview'; private currentRoute: IRouteState = DEFAULT_ROUTE;
private onNavigate: ((view: TViewSlug) => void) | null = null; private onNavigate: ((view: TViewSlug, subview: TSubviewSlug) => void) | null = null;
private suppressPush = false; private initialized = false;
init(): void { init(): void {
// Parse initial URL. if (this.initialized) return;
const path = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview';
if (VIEWS.includes(path as TViewSlug)) { this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true);
this.currentView = path as TViewSlug;
}
// Handle browser back/forward. // Handle browser back/forward.
window.addEventListener('popstate', () => { window.addEventListener('popstate', () => {
const p = location.pathname.replace(/^\/+/, '').split('/')[0] || 'overview'; this.applyRoute(this.parsePath(location.pathname) ?? DEFAULT_ROUTE, false, true);
if (VIEWS.includes(p as TViewSlug)) {
this.suppressPush = true;
this.navigateTo(p as TViewSlug);
this.suppressPush = false;
}
}); });
this.initialized = true;
} }
setNavigateHandler(handler: (view: TViewSlug) => void): void { setNavigateHandler(handler: (view: TViewSlug, subview: TSubviewSlug) => void): void {
this.onNavigate = handler; this.onNavigate = handler;
} }
navigateTo(view: TViewSlug, skipCallback = false): void { navigateToView(view: TViewSlug, subview?: TSubviewSlug, skipCallback = false): void {
this.currentView = view; const targetSubview = subview && isValidSubview(view, subview) ? subview : DEFAULT_SUBVIEW[view];
if (!this.suppressPush) { this.applyRoute({ view, subview: targetSubview }, skipCallback, false);
const url = `/${view}`; }
if (location.pathname !== url) {
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); history.pushState(null, '', url);
} }
} }
if (!skipCallback) { 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 const appRouter = new AppRouter();
export type { TViewSlug }; export type { IRouteState, TSubviewSlug, TViewSlug };