feat(web-ui): reorganize network and security views into tabbed subviews with route-aware navigation
This commit is contained in:
@@ -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';
|
||||
6
ts_web/elements/network/index.ts
Normal file
6
ts_web/elements/network/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
ts_web/elements/network/ops-view-network.ts
Normal file
119
ts_web/elements/network/ops-view-network.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
5
ts_web/elements/security/index.ts
Normal file
5
ts_web/elements/security/index.ts
Normal 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';
|
||||
120
ts_web/elements/security/ops-view-security-authentication.ts
Normal file
120
ts_web/elements/security/ops-view-security-authentication.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
117
ts_web/elements/security/ops-view-security-blocked.ts
Normal file
117
ts_web/elements/security/ops-view-security-blocked.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
159
ts_web/elements/security/ops-view-security-emailsecurity.ts
Normal file
159
ts_web/elements/security/ops-view-security-emailsecurity.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
171
ts_web/elements/security/ops-view-security-overview.ts
Normal file
171
ts_web/elements/security/ops-view-security-overview.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
114
ts_web/elements/security/ops-view-security.ts
Normal file
114
ts_web/elements/security/ops-view-security.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user