diff --git a/changelog.md b/changelog.md index fbc3794..c36c8e3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-08 - 13.3.0 - feat(web-ui) +reorganize network and security views into tabbed subviews with route-aware navigation + +- add URL-based subview support in app state and router for network and security sections +- group routes, source profiles, network targets, and target profiles under the network view with tab navigation +- split security into dedicated overview, blocked IPs, authentication, and email security subviews +- update configuration navigation to deep-link directly to the network routes subview + ## 2026-04-08 - 13.2.2 - fix(project) no changes to commit diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index faaa52d..5ee4f02 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.2.2', + version: '13.3.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index faaa52d..5ee4f02 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.2.2', + version: '13.3.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 3d815a1..618bf41 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -30,6 +30,7 @@ export interface IConfigState { export interface IUiState { activeView: string; + activeSubview: string | null; sidebarCollapsed: boolean; autoRefresh: boolean; refreshInterval: number; // milliseconds @@ -116,16 +117,24 @@ export const configStatePart = await appState.getStatePart( // Determine initial view from URL path const getInitialView = (): string => { const path = typeof window !== 'undefined' ? window.location.pathname : '/'; - const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'sourceprofiles', 'networktargets', 'targetprofiles']; + const validViews = ['overview', 'network', 'emails', 'logs', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn']; const segments = path.split('/').filter(Boolean); const view = segments[0]; return validViews.includes(view) ? view : 'overview'; }; +// Determine initial subview (second URL segment) from the path +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(), sidebarCollapsed: false, autoRefresh: true, refreshInterval: 1000, // 1 second @@ -435,15 +444,6 @@ export const setActiveViewAction = uiStatePart.createAction(async (state }, 100); } - // If switching to routes view, ensure we fetch route data - if (viewName === 'routes' && currentState.activeView !== 'routes') { - setTimeout(() => { - routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null); - // Also fetch profiles/targets for the Create Route dropdowns - profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null); - }, 100); - } - // If switching to apitokens view, ensure we fetch token data if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') { setTimeout(() => { @@ -458,20 +458,6 @@ export const setActiveViewAction = uiStatePart.createAction(async (state }, 100); } - // If switching to security profiles or network targets views, fetch profiles/targets data - if ((viewName === 'sourceprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) { - setTimeout(() => { - profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null); - }, 100); - } - - // If switching to target profiles view, fetch target profiles data - if (viewName === 'targetprofiles' && currentState.activeView !== viewName) { - setTimeout(() => { - targetProfilesStatePart.dispatchAction(fetchTargetProfilesAction, null); - }, 100); - } - return { ...currentState, activeView: viewName, diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index f2b6b5a..789ae8d 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -1,16 +1,12 @@ export * from './ops-dashboard.js'; export * from './ops-view-overview.js'; -export * from './ops-view-network.js'; +export * from './network/index.js'; export * from './ops-view-emails.js'; export * from './ops-view-logs.js'; export * from './ops-view-config.js'; -export * from './ops-view-routes.js'; export * from './ops-view-apitokens.js'; -export * from './ops-view-security.js'; +export * from './security/index.js'; export * from './ops-view-certificates.js'; export * from './ops-view-remoteingress.js'; export * from './ops-view-vpn.js'; -export * from './ops-view-sourceprofiles.js'; -export * from './ops-view-networktargets.js'; -export * from './ops-view-targetprofiles.js'; export * from './shared/index.js'; \ No newline at end of file diff --git a/ts_web/elements/network/index.ts b/ts_web/elements/network/index.ts new file mode 100644 index 0000000..e75a60d --- /dev/null +++ b/ts_web/elements/network/index.ts @@ -0,0 +1,6 @@ +export * from './ops-view-network.js'; +export * from './ops-view-network-activity.js'; +export * from './ops-view-routes.js'; +export * from './ops-view-sourceprofiles.js'; +export * from './ops-view-networktargets.js'; +export * from './ops-view-targetprofiles.js'; diff --git a/ts_web/elements/ops-view-network.ts b/ts_web/elements/network/ops-view-network-activity.ts similarity index 95% rename from ts_web/elements/ops-view-network.ts rename to ts_web/elements/network/ops-view-network-activity.ts index 8765a77..d082b12 100644 --- a/ts_web/elements/ops-view-network.ts +++ b/ts_web/elements/network/ops-view-network-activity.ts @@ -1,12 +1,11 @@ import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element'; -import * as appstate from '../appstate.js'; -import * as interfaces from '../../dist_ts_interfaces/index.js'; -import { viewHostCss } from './shared/css.js'; +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; import { type IStatsTile } from '@design.estate/dees-catalog'; declare global { interface HTMLElementTagNameMap { - 'ops-view-network': OpsViewNetwork; + 'ops-view-network-activity': OpsViewNetworkActivity; } } @@ -26,14 +25,14 @@ interface INetworkRequest { route?: string; } -@customElement('ops-view-network') -export class OpsViewNetwork extends DeesElement { +@customElement('ops-view-network-activity') +export class OpsViewNetworkActivity extends DeesElement { /** How far back the traffic chart shows */ private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes /** How often a new data point is added */ private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second /** Derived: max data points the buffer holds */ - private static readonly MAX_DATA_POINTS = OpsViewNetwork.CHART_WINDOW_MS / OpsViewNetwork.UPDATE_INTERVAL_MS; + private static readonly MAX_DATA_POINTS = OpsViewNetworkActivity.CHART_WINDOW_MS / OpsViewNetworkActivity.UPDATE_INTERVAL_MS; @state() accessor statsState = appstate.statsStatePart.getState()!; @@ -50,10 +49,10 @@ export class OpsViewNetwork extends DeesElement { @state() accessor trafficDataOut: Array<{ x: string | number; y: number }> = []; - + // Track if we need to update the chart to avoid unnecessary re-renders private lastChartUpdate = 0; - private chartUpdateThreshold = OpsViewNetwork.UPDATE_INTERVAL_MS; // Minimum ms between chart updates + private chartUpdateThreshold = OpsViewNetworkActivity.UPDATE_INTERVAL_MS; // Minimum ms between chart updates private trafficUpdateTimer: any = null; private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend @@ -101,17 +100,17 @@ export class OpsViewNetwork extends DeesElement { this.updateNetworkData(); }); this.rxSubscriptions.push(statsUnsubscribe); - + const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => { this.networkState = state; this.updateNetworkData(); }); this.rxSubscriptions.push(networkUnsubscribe); } - + private initializeTrafficData() { const now = Date.now(); - const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork; + const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity; // Initialize with empty data points for both in and out const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => { @@ -148,7 +147,7 @@ export class OpsViewNetwork extends DeesElement { y: Math.round((p.out * 8) / 1000000 * 10) / 10, })); - const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork; + const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity; // Use history as the chart data, keeping the most recent points within the window const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS); @@ -176,8 +175,8 @@ export class OpsViewNetwork extends DeesElement { public static styles = [ cssManager.defaultStyles, - viewHostCss, css` + :host { display: block; } .networkContainer { display: flex; flex-direction: column; @@ -285,8 +284,8 @@ export class OpsViewNetwork extends DeesElement { public render() { return html` - Network Activity - + Network Activity +
${this.renderNetworkStats()} @@ -307,7 +306,7 @@ export class OpsViewNetwork extends DeesElement { } ]} .realtimeMode=${true} - .rollingWindow=${OpsViewNetwork.CHART_WINDOW_MS} + .rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS} .yAxisFormatter=${(val: number) => `${val} Mbit/s`} > @@ -323,7 +322,6 @@ export class OpsViewNetwork extends DeesElement { ({ Time: new Date(req.timestamp).toLocaleTimeString(), Protocol: html`${req.protocol.toUpperCase()}`, @@ -358,7 +356,7 @@ export class OpsViewNetwork extends DeesElement { private async showRequestDetails(request: INetworkRequest) { const { DeesModal } = await import('@design.estate/dees-catalog'); - + await DeesModal.createAndShow({ heading: 'Request Details', content: html` @@ -401,10 +399,10 @@ export class OpsViewNetwork extends DeesElement { if (!statusCode) { return html`N/A`; } - + const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' : statusCode >= 400 ? 'error' : 'warning'; - + return html`${statusCode}`; } @@ -427,26 +425,26 @@ export class OpsViewNetwork extends DeesElement { const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; - + while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } - + return `${size.toFixed(1)} ${units[unitIndex]}`; } - + private formatBitsPerSecond(bytesPerSecond: number): string { const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s']; let size = bitsPerSecond; let unitIndex = 0; - + while (size >= 1000 && unitIndex < units.length - 1) { size /= 1000; // Use 1000 for bits (not 1024) unitIndex++; } - + return `${size.toFixed(1)} ${units[unitIndex]}`; } @@ -521,18 +519,9 @@ export class OpsViewNetwork extends DeesElement { ]; return html` - { - console.log('Export feature coming soon'); - }, - }, - ]} > `; } @@ -604,7 +593,6 @@ export class OpsViewNetwork extends DeesElement { return html` { const bw = bandwidthByIP.get(ipData.ip); return { @@ -632,7 +620,6 @@ export class OpsViewNetwork extends DeesElement { return html` { const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors; const protocolClass = item.protocol.toLowerCase().replace(/[^a-z0-9]/g, ''); @@ -735,12 +722,12 @@ export class OpsViewNetwork extends DeesElement { // Only update if connections changed significantly const newConnectionCount = this.networkState.connections.length; const oldConnectionCount = this.networkRequests.length; - + // Check if we need to update the network requests array - const shouldUpdate = newConnectionCount !== oldConnectionCount || + const shouldUpdate = newConnectionCount !== oldConnectionCount || newConnectionCount === 0 || (newConnectionCount > 0 && this.networkRequests.length === 0); - + if (shouldUpdate) { // Convert connection data to network requests format if (newConnectionCount > 0) { @@ -763,62 +750,62 @@ export class OpsViewNetwork extends DeesElement { this.networkRequests = []; } } - + // Load server-side throughput history into chart (once) if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) { this.loadThroughputHistory(); } } - + private startTrafficUpdateTimer() { this.stopTrafficUpdateTimer(); // Clear any existing timer this.trafficUpdateTimer = setInterval(() => { this.addTrafficDataPoint(); - }, OpsViewNetwork.UPDATE_INTERVAL_MS); + }, OpsViewNetworkActivity.UPDATE_INTERVAL_MS); } - + private addTrafficDataPoint() { const now = Date.now(); - + // Throttle chart updates to avoid excessive re-renders if (now - this.lastChartUpdate < this.chartUpdateThreshold) { return; } - + const throughput = this.calculateThroughput(); - + // Convert to Mbps (bytes * 8 / 1,000,000) const throughputInMbps = (throughput.in * 8) / 1000000; const throughputOutMbps = (throughput.out * 8) / 1000000; - + // Add new data points const timestamp = new Date(now).toISOString(); - + const newDataPointIn = { x: timestamp, y: Math.round(throughputInMbps * 10) / 10 }; - + const newDataPointOut = { x: timestamp, y: Math.round(throughputOutMbps * 10) / 10 }; - + // In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays) - if (this.trafficDataIn.length >= OpsViewNetwork.MAX_DATA_POINTS) { + if (this.trafficDataIn.length >= OpsViewNetworkActivity.MAX_DATA_POINTS) { this.trafficDataIn.shift(); this.trafficDataOut.shift(); } this.trafficDataIn = [...this.trafficDataIn, newDataPointIn]; this.trafficDataOut = [...this.trafficDataOut, newDataPointOut]; - + this.lastChartUpdate = now; } - + private stopTrafficUpdateTimer() { if (this.trafficUpdateTimer) { clearInterval(this.trafficUpdateTimer); this.trafficUpdateTimer = null; } } -} \ No newline at end of file +} diff --git a/ts_web/elements/network/ops-view-network.ts b/ts_web/elements/network/ops-view-network.ts new file mode 100644 index 0000000..6fa6fe2 --- /dev/null +++ b/ts_web/elements/network/ops-view-network.ts @@ -0,0 +1,119 @@ +import * as appstate from '../../appstate.js'; +import { appRouter } from '../../router.js'; +import { viewHostCss } from '../shared/css.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +// Side-effect imports register the subview custom elements +import './ops-view-network-activity.js'; +import './ops-view-routes.js'; +import './ops-view-sourceprofiles.js'; +import './ops-view-networktargets.js'; +import './ops-view-targetprofiles.js'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-network': OpsViewNetwork; + } +} + +type TNetworkTab = 'activity' | 'routes' | 'sourceprofiles' | 'networktargets' | 'targetprofiles'; + +@customElement('ops-view-network') +export class OpsViewNetwork extends DeesElement { + @state() + accessor selectedTab: TNetworkTab = 'activity'; + + private tabLabelMap: Record = { + 'activity': 'Network Activity', + 'routes': 'Routes', + 'sourceprofiles': 'Source Profiles', + 'networktargets': 'Network Targets', + 'targetprofiles': 'Target Profiles', + }; + + private labelToTab: Record = { + 'Network Activity': 'activity', + 'Routes': 'routes', + 'Source Profiles': 'sourceprofiles', + 'Network Targets': 'networktargets', + 'Target Profiles': 'targetprofiles', + }; + + private static isNetworkTab(s: string | null): s is TNetworkTab { + return s === 'activity' || s === 'routes' || s === 'sourceprofiles' + || s === 'networktargets' || s === 'targetprofiles'; + } + + constructor() { + super(); + // Read initial subview from state (URL-driven) + const initialState = appstate.uiStatePart.getState()!; + if (OpsViewNetwork.isNetworkTab(initialState.activeSubview)) { + this.selectedTab = initialState.activeSubview; + } + // Subscribe to future changes (back/forward navigation, direct URL entry) + const sub = appstate.uiStatePart.select((s) => s.activeSubview).subscribe((sub) => { + if (OpsViewNetwork.isNetworkTab(sub) && sub !== this.selectedTab) { + this.selectedTab = sub; + } + }); + this.rxSubscriptions.push(sub); + } + + async firstUpdated() { + const toggle = this.shadowRoot!.querySelector('dees-input-multitoggle') as any; + if (toggle) { + const sub = toggle.changeSubject.subscribe(() => { + const tab = this.labelToTab[toggle.selectedOption]; + if (tab && tab !== this.selectedTab) { + // Push URL → router updates state → subscription updates selectedTab + appRouter.navigateToView('network', tab); + } + }); + this.rxSubscriptions.push(sub); + } + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + dees-input-multitoggle { + margin-bottom: 24px; + } + `, + ]; + + public render(): TemplateResult { + return html` + Network + + + + ${this.renderTabContent()} + `; + } + + private renderTabContent(): TemplateResult { + switch (this.selectedTab) { + case 'activity': return html``; + case 'routes': return html``; + case 'sourceprofiles': return html``; + case 'networktargets': return html``; + case 'targetprofiles': return html``; + } + } +} diff --git a/ts_web/elements/ops-view-networktargets.ts b/ts_web/elements/network/ops-view-networktargets.ts similarity index 96% rename from ts_web/elements/ops-view-networktargets.ts rename to ts_web/elements/network/ops-view-networktargets.ts index 2b1f206..30d4c63 100644 --- a/ts_web/elements/ops-view-networktargets.ts +++ b/ts_web/elements/network/ops-view-networktargets.ts @@ -7,9 +7,8 @@ import { state, cssManager, } from '@design.estate/dees-element'; -import * as appstate from '../appstate.js'; -import * as interfaces from '../../dist_ts_interfaces/index.js'; -import { viewHostCss } from './shared/css.js'; +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; import { type IStatsTile } from '@design.estate/dees-catalog'; declare global { @@ -38,8 +37,8 @@ export class OpsViewNetworkTargets extends DeesElement { public static styles = [ cssManager.defaultStyles, - viewHostCss, css` + :host { display: block; } .targetsContainer { display: flex; flex-direction: column; @@ -64,7 +63,7 @@ export class OpsViewNetworkTargets extends DeesElement { ]; return html` - Network Targets + Network Targets
Route Management + Route Management
Source Profiles + Source Profiles
Target Profiles + Target Profiles
{ if (e.detail?.view) { - appRouter.navigateToView(e.detail.view); + appRouter.navigateToView(e.detail.view, e.detail.subview); } }} > @@ -149,7 +149,7 @@ export class OpsViewConfig extends DeesElement { } const actions: IConfigSectionAction[] = [ - { label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } }, + { label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'routes' } }, ]; return html` diff --git a/ts_web/elements/ops-view-security.ts b/ts_web/elements/ops-view-security.ts deleted file mode 100644 index a16a4c2..0000000 --- a/ts_web/elements/ops-view-security.ts +++ /dev/null @@ -1,456 +0,0 @@ -import * as plugins from '../plugins.js'; -import * as shared from './shared/index.js'; -import * as appstate from '../appstate.js'; - -import { - DeesElement, - customElement, - html, - state, - css, - cssManager, -} from '@design.estate/dees-element'; -import { type IStatsTile } from '@design.estate/dees-catalog'; - -@customElement('ops-view-security') -export class OpsViewSecurity extends DeesElement { - @state() - accessor statsState: appstate.IStatsState = { - serverStats: null, - emailStats: null, - dnsStats: null, - securityMetrics: null, - radiusStats: null, - vpnStats: null, - lastUpdated: 0, - isLoading: false, - error: null, - }; - - @state() - accessor selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview'; - - private tabLabelMap: Record = { - 'overview': 'Overview', - 'blocked': 'Blocked IPs', - 'authentication': 'Authentication', - 'email-security': 'Email Security', - }; - - private labelToTab: Record = { - 'Overview': 'overview', - 'Blocked IPs': 'blocked', - 'Authentication': 'authentication', - 'Email Security': 'email-security', - }; - - constructor() { - super(); - const subscription = appstate.statsStatePart - .select((stateArg) => stateArg) - .subscribe((statsState) => { - this.statsState = statsState; - }); - this.rxSubscriptions.push(subscription); - } - - async firstUpdated() { - const toggle = this.shadowRoot!.querySelector('dees-input-multitoggle') as any; - if (toggle) { - const sub = toggle.changeSubject.subscribe(() => { - const tab = this.labelToTab[toggle.selectedOption]; - if (tab) this.selectedTab = tab; - }); - this.rxSubscriptions.push(sub); - } - } - - public static styles = [ - cssManager.defaultStyles, - shared.viewHostCss, - css` - dees-input-multitoggle { - margin-bottom: 24px; - } - - h2 { - margin: 32px 0 16px 0; - font-size: 24px; - font-weight: 600; - color: ${cssManager.bdTheme('#333', '#ccc')}; - } - - dees-statsgrid { - margin-bottom: 32px; - } - - .securityCard { - background: ${cssManager.bdTheme('#fff', '#222')}; - border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; - border-radius: 8px; - padding: 24px; - position: relative; - overflow: hidden; - } - - .actionButton { - margin-top: 16px; - } - - `, - ]; - - public render() { - return html` - Security - - - - ${this.renderTabContent()} - `; - } - - private renderTabContent() { - const metrics = this.statsState.securityMetrics; - - if (!metrics) { - return html` -
-

Loading security metrics...

-
- `; - } - - switch(this.selectedTab) { - case 'overview': - return this.renderOverview(metrics); - case 'blocked': - return this.renderBlockedIPs(metrics); - case 'authentication': - return this.renderAuthentication(metrics); - case 'email-security': - return this.renderEmailSecurity(metrics); - } - } - - private renderOverview(metrics: any) { - const threatLevel = this.calculateThreatLevel(metrics); - const threatScore = this.getThreatScore(metrics); - - // Derive active sessions from recent successful auth events (last hour) - const allEvents: any[] = metrics.recentEvents || []; - const oneHourAgo = Date.now() - 3600000; - const recentAuthSuccesses = allEvents.filter( - (evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo - ).length; - - const tiles: IStatsTile[] = [ - { - id: 'threatLevel', - title: 'Threat Level', - value: threatScore, - type: 'gauge', - icon: 'lucide:Shield', - gaugeOptions: { - min: 0, - max: 100, - thresholds: [ - { value: 0, color: '#ef4444' }, - { value: 30, color: '#f59e0b' }, - { value: 70, color: '#22c55e' }, - ], - }, - description: `Status: ${threatLevel.toUpperCase()}`, - }, - { - id: 'blockedThreats', - title: 'Blocked Threats', - value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected, - type: 'number', - icon: 'lucide:ShieldCheck', - color: '#ef4444', - description: 'Total threats blocked today', - }, - { - id: 'activeSessions', - title: 'Active Sessions', - value: recentAuthSuccesses, - type: 'number', - icon: 'lucide:Users', - color: '#22c55e', - description: 'Authenticated in last hour', - }, - { - id: 'authFailures', - title: 'Auth Failures', - value: metrics.authenticationFailures, - type: 'number', - icon: 'lucide:LockOpen', - color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b', - description: 'Failed login attempts today', - }, - ]; - - return html` - - -

Recent Security Events

- ({ - 'Time': new Date(item.timestamp).toLocaleTimeString(), - 'Event': item.event, - 'Severity': item.severity, - 'Details': item.details, - })} - > - `; - } - - private renderBlockedIPs(metrics: any) { - const blockedIPs: string[] = metrics.blockedIPs || []; - - const tiles: IStatsTile[] = [ - { - id: 'totalBlocked', - title: 'Blocked IPs', - value: blockedIPs.length, - type: 'number', - icon: 'lucide:ShieldBan', - color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e', - description: 'Currently blocked addresses', - }, - ]; - - return html` - - - ({ ip }))} - .showColumnFilters=${true} - .displayFunction=${(item) => ({ - 'IP Address': item.ip, - 'Reason': 'Suspicious activity', - })} - .dataActions=${[ - { - name: 'Unblock', - iconName: 'lucide:shield-off', - type: ['contextmenu' as const], - actionFunc: async (item) => { - await this.unblockIP(item.ip); - }, - }, - { - name: 'Clear All', - iconName: 'lucide:trash-2', - type: ['header' as const], - actionFunc: async () => { - await this.clearBlockedIPs(); - }, - }, - ]} - > - `; - } - - private renderAuthentication(metrics: any) { - // Derive auth events from recentEvents - const allEvents: any[] = metrics.recentEvents || []; - const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication'); - const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length; - - const tiles: IStatsTile[] = [ - { - id: 'authFailures', - title: 'Authentication Failures', - value: metrics.authenticationFailures, - type: 'number', - icon: 'lucide:LockOpen', - color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b', - description: 'Failed authentication attempts today', - }, - { - id: 'successfulLogins', - title: 'Successful Logins', - value: successfulLogins, - type: 'number', - icon: 'lucide:Lock', - color: '#22c55e', - description: 'Successful logins today', - }, - ]; - - // Map auth events to login history table data - const loginHistory = authEvents.map((evt: any) => ({ - timestamp: evt.timestamp, - username: evt.details?.username || 'unknown', - ipAddress: evt.ipAddress || 'unknown', - success: evt.success ?? false, - reason: evt.success ? '' : evt.message || 'Authentication failed', - })); - - return html` - - -

Recent Login Attempts

- ({ - 'Time': new Date(item.timestamp).toLocaleString(), - 'Username': item.username, - 'IP Address': item.ipAddress, - 'Status': item.success ? 'Success' : 'Failed', - 'Reason': item.reason || '-', - })} - > - `; - } - - private renderEmailSecurity(metrics: any) { - const tiles: IStatsTile[] = [ - { - id: 'malware', - title: 'Malware Detection', - value: metrics.malwareDetected, - type: 'number', - icon: 'lucide:BugOff', - color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e', - description: 'Malware detected', - }, - { - id: 'phishing', - title: 'Phishing Detection', - value: metrics.phishingDetected, - type: 'number', - icon: 'lucide:Fish', - color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e', - description: 'Phishing attempts detected', - }, - { - id: 'suspicious', - title: 'Suspicious Activities', - value: metrics.suspiciousActivities, - type: 'number', - icon: 'lucide:TriangleAlert', - color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b', - description: 'Suspicious activities detected', - }, - { - id: 'spam', - title: 'Spam Detection', - value: metrics.spamDetected, - type: 'number', - icon: 'lucide:Ban', - color: '#f59e0b', - description: 'Spam emails blocked', - }, - ]; - - return html` - - -

Email Security Configuration

-
- - - - - - - this.saveEmailSecuritySettings()} - > - Save Settings - -
- `; - } - - private calculateThreatLevel(metrics: any): string { - const score = this.getThreatScore(metrics); - if (score < 30) return 'alert'; - if (score < 70) return 'warning'; - return 'success'; - } - - private getThreatScore(metrics: any): number { - // Simple scoring algorithm - let score = 100; - const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0); - score -= blockedCount * 2; - score -= (metrics.authenticationFailures || 0) * 1; - score -= (metrics.spamDetected || 0) * 0.5; - score -= (metrics.malwareDetected || 0) * 3; - score -= (metrics.phishingDetected || 0) * 3; - score -= (metrics.suspiciousActivities || 0) * 2; - return Math.max(0, Math.min(100, Math.round(score))); - } - - private getSecurityEvents(metrics: any): any[] { - const events: any[] = metrics.recentEvents || []; - return events.map((evt: any) => ({ - timestamp: evt.timestamp, - event: evt.message, - severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info', - details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type, - })); - } - - private async clearBlockedIPs() { - // SmartProxy manages IP blocking — not yet exposed via API - alert('Clearing blocked IPs is not yet supported from the UI.'); - } - - private async unblockIP(ip: string) { - // SmartProxy manages IP blocking — not yet exposed via API - alert(`Unblocking IP ${ip} is not yet supported from the UI.`); - } - - private async saveEmailSecuritySettings() { - // Config is read-only from the UI for now - alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.'); - } -} \ No newline at end of file diff --git a/ts_web/elements/security/index.ts b/ts_web/elements/security/index.ts new file mode 100644 index 0000000..a10d4d3 --- /dev/null +++ b/ts_web/elements/security/index.ts @@ -0,0 +1,5 @@ +export * from './ops-view-security.js'; +export * from './ops-view-security-overview.js'; +export * from './ops-view-security-blocked.js'; +export * from './ops-view-security-authentication.js'; +export * from './ops-view-security-emailsecurity.js'; diff --git a/ts_web/elements/security/ops-view-security-authentication.ts b/ts_web/elements/security/ops-view-security-authentication.ts new file mode 100644 index 0000000..274c11d --- /dev/null +++ b/ts_web/elements/security/ops-view-security-authentication.ts @@ -0,0 +1,120 @@ +import * as appstate from '../../appstate.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-security-authentication': OpsViewSecurityAuthentication; + } +} + +@customElement('ops-view-security-authentication') +export class OpsViewSecurityAuthentication extends DeesElement { + @state() + accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.statsStatePart + .select((s) => s) + .subscribe((s) => { + this.statsState = s; + }); + this.rxSubscriptions.push(sub); + } + + public static styles = [ + cssManager.defaultStyles, + css` + :host { display: block; } + h2 { + margin: 32px 0 16px 0; + font-size: 24px; + font-weight: 600; + color: ${cssManager.bdTheme('#333', '#ccc')}; + } + dees-statsgrid { + margin-bottom: 32px; + } + `, + ]; + + public render(): TemplateResult { + const metrics = this.statsState.securityMetrics; + + if (!metrics) { + return html` +
+

Loading security metrics...

+
+ `; + } + + // Derive auth events from recentEvents + const allEvents: any[] = metrics.recentEvents || []; + const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication'); + const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length; + + const tiles: IStatsTile[] = [ + { + id: 'authFailures', + title: 'Authentication Failures', + value: metrics.authenticationFailures, + type: 'number', + icon: 'lucide:LockOpen', + color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b', + description: 'Failed authentication attempts today', + }, + { + id: 'successfulLogins', + title: 'Successful Logins', + value: successfulLogins, + type: 'number', + icon: 'lucide:Lock', + color: '#22c55e', + description: 'Successful logins today', + }, + ]; + + // Map auth events to login history table data + const loginHistory = authEvents.map((evt: any) => ({ + timestamp: evt.timestamp, + username: evt.details?.username || 'unknown', + ipAddress: evt.ipAddress || 'unknown', + success: evt.success ?? false, + reason: evt.success ? '' : evt.message || 'Authentication failed', + })); + + return html` + Authentication + + + +

Recent Login Attempts

+ ({ + 'Time': new Date(item.timestamp).toLocaleString(), + 'Username': item.username, + 'IP Address': item.ipAddress, + 'Status': item.success ? 'Success' : 'Failed', + 'Reason': item.reason || '-', + })} + > + `; + } +} diff --git a/ts_web/elements/security/ops-view-security-blocked.ts b/ts_web/elements/security/ops-view-security-blocked.ts new file mode 100644 index 0000000..48b2c1f --- /dev/null +++ b/ts_web/elements/security/ops-view-security-blocked.ts @@ -0,0 +1,117 @@ +import * as appstate from '../../appstate.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-security-blocked': OpsViewSecurityBlocked; + } +} + +@customElement('ops-view-security-blocked') +export class OpsViewSecurityBlocked extends DeesElement { + @state() + accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.statsStatePart + .select((s) => s) + .subscribe((s) => { + this.statsState = s; + }); + this.rxSubscriptions.push(sub); + } + + public static styles = [ + cssManager.defaultStyles, + css` + :host { display: block; } + dees-statsgrid { + margin-bottom: 32px; + } + `, + ]; + + public render(): TemplateResult { + const metrics = this.statsState.securityMetrics; + + if (!metrics) { + return html` +
+

Loading security metrics...

+
+ `; + } + + const blockedIPs: string[] = metrics.blockedIPs || []; + + const tiles: IStatsTile[] = [ + { + id: 'totalBlocked', + title: 'Blocked IPs', + value: blockedIPs.length, + type: 'number', + icon: 'lucide:ShieldBan', + color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e', + description: 'Currently blocked addresses', + }, + ]; + + return html` + Blocked IPs + + + + ({ ip }))} + .displayFunction=${(item) => ({ + 'IP Address': item.ip, + 'Reason': 'Suspicious activity', + })} + .dataActions=${[ + { + name: 'Unblock', + iconName: 'lucide:shield-off', + type: ['contextmenu' as const], + actionFunc: async (item) => { + await this.unblockIP(item.ip); + }, + }, + { + name: 'Clear All', + iconName: 'lucide:trash-2', + type: ['header' as const], + actionFunc: async () => { + await this.clearBlockedIPs(); + }, + }, + ]} + > + `; + } + + private async clearBlockedIPs() { + // SmartProxy manages IP blocking — not yet exposed via API + alert('Clearing blocked IPs is not yet supported from the UI.'); + } + + private async unblockIP(ip: string) { + // SmartProxy manages IP blocking — not yet exposed via API + alert(`Unblocking IP ${ip} is not yet supported from the UI.`); + } +} diff --git a/ts_web/elements/security/ops-view-security-emailsecurity.ts b/ts_web/elements/security/ops-view-security-emailsecurity.ts new file mode 100644 index 0000000..ff908e5 --- /dev/null +++ b/ts_web/elements/security/ops-view-security-emailsecurity.ts @@ -0,0 +1,159 @@ +import * as appstate from '../../appstate.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-security-emailsecurity': OpsViewSecurityEmailsecurity; + } +} + +@customElement('ops-view-security-emailsecurity') +export class OpsViewSecurityEmailsecurity extends DeesElement { + @state() + accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.statsStatePart + .select((s) => s) + .subscribe((s) => { + this.statsState = s; + }); + this.rxSubscriptions.push(sub); + } + + public static styles = [ + cssManager.defaultStyles, + css` + :host { display: block; } + h2 { + margin: 32px 0 16px 0; + font-size: 24px; + font-weight: 600; + color: ${cssManager.bdTheme('#333', '#ccc')}; + } + dees-statsgrid { + margin-bottom: 32px; + } + .securityCard { + background: ${cssManager.bdTheme('#fff', '#222')}; + border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; + border-radius: 8px; + padding: 24px; + position: relative; + overflow: hidden; + } + .actionButton { + margin-top: 16px; + } + `, + ]; + + public render(): TemplateResult { + const metrics = this.statsState.securityMetrics; + + if (!metrics) { + return html` +
+

Loading security metrics...

+
+ `; + } + + const tiles: IStatsTile[] = [ + { + id: 'malware', + title: 'Malware Detection', + value: metrics.malwareDetected, + type: 'number', + icon: 'lucide:BugOff', + color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e', + description: 'Malware detected', + }, + { + id: 'phishing', + title: 'Phishing Detection', + value: metrics.phishingDetected, + type: 'number', + icon: 'lucide:Fish', + color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e', + description: 'Phishing attempts detected', + }, + { + id: 'suspicious', + title: 'Suspicious Activities', + value: metrics.suspiciousActivities, + type: 'number', + icon: 'lucide:TriangleAlert', + color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b', + description: 'Suspicious activities detected', + }, + { + id: 'spam', + title: 'Spam Detection', + value: metrics.spamDetected, + type: 'number', + icon: 'lucide:Ban', + color: '#f59e0b', + description: 'Spam emails blocked', + }, + ]; + + return html` + Email Security + + + +

Email Security Configuration

+
+ + + + + + + this.saveEmailSecuritySettings()} + > + Save Settings + +
+ `; + } + + private async saveEmailSecuritySettings() { + // Config is read-only from the UI for now + alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.'); + } +} diff --git a/ts_web/elements/security/ops-view-security-overview.ts b/ts_web/elements/security/ops-view-security-overview.ts new file mode 100644 index 0000000..4c22ad6 --- /dev/null +++ b/ts_web/elements/security/ops-view-security-overview.ts @@ -0,0 +1,171 @@ +import * as appstate from '../../appstate.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; +import { type IStatsTile } from '@design.estate/dees-catalog'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-security-overview': OpsViewSecurityOverview; + } +} + +@customElement('ops-view-security-overview') +export class OpsViewSecurityOverview extends DeesElement { + @state() + accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!; + + constructor() { + super(); + const sub = appstate.statsStatePart + .select((s) => s) + .subscribe((s) => { + this.statsState = s; + }); + this.rxSubscriptions.push(sub); + } + + public static styles = [ + cssManager.defaultStyles, + css` + :host { display: block; } + h2 { + margin: 32px 0 16px 0; + font-size: 24px; + font-weight: 600; + color: ${cssManager.bdTheme('#333', '#ccc')}; + } + dees-statsgrid { + margin-bottom: 32px; + } + `, + ]; + + public render(): TemplateResult { + const metrics = this.statsState.securityMetrics; + + if (!metrics) { + return html` +
+

Loading security metrics...

+
+ `; + } + + const threatLevel = this.calculateThreatLevel(metrics); + const threatScore = this.getThreatScore(metrics); + + // Derive active sessions from recent successful auth events (last hour) + const allEvents: any[] = metrics.recentEvents || []; + const oneHourAgo = Date.now() - 3600000; + const recentAuthSuccesses = allEvents.filter( + (evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo + ).length; + + const tiles: IStatsTile[] = [ + { + id: 'threatLevel', + title: 'Threat Level', + value: threatScore, + type: 'gauge', + icon: 'lucide:Shield', + gaugeOptions: { + min: 0, + max: 100, + thresholds: [ + { value: 0, color: '#ef4444' }, + { value: 30, color: '#f59e0b' }, + { value: 70, color: '#22c55e' }, + ], + }, + description: `Status: ${threatLevel.toUpperCase()}`, + }, + { + id: 'blockedThreats', + title: 'Blocked Threats', + value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected, + type: 'number', + icon: 'lucide:ShieldCheck', + color: '#ef4444', + description: 'Total threats blocked today', + }, + { + id: 'activeSessions', + title: 'Active Sessions', + value: recentAuthSuccesses, + type: 'number', + icon: 'lucide:Users', + color: '#22c55e', + description: 'Authenticated in last hour', + }, + { + id: 'authFailures', + title: 'Auth Failures', + value: metrics.authenticationFailures, + type: 'number', + icon: 'lucide:LockOpen', + color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b', + description: 'Failed login attempts today', + }, + ]; + + return html` + Overview + + + +

Recent Security Events

+ ({ + 'Time': new Date(item.timestamp).toLocaleTimeString(), + 'Event': item.event, + 'Severity': item.severity, + 'Details': item.details, + })} + > + `; + } + + private calculateThreatLevel(metrics: any): string { + const score = this.getThreatScore(metrics); + if (score < 30) return 'alert'; + if (score < 70) return 'warning'; + return 'success'; + } + + private getThreatScore(metrics: any): number { + // Simple scoring algorithm + let score = 100; + const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0); + score -= blockedCount * 2; + score -= (metrics.authenticationFailures || 0) * 1; + score -= (metrics.spamDetected || 0) * 0.5; + score -= (metrics.malwareDetected || 0) * 3; + score -= (metrics.phishingDetected || 0) * 3; + score -= (metrics.suspiciousActivities || 0) * 2; + return Math.max(0, Math.min(100, Math.round(score))); + } + + private getSecurityEvents(metrics: any): any[] { + const events: any[] = metrics.recentEvents || []; + return events.map((evt: any) => ({ + timestamp: evt.timestamp, + event: evt.message, + severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info', + details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type, + })); + } +} diff --git a/ts_web/elements/security/ops-view-security.ts b/ts_web/elements/security/ops-view-security.ts new file mode 100644 index 0000000..475d065 --- /dev/null +++ b/ts_web/elements/security/ops-view-security.ts @@ -0,0 +1,114 @@ +import * as appstate from '../../appstate.js'; +import { appRouter } from '../../router.js'; +import { viewHostCss } from '../shared/css.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +// Side-effect imports register the subview custom elements +import './ops-view-security-overview.js'; +import './ops-view-security-blocked.js'; +import './ops-view-security-authentication.js'; +import './ops-view-security-emailsecurity.js'; + +declare global { + interface HTMLElementTagNameMap { + 'ops-view-security': OpsViewSecurity; + } +} + +type TSecurityTab = 'overview' | 'blocked' | 'authentication' | 'emailsecurity'; + +@customElement('ops-view-security') +export class OpsViewSecurity extends DeesElement { + @state() + accessor selectedTab: TSecurityTab = 'overview'; + + private tabLabelMap: Record = { + 'overview': 'Overview', + 'blocked': 'Blocked IPs', + 'authentication': 'Authentication', + 'emailsecurity': 'Email Security', + }; + + private labelToTab: Record = { + 'Overview': 'overview', + 'Blocked IPs': 'blocked', + 'Authentication': 'authentication', + 'Email Security': 'emailsecurity', + }; + + private static isSecurityTab(s: string | null): s is TSecurityTab { + return s === 'overview' || s === 'blocked' || s === 'authentication' || s === 'emailsecurity'; + } + + constructor() { + super(); + // Read initial subview from state (URL-driven) + const initialState = appstate.uiStatePart.getState()!; + if (OpsViewSecurity.isSecurityTab(initialState.activeSubview)) { + this.selectedTab = initialState.activeSubview; + } + // Subscribe to future changes (back/forward navigation, direct URL entry) + const sub = appstate.uiStatePart.select((s) => s.activeSubview).subscribe((sub) => { + if (OpsViewSecurity.isSecurityTab(sub) && sub !== this.selectedTab) { + this.selectedTab = sub; + } + }); + this.rxSubscriptions.push(sub); + } + + async firstUpdated() { + const toggle = this.shadowRoot!.querySelector('dees-input-multitoggle') as any; + if (toggle) { + const sub = toggle.changeSubject.subscribe(() => { + const tab = this.labelToTab[toggle.selectedOption]; + if (tab && tab !== this.selectedTab) { + // Push URL → router updates state → subscription updates selectedTab + appRouter.navigateToView('security', tab); + } + }); + this.rxSubscriptions.push(sub); + } + } + + public static styles = [ + cssManager.defaultStyles, + viewHostCss, + css` + dees-input-multitoggle { + margin-bottom: 24px; + } + `, + ]; + + public render(): TemplateResult { + return html` + Security + + + + ${this.renderTabContent()} + `; + } + + private renderTabContent(): TemplateResult { + switch (this.selectedTab) { + case 'overview': return html``; + case 'blocked': return html``; + case 'authentication': return html``; + case 'emailsecurity': return html``; + } + } +} diff --git a/ts_web/router.ts b/ts_web/router.ts index e627816..158f50e 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -3,9 +3,31 @@ import * as appstate from './appstate.js'; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; -export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'sourceprofiles', 'networktargets', 'targetprofiles'] as const; +// Flat top-level views (no subviews) +const flatViews = ['overview', 'configuration', 'emails', 'logs', 'apitokens', 'certificates', 'remoteingress', 'vpn'] as const; -export type TValidView = typeof validViews[number]; +// Tabbed views and their valid subviews +const subviewMap: Record = { + network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles'] as const, + security: ['overview', 'blocked', 'authentication', 'emailsecurity'] as const, +}; + +// Default subview when user visits the bare parent URL +const defaultSubview: Record = { + network: 'activity', + security: 'overview', +}; + +export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const; +export type TValidView = typeof validTopLevelViews[number]; + +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; @@ -25,12 +47,27 @@ class AppRouter { } private setupRoutes(): void { - for (const view of validViews) { + // Flat views + for (const view of flatViews) { this.router.on(`/${view}`, async () => { - this.updateViewState(view); + this.updateViewState(view, null); }); } + // Tabbed views + for (const view of Object.keys(subviewMap)) { + // Bare parent → redirect to default subview + this.router.on(`/${view}`, async () => { + this.navigateTo(`/${view}/${defaultSubview[view]}`); + }); + // Each valid subview + for (const sub of subviewMap[view]) { + this.router.on(`/${view}/${sub}`, async () => { + this.updateViewState(view, sub); + }); + } + } + // Root redirect this.router.on('/', async () => { this.navigateTo('/overview'); @@ -42,7 +79,9 @@ class AppRouter { if (this.suppressStateUpdate) return; const currentPath = window.location.pathname; - const expectedPath = `/${uiState.activeView}`; + const expectedPath = uiState.activeSubview + ? `/${uiState.activeView}/${uiState.activeSubview}` + : `/${uiState.activeView}`; if (currentPath !== expectedPath) { this.suppressStateUpdate = true; @@ -57,25 +96,38 @@ class AppRouter { if (!path || path === '/') { this.router.pushUrl('/overview'); - } else { - const segments = path.split('/').filter(Boolean); - const view = segments[0]; + return; + } - if (validViews.includes(view as TValidView)) { - this.updateViewState(view as TValidView); + const segments = path.split('/').filter(Boolean); + const view = segments[0]; + const sub = segments[1]; + + if (!isValidView(view)) { + this.router.pushUrl('/overview'); + return; + } + + if (subviewMap[view]) { + if (sub && isValidSubview(view, sub)) { + this.updateViewState(view, sub); } else { - this.router.pushUrl('/overview'); + // Bare parent or invalid sub → default subview + this.router.pushUrl(`/${view}/${defaultSubview[view]}`); } + } else { + this.updateViewState(view, null); } } - private updateViewState(view: string): void { + private updateViewState(view: string, subview: string | null): void { this.suppressStateUpdate = true; const currentState = appstate.uiStatePart.getState()!; - if (currentState.activeView !== view) { + if (currentState.activeView !== view || currentState.activeSubview !== subview) { appstate.uiStatePart.setState({ ...currentState, activeView: view, + activeSubview: subview, } as appstate.IUiState); } this.suppressStateUpdate = false; @@ -85,11 +137,17 @@ class AppRouter { this.router.pushUrl(path); } - public navigateToView(view: string): void { - if (validViews.includes(view as TValidView)) { - this.navigateTo(`/${view}`); - } else { + 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}`); } }