feat(web-ui): reorganize dashboard views into grouped navigation with new email, access, and network subviews

This commit is contained in:
2026-04-08 08:24:55 +00:00
parent 00fdadb088
commit 2325f01cde
31 changed files with 214 additions and 378 deletions

View File

@@ -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();
}
}
}
}