feat(web-ui): reorganize dashboard views into grouped navigation with new email, access, and network subviews
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user