From 2325f01cde0e612dde6073b49d12c5cc97a85bdf Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 8 Apr 2026 08:24:55 +0000 Subject: [PATCH] feat(web-ui): reorganize dashboard views into grouped navigation with new email, access, and network subviews --- changelog.md | 8 + package.json | 2 +- pnpm-lock.yaml | 14 +- ts/00_commitinfo_data.ts | 2 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 25 +-- ts_web/elements/access/index.ts | 1 + .../{ => access}/ops-view-apitokens.ts | 6 +- ts_web/elements/email/index.ts | 2 + .../ops-view-email-security.ts} | 9 +- .../elements/{ => email}/ops-view-emails.ts | 8 +- ts_web/elements/index.ts | 11 +- ts_web/elements/network/index.ts | 3 +- .../network/ops-view-network-activity.ts | 3 +- ts_web/elements/network/ops-view-network.ts | 119 ----------- .../network/ops-view-networktargets.ts | 3 +- .../{ => network}/ops-view-remoteingress.ts | 8 +- ts_web/elements/network/ops-view-routes.ts | 3 +- .../network/ops-view-sourceprofiles.ts | 3 +- .../network/ops-view-targetprofiles.ts | 3 +- ts_web/elements/{ => network}/ops-view-vpn.ts | 10 +- ts_web/elements/ops-dashboard.ts | 190 ++++++++++++------ ts_web/elements/overview/index.ts | 2 + .../{ => overview}/ops-view-config.ts | 12 +- .../{ => overview}/ops-view-overview.ts | 6 +- ts_web/elements/security/index.ts | 2 - .../ops-view-security-authentication.ts | 3 +- .../security/ops-view-security-blocked.ts | 3 +- .../security/ops-view-security-overview.ts | 3 +- ts_web/elements/security/ops-view-security.ts | 114 ----------- ts_web/router.ts | 12 +- 31 files changed, 214 insertions(+), 378 deletions(-) create mode 100644 ts_web/elements/access/index.ts rename ts_web/elements/{ => access}/ops-view-apitokens.ts (98%) create mode 100644 ts_web/elements/email/index.ts rename ts_web/elements/{security/ops-view-security-emailsecurity.ts => email/ops-view-email-security.ts} (94%) rename ts_web/elements/{ => email}/ops-view-emails.ts (93%) delete mode 100644 ts_web/elements/network/ops-view-network.ts rename ts_web/elements/{ => network}/ops-view-remoteingress.ts (98%) rename ts_web/elements/{ => network}/ops-view-vpn.ts (99%) create mode 100644 ts_web/elements/overview/index.ts rename ts_web/elements/{ => overview}/ops-view-config.ts (97%) rename ts_web/elements/{ => overview}/ops-view-overview.ts (99%) delete mode 100644 ts_web/elements/security/ops-view-security.ts diff --git a/changelog.md b/changelog.md index c36c8e3..abb31a1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-08 - 13.4.0 - feat(web-ui) +reorganize dashboard views into grouped navigation with new email, access, and network subviews + +- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security. +- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation. +- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior. +- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling. + ## 2026-04-08 - 13.3.0 - feat(web-ui) reorganize network and security views into tabbed subviews with route-aware navigation diff --git a/package.json b/package.json index 0021d22..70bf425 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@api.global/typedserver": "^8.4.6", "@api.global/typedsocket": "^4.1.2", "@apiclient.xyz/cloudflare": "^7.1.0", - "@design.estate/dees-catalog": "^3.67.1", + "@design.estate/dees-catalog": "^3.68.0", "@design.estate/dees-element": "^2.2.4", "@push.rocks/lik": "^6.4.0", "@push.rocks/projectinfo": "^5.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca32800..8c400c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^7.1.0 version: 7.1.0 '@design.estate/dees-catalog': - specifier: ^3.67.1 - version: 3.67.1(@tiptap/pm@2.27.2) + specifier: ^3.68.0 + version: 3.68.0(@tiptap/pm@2.27.2) '@design.estate/dees-element': specifier: ^2.2.4 version: 2.2.4 @@ -353,8 +353,8 @@ packages: '@configvault.io/interfaces@1.0.17': resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} - '@design.estate/dees-catalog@3.67.1': - resolution: {integrity: sha512-8zaVNP70IbcB6pEmLoBxVA5WD0N5gQr12ylTdILtvds6rftKLCI1i2jx4RBztIy4FpZv0wIewJBtRvSUjK8Ysw==} + '@design.estate/dees-catalog@3.68.0': + resolution: {integrity: sha512-4jTq/pZmhLFS2jGsF8I+bqLV+P4O9bBAyNtF5Ga1omNCwZFQmITiwPZ2brOGvVFaVrMDi8VdY4I7FTMofF7Diw==} '@design.estate/dees-comms@1.0.30': resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} @@ -4315,7 +4315,7 @@ snapshots: '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3) '@cloudflare/workers-types': 4.20260405.1 - '@design.estate/dees-catalog': 3.67.1(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.68.0(@tiptap/pm@2.27.2) '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.4.0 '@push.rocks/smartdelay': 3.0.5 @@ -4844,7 +4844,7 @@ snapshots: dependencies: '@api.global/typedrequest-interfaces': 3.0.19 - '@design.estate/dees-catalog@3.67.1(@tiptap/pm@2.27.2)': + '@design.estate/dees-catalog@3.68.0(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-element': 2.2.4 @@ -6900,7 +6900,7 @@ snapshots: '@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)': dependencies: - '@design.estate/dees-catalog': 3.67.1(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.68.0(@tiptap/pm@2.27.2) '@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-element': 2.2.4 '@design.estate/dees-wcctools': 3.8.0 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 5ee4f02..cc434e7 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.3.0', + version: '13.4.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 5ee4f02..cc434e7 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.3.0', + version: '13.4.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 618bf41..3d06881 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -117,7 +117,7 @@ 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', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn']; + const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'certificates']; const segments = path.split('/').filter(Boolean); const view = segments[0]; return validViews.includes(view) ? view : 'overview'; @@ -444,20 +444,6 @@ export const setActiveViewAction = uiStatePart.createAction(async (state }, 100); } - // If switching to apitokens view, ensure we fetch token data - if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') { - setTimeout(() => { - routeManagementStatePart.dispatchAction(fetchApiTokensAction, null); - }, 100); - } - - // If switching to remoteingress view, ensure we fetch edge data - if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') { - setTimeout(() => { - remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); - }, 100); - } - return { ...currentState, activeView: viewName, @@ -1930,6 +1916,7 @@ async function dispatchCombinedRefreshActionInner() { const context = getActionContext(); if (!context.identity) return; const currentView = uiStatePart.getState()!.activeView; + const currentSubview = uiStatePart.getState()!.activeSubview; try { // Always fetch basic stats for dashboard widgets @@ -2041,8 +2028,8 @@ async function dispatchCombinedRefreshActionInner() { } } - // Refresh remote ingress data if on remoteingress view - if (currentView === 'remoteingress') { + // Refresh remote ingress data if on the Network → Remote Ingress subview + if (currentView === 'network' && currentSubview === 'remoteingress') { try { await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); } catch (error) { @@ -2050,8 +2037,8 @@ async function dispatchCombinedRefreshActionInner() { } } - // Refresh VPN data if on vpn view - if (currentView === 'vpn') { + // Refresh VPN data if on the Network → VPN subview + if (currentView === 'network' && currentSubview === 'vpn') { try { await vpnStatePart.dispatchAction(fetchVpnAction, null); } catch (error) { diff --git a/ts_web/elements/access/index.ts b/ts_web/elements/access/index.ts new file mode 100644 index 0000000..cc46c36 --- /dev/null +++ b/ts_web/elements/access/index.ts @@ -0,0 +1 @@ +export * from './ops-view-apitokens.js'; diff --git a/ts_web/elements/ops-view-apitokens.ts b/ts_web/elements/access/ops-view-apitokens.ts similarity index 98% rename from ts_web/elements/ops-view-apitokens.ts rename to ts_web/elements/access/ops-view-apitokens.ts index f2e715a..1c308c0 100644 --- a/ts_web/elements/ops-view-apitokens.ts +++ b/ts_web/elements/access/ops-view-apitokens.ts @@ -1,6 +1,6 @@ -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 { viewHostCss } from '../shared/css.js'; import { DeesElement, diff --git a/ts_web/elements/email/index.ts b/ts_web/elements/email/index.ts new file mode 100644 index 0000000..f8c2e1c --- /dev/null +++ b/ts_web/elements/email/index.ts @@ -0,0 +1,2 @@ +export * from './ops-view-emails.js'; +export * from './ops-view-email-security.js'; diff --git a/ts_web/elements/security/ops-view-security-emailsecurity.ts b/ts_web/elements/email/ops-view-email-security.ts similarity index 94% rename from ts_web/elements/security/ops-view-security-emailsecurity.ts rename to ts_web/elements/email/ops-view-email-security.ts index ff908e5..a16c5bf 100644 --- a/ts_web/elements/security/ops-view-security-emailsecurity.ts +++ b/ts_web/elements/email/ops-view-email-security.ts @@ -1,4 +1,5 @@ import * as appstate from '../../appstate.js'; +import { viewHostCss } from '../shared/css.js'; import { DeesElement, @@ -13,12 +14,12 @@ import { type IStatsTile } from '@design.estate/dees-catalog'; declare global { interface HTMLElementTagNameMap { - 'ops-view-security-emailsecurity': OpsViewSecurityEmailsecurity; + 'ops-view-email-security': OpsViewEmailSecurity; } } -@customElement('ops-view-security-emailsecurity') -export class OpsViewSecurityEmailsecurity extends DeesElement { +@customElement('ops-view-email-security') +export class OpsViewEmailSecurity extends DeesElement { @state() accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!; @@ -34,8 +35,8 @@ export class OpsViewSecurityEmailsecurity extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } h2 { margin: 32px 0 16px 0; font-size: 24px; diff --git a/ts_web/elements/ops-view-emails.ts b/ts_web/elements/email/ops-view-emails.ts similarity index 93% rename from ts_web/elements/ops-view-emails.ts rename to ts_web/elements/email/ops-view-emails.ts index c57a9ae..a05dc65 100644 --- a/ts_web/elements/ops-view-emails.ts +++ b/ts_web/elements/email/ops-view-emails.ts @@ -1,8 +1,8 @@ import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element'; -import * as plugins from '../plugins.js'; -import * as appstate from '../appstate.js'; -import * as shared from './shared/index.js'; -import * as interfaces from '../../dist_ts_interfaces/index.js'; +import * as plugins from '../../plugins.js'; +import * as appstate from '../../appstate.js'; +import * as shared from '../shared/index.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; declare global { interface HTMLElementTagNameMap { diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 789ae8d..6e6fd4b 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -1,12 +1,9 @@ export * from './ops-dashboard.js'; -export * from './ops-view-overview.js'; +export * from './overview/index.js'; export * from './network/index.js'; -export * from './ops-view-emails.js'; +export * from './email/index.js'; export * from './ops-view-logs.js'; -export * from './ops-view-config.js'; -export * from './ops-view-apitokens.js'; +export * from './access/index.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 './shared/index.js'; \ No newline at end of file +export * from './shared/index.js'; diff --git a/ts_web/elements/network/index.ts b/ts_web/elements/network/index.ts index e75a60d..6d5b65b 100644 --- a/ts_web/elements/network/index.ts +++ b/ts_web/elements/network/index.ts @@ -1,6 +1,7 @@ -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'; +export * from './ops-view-remoteingress.js'; +export * from './ops-view-vpn.js'; diff --git a/ts_web/elements/network/ops-view-network-activity.ts b/ts_web/elements/network/ops-view-network-activity.ts index d082b12..6de5755 100644 --- a/ts_web/elements/network/ops-view-network-activity.ts +++ b/ts_web/elements/network/ops-view-network-activity.ts @@ -1,6 +1,7 @@ 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 { type IStatsTile } from '@design.estate/dees-catalog'; declare global { @@ -175,8 +176,8 @@ export class OpsViewNetworkActivity extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } .networkContainer { display: flex; flex-direction: column; diff --git a/ts_web/elements/network/ops-view-network.ts b/ts_web/elements/network/ops-view-network.ts deleted file mode 100644 index 6fa6fe2..0000000 --- a/ts_web/elements/network/ops-view-network.ts +++ /dev/null @@ -1,119 +0,0 @@ -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/network/ops-view-networktargets.ts b/ts_web/elements/network/ops-view-networktargets.ts index 30d4c63..34fce82 100644 --- a/ts_web/elements/network/ops-view-networktargets.ts +++ b/ts_web/elements/network/ops-view-networktargets.ts @@ -9,6 +9,7 @@ import { } 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 { type IStatsTile } from '@design.estate/dees-catalog'; declare global { @@ -37,8 +38,8 @@ export class OpsViewNetworkTargets extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } .targetsContainer { display: flex; flex-direction: column; diff --git a/ts_web/elements/ops-view-remoteingress.ts b/ts_web/elements/network/ops-view-remoteingress.ts similarity index 98% rename from ts_web/elements/ops-view-remoteingress.ts rename to ts_web/elements/network/ops-view-remoteingress.ts index db08d84..6d3bd32 100644 --- a/ts_web/elements/ops-view-remoteingress.ts +++ b/ts_web/elements/network/ops-view-remoteingress.ts @@ -7,9 +7,9 @@ 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 { viewHostCss } from '../shared/css.js'; import { type IStatsTile } from '@design.estate/dees-catalog'; declare global { @@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement { ]; return html` - Remote Ingress + Remote Ingress ${this.riState.newEdgeId ? html`
diff --git a/ts_web/elements/network/ops-view-routes.ts b/ts_web/elements/network/ops-view-routes.ts index 0058fe8..d691338 100644 --- a/ts_web/elements/network/ops-view-routes.ts +++ b/ts_web/elements/network/ops-view-routes.ts @@ -1,5 +1,6 @@ import * as appstate from '../../appstate.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; import { type IStatsTile } from '@design.estate/dees-catalog'; import { @@ -96,8 +97,8 @@ export class OpsViewRoutes extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } .routesContainer { display: flex; flex-direction: column; diff --git a/ts_web/elements/network/ops-view-sourceprofiles.ts b/ts_web/elements/network/ops-view-sourceprofiles.ts index 1546552..9da644e 100644 --- a/ts_web/elements/network/ops-view-sourceprofiles.ts +++ b/ts_web/elements/network/ops-view-sourceprofiles.ts @@ -9,6 +9,7 @@ import { } 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 { type IStatsTile } from '@design.estate/dees-catalog'; declare global { @@ -37,8 +38,8 @@ export class OpsViewSourceProfiles extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } .profilesContainer { display: flex; flex-direction: column; diff --git a/ts_web/elements/network/ops-view-targetprofiles.ts b/ts_web/elements/network/ops-view-targetprofiles.ts index b4c8762..2f1614c 100644 --- a/ts_web/elements/network/ops-view-targetprofiles.ts +++ b/ts_web/elements/network/ops-view-targetprofiles.ts @@ -10,6 +10,7 @@ import { import * as plugins from '../../plugins.js'; import * as appstate from '../../appstate.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; import { type IStatsTile } from '@design.estate/dees-catalog'; declare global { @@ -38,8 +39,8 @@ export class OpsViewTargetProfiles extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } .profilesContainer { display: flex; flex-direction: column; diff --git a/ts_web/elements/ops-view-vpn.ts b/ts_web/elements/network/ops-view-vpn.ts similarity index 99% rename from ts_web/elements/ops-view-vpn.ts rename to ts_web/elements/network/ops-view-vpn.ts index 98658ea..114d2eb 100644 --- a/ts_web/elements/ops-view-vpn.ts +++ b/ts_web/elements/network/ops-view-vpn.ts @@ -7,10 +7,10 @@ import { state, cssManager, } from '@design.estate/dees-element'; -import * as plugins from '../plugins.js'; -import * as appstate from '../appstate.js'; -import * as interfaces from '../../dist_ts_interfaces/index.js'; -import { viewHostCss } from './shared/css.js'; +import * as plugins from '../../plugins.js'; +import * as appstate from '../../appstate.js'; +import * as interfaces from '../../../dist_ts_interfaces/index.js'; +import { viewHostCss } from '../shared/css.js'; import { type IStatsTile } from '@design.estate/dees-catalog'; /** @@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement { ]; return html` - VPN + VPN
${this.vpnState.newClientConfig ? html` diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index fb973d3..2660378 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -11,18 +11,45 @@ import { state, type TemplateResult } from '@design.estate/dees-element'; +import type { IView } from '@design.estate/dees-catalog'; -// Import view components -import { OpsViewOverview } from './ops-view-overview.js'; -import { OpsViewNetwork } from './network/ops-view-network.js'; -import { OpsViewEmails } from './ops-view-emails.js'; +// Top-level / flat views import { OpsViewLogs } from './ops-view-logs.js'; -import { OpsViewConfig } from './ops-view-config.js'; -import { OpsViewApiTokens } from './ops-view-apitokens.js'; -import { OpsViewSecurity } from './security/ops-view-security.js'; import { OpsViewCertificates } from './ops-view-certificates.js'; -import { OpsViewRemoteIngress } from './ops-view-remoteingress.js'; -import { OpsViewVpn } from './ops-view-vpn.js'; + +// Overview group +import { OpsViewOverview } from './overview/ops-view-overview.js'; +import { OpsViewConfig } from './overview/ops-view-config.js'; + +// Network group +import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js'; +import { OpsViewRoutes } from './network/ops-view-routes.js'; +import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js'; +import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js'; +import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js'; +import { OpsViewRemoteIngress } from './network/ops-view-remoteingress.js'; +import { OpsViewVpn } from './network/ops-view-vpn.js'; + +// Email group +import { OpsViewEmails } from './email/ops-view-emails.js'; +import { OpsViewEmailSecurity } from './email/ops-view-email-security.js'; + +// Access group +import { OpsViewApiTokens } from './access/ops-view-apitokens.js'; + +// Security group +import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js'; +import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js'; +import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js'; + +/** + * Extended IView with explicit URL slug. Without an explicit `slug`, the URL + * slug is derived from `name.toLowerCase().replace(/\s+/g, '')`. + */ +interface ITabbedView extends IView { + slug?: string; + subViews?: ITabbedView[]; +} @customElement('ops-dashboard') export class OpsDashboard extends DeesElement { @@ -46,27 +73,36 @@ export class OpsDashboard extends DeesElement { error: null, }; - // Store viewTabs as a property to maintain object references - private viewTabs = [ + // Store viewTabs as a property to maintain object references (used for === selectedView identity) + private viewTabs: ITabbedView[] = [ { name: 'Overview', iconName: 'lucide:layoutDashboard', - element: OpsViewOverview, - }, - { - name: 'Configuration', - iconName: 'lucide:settings', - element: OpsViewConfig, + subViews: [ + { slug: 'stats', name: 'Stats', iconName: 'lucide:activity', element: OpsViewOverview }, + { slug: 'configuration', name: 'Configuration', iconName: 'lucide:settings', element: OpsViewConfig }, + ], }, { name: 'Network', iconName: 'lucide:network', - element: OpsViewNetwork, + subViews: [ + { slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity }, + { slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes }, + { slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles }, + { slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets }, + { slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles }, + { slug: 'remoteingress', name: 'Remote Ingress', iconName: 'lucide:globe', element: OpsViewRemoteIngress }, + { slug: 'vpn', name: 'VPN', iconName: 'lucide:shield', element: OpsViewVpn }, + ], }, { - name: 'Emails', + name: 'Email', iconName: 'lucide:mail', - element: OpsViewEmails, + subViews: [ + { slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails }, + { slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity }, + ], }, { name: 'Logs', @@ -74,32 +110,48 @@ export class OpsDashboard extends DeesElement { element: OpsViewLogs, }, { - name: 'ApiTokens', - iconName: 'lucide:key', - element: OpsViewApiTokens, + name: 'Access', + iconName: 'lucide:keyRound', + subViews: [ + { slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens }, + ], }, { name: 'Security', iconName: 'lucide:shield', - element: OpsViewSecurity, + subViews: [ + { slug: 'overview', name: 'Overview', iconName: 'lucide:eye', element: OpsViewSecurityOverview }, + { slug: 'blocked', name: 'Blocked IPs', iconName: 'lucide:shieldBan', element: OpsViewSecurityBlocked }, + { slug: 'authentication', name: 'Authentication', iconName: 'lucide:lock', element: OpsViewSecurityAuthentication }, + ], }, { name: 'Certificates', iconName: 'lucide:badgeCheck', element: OpsViewCertificates, }, - { - name: 'RemoteIngress', - iconName: 'lucide:globe', - element: OpsViewRemoteIngress, - }, - { - name: 'VPN', - iconName: 'lucide:shield', - element: OpsViewVpn, - }, ]; + /** URL slug for a view (explicit `slug` field, or lowercased name with spaces stripped). */ + private slugFor(view: ITabbedView): string { + return view.slug ?? view.name.toLowerCase().replace(/\s+/g, ''); + } + + /** Find the parent group of a subview, or undefined for top-level views. */ + private findParent(view: ITabbedView): ITabbedView | undefined { + return this.viewTabs.find((v) => v.subViews?.includes(view)); + } + + /** Look up a view (or subview) by its URL slug pair. */ + private findViewBySlug(viewSlug: string, subSlug: string | null): ITabbedView | undefined { + const top = this.viewTabs.find((v) => this.slugFor(v) === viewSlug); + if (!top) return undefined; + if (subSlug && top.subViews) { + return top.subViews.find((sv) => this.slugFor(sv) === subSlug) ?? top; + } + return top; + } + private get globalMessages() { const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = []; const config = this.configState.config; @@ -115,17 +167,19 @@ export class OpsDashboard extends DeesElement { } /** - * Get the current view tab based on the UI state's activeView. + * Get the current view tab based on the UI state's activeView/activeSubview. * Used to pass the correct selectedView to dees-simple-appdash on initial render. */ - private get currentViewTab() { - return this.viewTabs.find(t => t.name.toLowerCase() === this.uiState.activeView) || this.viewTabs[0]; + private get currentViewTab(): ITabbedView { + return ( + this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0] + ); } constructor() { super(); document.title = 'DCRouter OpsServer'; - + // Subscribe to login state const loginSubscription = appstate.loginStatePart .select((stateArg) => stateArg) @@ -138,7 +192,7 @@ export class OpsDashboard extends DeesElement { } }); this.rxSubscriptions.push(loginSubscription); - + // Subscribe to config state (for global warnings) const configSubscription = appstate.configStatePart .select((stateArg) => stateArg) @@ -153,38 +207,27 @@ export class OpsDashboard extends DeesElement { .subscribe((uiState) => { this.uiState = uiState; // Sync appdash view when state changes (e.g., from URL navigation) - this.syncAppdashView(uiState.activeView); + this.syncAppdashView(uiState.activeView, uiState.activeSubview); }); this.rxSubscriptions.push(uiSubscription); } /** * Sync the dees-simple-appdash view selection with the current state. - * This is needed when the URL changes and we need to update the UI. + * This is needed when the URL changes externally (back/forward, deep link). */ - private syncAppdashView(viewName: string): void { + private syncAppdashView(viewSlug: string, subviewSlug: string | null): void { const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any; if (!appDash) return; - const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName); - if (!targetTab) return; + const targetView = this.findViewBySlug(viewSlug, subviewSlug); + if (!targetView) return; - // Check if we need to switch (avoid unnecessary updates) - if (appDash.selectedView === targetTab) return; + if (appDash.selectedView === targetView) return; - // Update the selected view programmatically - appDash.selectedView = targetTab; - - // Update the displayed content - const content = appDash.shadowRoot?.querySelector('.appcontent'); - if (content) { - if (appDash.currentView) { - appDash.currentView.remove(); - } - const view = new targetTab.element(); - content.appendChild(view); - appDash.currentView = view; - } + // Use loadView to update both selectedView and the mounted element. + // It will dispatch view-select; our handler skips when state already matches. + appDash.loadView(targetView); } public static styles = [ @@ -226,7 +269,7 @@ export class OpsDashboard extends DeesElement { public async firstUpdated() { const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; simpleLogin.addEventListener('login', (e: Event) => { - // Handle logout event + // Handle login event const detail = (e as CustomEvent).detail; this.login(detail.data.username, detail.data.password); }); @@ -235,9 +278,24 @@ export class OpsDashboard extends DeesElement { const appDash = this.shadowRoot!.querySelector('dees-simple-appdash'); if (appDash) { appDash.addEventListener('view-select', (e: Event) => { - const viewName = (e as CustomEvent).detail.view.name.toLowerCase(); - // Use router for navigation instead of direct state update - appRouter.navigateToView(viewName); + const view = (e as CustomEvent).detail.view as ITabbedView; + const parent = this.findParent(view); + const currentState = appstate.uiStatePart.getState(); + if (parent) { + const parentSlug = this.slugFor(parent); + const subSlug = this.slugFor(view); + // Skip if already on this exact subview — preserves URL on initial mount + if (currentState?.activeView === parentSlug && currentState?.activeSubview === subSlug) { + return; + } + appRouter.navigateToView(parentSlug, subSlug); + } else { + const slug = this.slugFor(view); + if (currentState?.activeView === slug && !currentState?.activeSubview) { + return; + } + appRouter.navigateToView(slug); + } }); // Handle logout event @@ -283,12 +341,12 @@ export class OpsDashboard extends DeesElement { const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any; form.setStatus('pending', 'Logging in...'); - + const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, { username, password, }); - + if (state.identity) { console.log('Login successful'); this.loginState = state; @@ -302,4 +360,4 @@ export class OpsDashboard extends DeesElement { form!.reset(); } } -} \ No newline at end of file +} diff --git a/ts_web/elements/overview/index.ts b/ts_web/elements/overview/index.ts new file mode 100644 index 0000000..07e2dde --- /dev/null +++ b/ts_web/elements/overview/index.ts @@ -0,0 +1,2 @@ +export * from './ops-view-overview.js'; +export * from './ops-view-config.js'; diff --git a/ts_web/elements/ops-view-config.ts b/ts_web/elements/overview/ops-view-config.ts similarity index 97% rename from ts_web/elements/ops-view-config.ts rename to ts_web/elements/overview/ops-view-config.ts index 1372a84..968feb1 100644 --- a/ts_web/elements/ops-view-config.ts +++ b/ts_web/elements/overview/ops-view-config.ts @@ -1,7 +1,7 @@ -import * as plugins from '../plugins.js'; -import * as shared from './shared/index.js'; -import * as appstate from '../appstate.js'; -import { appRouter } from '../router.js'; +import * as plugins from '../../plugins.js'; +import * as shared from '../shared/index.js'; +import * as appstate from '../../appstate.js'; +import { appRouter } from '../../router.js'; import { DeesElement, @@ -181,7 +181,7 @@ export class OpsViewConfig extends DeesElement { } const actions: IConfigSectionAction[] = [ - { label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } }, + { label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'email', subview: 'log' } }, ]; return html` @@ -305,7 +305,7 @@ export class OpsViewConfig extends DeesElement { ]; const actions: IConfigSectionAction[] = [ - { label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } }, + { label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } }, ]; return html` diff --git a/ts_web/elements/ops-view-overview.ts b/ts_web/elements/overview/ops-view-overview.ts similarity index 99% rename from ts_web/elements/ops-view-overview.ts rename to ts_web/elements/overview/ops-view-overview.ts index f2b8e16..213467c 100644 --- a/ts_web/elements/ops-view-overview.ts +++ b/ts_web/elements/overview/ops-view-overview.ts @@ -1,6 +1,6 @@ -import * as plugins from '../plugins.js'; -import * as shared from './shared/index.js'; -import * as appstate from '../appstate.js'; +import * as plugins from '../../plugins.js'; +import * as shared from '../shared/index.js'; +import * as appstate from '../../appstate.js'; import { DeesElement, diff --git a/ts_web/elements/security/index.ts b/ts_web/elements/security/index.ts index a10d4d3..46d9a05 100644 --- a/ts_web/elements/security/index.ts +++ b/ts_web/elements/security/index.ts @@ -1,5 +1,3 @@ -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 index 274c11d..23c9a84 100644 --- a/ts_web/elements/security/ops-view-security-authentication.ts +++ b/ts_web/elements/security/ops-view-security-authentication.ts @@ -1,4 +1,5 @@ import * as appstate from '../../appstate.js'; +import { viewHostCss } from '../shared/css.js'; import { DeesElement, @@ -34,8 +35,8 @@ export class OpsViewSecurityAuthentication extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } h2 { margin: 32px 0 16px 0; font-size: 24px; diff --git a/ts_web/elements/security/ops-view-security-blocked.ts b/ts_web/elements/security/ops-view-security-blocked.ts index 48b2c1f..98d4f4f 100644 --- a/ts_web/elements/security/ops-view-security-blocked.ts +++ b/ts_web/elements/security/ops-view-security-blocked.ts @@ -1,4 +1,5 @@ import * as appstate from '../../appstate.js'; +import { viewHostCss } from '../shared/css.js'; import { DeesElement, @@ -34,8 +35,8 @@ export class OpsViewSecurityBlocked extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } dees-statsgrid { margin-bottom: 32px; } diff --git a/ts_web/elements/security/ops-view-security-overview.ts b/ts_web/elements/security/ops-view-security-overview.ts index 4c22ad6..62d092c 100644 --- a/ts_web/elements/security/ops-view-security-overview.ts +++ b/ts_web/elements/security/ops-view-security-overview.ts @@ -1,4 +1,5 @@ import * as appstate from '../../appstate.js'; +import { viewHostCss } from '../shared/css.js'; import { DeesElement, @@ -34,8 +35,8 @@ export class OpsViewSecurityOverview extends DeesElement { public static styles = [ cssManager.defaultStyles, + viewHostCss, css` - :host { display: block; } h2 { margin: 32px 0 16px 0; font-size: 24px; diff --git a/ts_web/elements/security/ops-view-security.ts b/ts_web/elements/security/ops-view-security.ts deleted file mode 100644 index 475d065..0000000 --- a/ts_web/elements/security/ops-view-security.ts +++ /dev/null @@ -1,114 +0,0 @@ -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 158f50e..c2ec5b8 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -4,17 +4,23 @@ import * as appstate from './appstate.js'; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; // Flat top-level views (no subviews) -const flatViews = ['overview', 'configuration', 'emails', 'logs', 'apitokens', 'certificates', 'remoteingress', 'vpn'] as const; +const flatViews = ['logs', 'certificates'] as const; // Tabbed views and their valid subviews const subviewMap: Record = { - network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles'] as const, - security: ['overview', 'blocked', 'authentication', 'emailsecurity'] as const, + overview: ['stats', 'configuration'] as const, + network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const, + email: ['log', 'security'] as const, + access: ['apitokens'] as const, + security: ['overview', 'blocked', 'authentication'] as const, }; // Default subview when user visits the bare parent URL const defaultSubview: Record = { + overview: 'stats', network: 'activity', + email: 'log', + access: 'apitokens', security: 'overview', };