Compare commits

..

4 Commits

Author SHA1 Message Date
00fdadb088 v13.3.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:45:26 +00:00
2b76e05a40 feat(web-ui): reorganize network and security views into tabbed subviews with route-aware navigation 2026-04-08 07:45:26 +00:00
1b37944aab v13.2.2
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:13:01 +00:00
35a01a6981 fix(project): no changes to commit 2026-04-08 07:13:01 +00:00
23 changed files with 978 additions and 611 deletions

View File

@@ -1,5 +1,17 @@
# 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
## 2026-04-08 - 13.2.1 - fix(project)
no changes to commit

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.2.1",
"version": "13.3.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.2.1',
version: '13.3.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.2.1',
version: '13.3.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -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<IConfigState>(
// 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<IUiState>(
'ui',
{
activeView: getInitialView(),
activeSubview: getInitialSubview(),
sidebarCollapsed: false,
autoRefresh: true,
refreshInterval: 1000, // 1 second
@@ -435,15 +444,6 @@ export const setActiveViewAction = uiStatePart.createAction<string>(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<string>(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,

View File

@@ -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';

View File

@@ -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';

View File

@@ -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`
<dees-heading level="2">Network Activity</dees-heading>
<dees-heading level="hr">Network Activity</dees-heading>
<div class="networkContainer">
<!-- Stats Grid -->
${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`}
></dees-chart-area>
@@ -323,7 +322,6 @@ export class OpsViewNetwork extends DeesElement {
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.showColumnFilters=${true}
.displayFunction=${(req: INetworkRequest) => ({
Time: new Date(req.timestamp).toLocaleTimeString(),
Protocol: html`<span class="protocolBadge ${req.protocol}">${req.protocol.toUpperCase()}</span>`,
@@ -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`<span class="statusBadge warning">N/A</span>`;
}
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
statusCode >= 400 ? 'error' : 'warning';
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
}
@@ -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`
<dees-statsgrid
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
.gridActions=${[
{
name: 'Export Data',
iconName: 'lucide:FileOutput',
action: async () => {
console.log('Export feature coming soon');
},
},
]}
></dees-statsgrid>
`;
}
@@ -604,7 +593,6 @@ export class OpsViewNetwork extends DeesElement {
return html`
<dees-table
.data=${this.networkState.topIPs}
.showColumnFilters=${true}
.displayFunction=${(ipData: { ip: string; count: number }) => {
const bw = bandwidthByIP.get(ipData.ip);
return {
@@ -632,7 +620,6 @@ export class OpsViewNetwork extends DeesElement {
return html`
<dees-table
.data=${backends}
.showColumnFilters=${true}
.displayFunction=${(item: interfaces.data.IBackendInfo) => {
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;
}
}
}
}

View File

@@ -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<TNetworkTab, string> = {
'activity': 'Network Activity',
'routes': 'Routes',
'sourceprofiles': 'Source Profiles',
'networktargets': 'Network Targets',
'targetprofiles': 'Target Profiles',
};
private labelToTab: Record<string, TNetworkTab> = {
'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`
<dees-heading level="2">Network</dees-heading>
<dees-input-multitoggle
.type=${'single'}
.options=${['Network Activity', 'Routes', 'Source Profiles', 'Network Targets', 'Target Profiles']}
.selectedOption=${this.tabLabelMap[this.selectedTab]}
></dees-input-multitoggle>
${this.renderTabContent()}
`;
}
private renderTabContent(): TemplateResult {
switch (this.selectedTab) {
case 'activity': return html`<ops-view-network-activity></ops-view-network-activity>`;
case 'routes': return html`<ops-view-routes></ops-view-routes>`;
case 'sourceprofiles': return html`<ops-view-sourceprofiles></ops-view-sourceprofiles>`;
case 'networktargets': return html`<ops-view-networktargets></ops-view-networktargets>`;
case 'targetprofiles': return html`<ops-view-targetprofiles></ops-view-targetprofiles>`;
}
}
}

View File

@@ -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`
<dees-heading level="2">Network Targets</dees-heading>
<dees-heading level="hr">Network Targets</dees-heading>
<div class="targetsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table

View File

@@ -1,6 +1,5 @@
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';
import {
@@ -97,8 +96,8 @@ export class OpsViewRoutes extends DeesElement {
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
:host { display: block; }
.routesContainer {
display: flex;
flex-direction: column;
@@ -200,7 +199,7 @@ export class OpsViewRoutes extends DeesElement {
});
return html`
<dees-heading level="2">Route Management</dees-heading>
<dees-heading level="hr">Route Management</dees-heading>
<div class="routesContainer">
<dees-statsgrid

View File

@@ -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 OpsViewSourceProfiles extends DeesElement {
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
:host { display: block; }
.profilesContainer {
display: flex;
flex-direction: column;
@@ -64,7 +63,7 @@ export class OpsViewSourceProfiles extends DeesElement {
];
return html`
<dees-heading level="2">Source Profiles</dees-heading>
<dees-heading level="hr">Source Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table

View File

@@ -7,10 +7,9 @@ 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 { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
@@ -39,8 +38,8 @@ export class OpsViewTargetProfiles extends DeesElement {
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
:host { display: block; }
.profilesContainer {
display: flex;
flex-direction: column;
@@ -77,7 +76,7 @@ export class OpsViewTargetProfiles extends DeesElement {
];
return html`
<dees-heading level="2">Target Profiles</dees-heading>
<dees-heading level="hr">Target Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table

View File

@@ -14,19 +14,15 @@ import {
// Import view components
import { OpsViewOverview } from './ops-view-overview.js';
import { OpsViewNetwork } from './ops-view-network.js';
import { OpsViewNetwork } from './network/ops-view-network.js';
import { OpsViewEmails } from './ops-view-emails.js';
import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewConfig } from './ops-view-config.js';
import { OpsViewRoutes } from './ops-view-routes.js';
import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './ops-view-security.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';
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js';
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.js';
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -37,6 +33,7 @@ export class OpsDashboard extends DeesElement {
@state() accessor uiState: appstate.IUiState = {
activeView: 'overview',
activeSubview: null,
sidebarCollapsed: false,
autoRefresh: true,
refreshInterval: 1000,
@@ -76,26 +73,6 @@ export class OpsDashboard extends DeesElement {
iconName: 'lucide:scrollText',
element: OpsViewLogs,
},
{
name: 'Routes',
iconName: 'lucide:route',
element: OpsViewRoutes,
},
{
name: 'SourceProfiles',
iconName: 'lucide:shieldCheck',
element: OpsViewSourceProfiles,
},
{
name: 'NetworkTargets',
iconName: 'lucide:server',
element: OpsViewNetworkTargets,
},
{
name: 'TargetProfiles',
iconName: 'lucide:target',
element: OpsViewTargetProfiles,
},
{
name: 'ApiTokens',
iconName: 'lucide:key',

View File

@@ -86,7 +86,7 @@ export class OpsViewConfig extends DeesElement {
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
@navigate=${(e: CustomEvent) => {
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`

View File

@@ -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<string, string> = {
'overview': 'Overview',
'blocked': 'Blocked IPs',
'authentication': 'Authentication',
'email-security': 'Email Security',
};
private labelToTab: Record<string, 'overview' | 'blocked' | 'authentication' | 'email-security'> = {
'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`
<dees-heading level="2">Security</dees-heading>
<dees-input-multitoggle
.type=${'single'}
.options=${['Overview', 'Blocked IPs', 'Authentication', 'Email Security']}
.selectedOption=${this.tabLabelMap[this.selectedTab]}
></dees-input-multitoggle>
${this.renderTabContent()}
`;
}
private renderTabContent() {
const metrics = this.statsState.securityMetrics;
if (!metrics) {
return html`
<div class="loadingMessage">
<p>Loading security metrics...</p>
</div>
`;
}
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`
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Recent Security Events</h2>
<dees-table
.heading1=${'Security Events'}
.heading2=${'Last 24 hours'}
.data=${this.getSecurityEvents(metrics)}
.showColumnFilters=${true}
.displayFunction=${(item) => ({
'Time': new Date(item.timestamp).toLocaleTimeString(),
'Event': item.event,
'Severity': item.severity,
'Details': item.details,
})}
></dees-table>
`;
}
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`
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<dees-table
.heading1=${'Blocked IP Addresses'}
.heading2=${'IPs blocked due to suspicious activity'}
.data=${blockedIPs.map((ip) => ({ 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();
},
},
]}
></dees-table>
`;
}
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`
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Recent Login Attempts</h2>
<dees-table
.heading1=${'Login History'}
.heading2=${'Recent authentication attempts'}
.data=${loginHistory}
.showColumnFilters=${true}
.displayFunction=${(item) => ({
'Time': new Date(item.timestamp).toLocaleString(),
'Username': item.username,
'IP Address': item.ipAddress,
'Status': item.success ? 'Success' : 'Failed',
'Reason': item.reason || '-',
})}
></dees-table>
`;
}
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`
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Email Security Configuration</h2>
<div class="securityCard">
<dees-form>
<dees-input-checkbox
.key=${'enableSPF'}
.label=${'Enable SPF checking'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDKIM'}
.label=${'Enable DKIM validation'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDMARC'}
.label=${'Enable DMARC policy enforcement'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableSpamFilter'}
.label=${'Enable spam filtering'}
.value=${true}
></dees-input-checkbox>
</dees-form>
<dees-button
class="actionButton"
type="highlighted"
@click=${() => this.saveEmailSecuritySettings()}
>
Save Settings
</dees-button>
</div>
`;
}
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.');
}
}

View File

@@ -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';

View File

@@ -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`
<div class="loadingMessage">
<p>Loading security metrics...</p>
</div>
`;
}
// 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`
<dees-heading level="hr">Authentication</dees-heading>
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Recent Login Attempts</h2>
<dees-table
.heading1=${'Login History'}
.heading2=${'Recent authentication attempts'}
.data=${loginHistory}
.displayFunction=${(item) => ({
'Time': new Date(item.timestamp).toLocaleString(),
'Username': item.username,
'IP Address': item.ipAddress,
'Status': item.success ? 'Success' : 'Failed',
'Reason': item.reason || '-',
})}
></dees-table>
`;
}
}

View File

@@ -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`
<div class="loadingMessage">
<p>Loading security metrics...</p>
</div>
`;
}
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`
<dees-heading level="hr">Blocked IPs</dees-heading>
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<dees-table
.heading1=${'Blocked IP Addresses'}
.heading2=${'IPs blocked due to suspicious activity'}
.data=${blockedIPs.map((ip) => ({ 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();
},
},
]}
></dees-table>
`;
}
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.`);
}
}

View File

@@ -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`
<div class="loadingMessage">
<p>Loading security metrics...</p>
</div>
`;
}
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`
<dees-heading level="hr">Email Security</dees-heading>
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Email Security Configuration</h2>
<div class="securityCard">
<dees-form>
<dees-input-checkbox
.key=${'enableSPF'}
.label=${'Enable SPF checking'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDKIM'}
.label=${'Enable DKIM validation'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDMARC'}
.label=${'Enable DMARC policy enforcement'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableSpamFilter'}
.label=${'Enable spam filtering'}
.value=${true}
></dees-input-checkbox>
</dees-form>
<dees-button
class="actionButton"
type="highlighted"
@click=${() => this.saveEmailSecuritySettings()}
>
Save Settings
</dees-button>
</div>
`;
}
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.');
}
}

View File

@@ -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`
<div class="loadingMessage">
<p>Loading security metrics...</p>
</div>
`;
}
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`
<dees-heading level="hr">Overview</dees-heading>
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Recent Security Events</h2>
<dees-table
.heading1=${'Security Events'}
.heading2=${'Last 24 hours'}
.data=${this.getSecurityEvents(metrics)}
.displayFunction=${(item) => ({
'Time': new Date(item.timestamp).toLocaleTimeString(),
'Event': item.event,
'Severity': item.severity,
'Details': item.details,
})}
></dees-table>
`;
}
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,
}));
}
}

View File

@@ -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<TSecurityTab, string> = {
'overview': 'Overview',
'blocked': 'Blocked IPs',
'authentication': 'Authentication',
'emailsecurity': 'Email Security',
};
private labelToTab: Record<string, TSecurityTab> = {
'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`
<dees-heading level="2">Security</dees-heading>
<dees-input-multitoggle
.type=${'single'}
.options=${['Overview', 'Blocked IPs', 'Authentication', 'Email Security']}
.selectedOption=${this.tabLabelMap[this.selectedTab]}
></dees-input-multitoggle>
${this.renderTabContent()}
`;
}
private renderTabContent(): TemplateResult {
switch (this.selectedTab) {
case 'overview': return html`<ops-view-security-overview></ops-view-security-overview>`;
case 'blocked': return html`<ops-view-security-blocked></ops-view-security-blocked>`;
case 'authentication': return html`<ops-view-security-authentication></ops-view-security-authentication>`;
case 'emailsecurity': return html`<ops-view-security-emailsecurity></ops-view-security-emailsecurity>`;
}
}
}

View File

@@ -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<string, readonly string[]> = {
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<string, string> = {
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<typeof SmartRouter>;
@@ -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}`);
}
}