Compare commits

...

14 Commits

Author SHA1 Message Date
e77fe9451e v13.5.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 09:01:08 +00:00
7971bd249e feat(opsserver-access): add admin user listing to the access dashboard 2026-04-08 09:01:08 +00:00
6099563acd v13.4.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 08:29:30 +00:00
bf4c181026 fix(repo): no changes to commit 2026-04-08 08:29:30 +00:00
d9d12427d3 v13.4.1
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 08:28:00 +00:00
91aa9a7228 fix(repo): no changes to commit 2026-04-08 08:28:00 +00:00
877356b247 v13.4.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 08:24:55 +00:00
2325f01cde feat(web-ui): reorganize dashboard views into grouped navigation with new email, access, and network subviews 2026-04-08 08:24:55 +00:00
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
3058706d2a v13.2.1
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:12:16 +00:00
0e4d6a3c0c fix(project): no changes to commit 2026-04-08 07:12:16 +00:00
39 changed files with 1231 additions and 736 deletions

View File

@@ -1,5 +1,44 @@
# Changelog
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
add admin user listing to the access dashboard
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
- update access routing to include the new users subview and improve related table filtering and section headings
## 2026-04-08 - 13.4.2 - fix(repo)
no changes to commit
## 2026-04-08 - 13.4.1 - fix(repo)
no changes to commit
## 2026-04-08 - 13.4.0 - feat(web-ui)
reorganize dashboard views into grouped navigation with new email, access, and network subviews
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
## 2026-04-08 - 13.3.0 - feat(web-ui)
reorganize network and security views into tabbed subviews with route-aware navigation
- 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
## 2026-04-08 - 13.2.0 - feat(ops-ui)
add column filters to operations tables across admin views

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.2.0",
"version": "13.5.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -35,7 +35,7 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.67.1",
"@design.estate/dees-catalog": "^3.68.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",

14
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
specifier: ^3.67.1
version: 3.67.1(@tiptap/pm@2.27.2)
specifier: ^3.68.0
version: 3.68.0(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -353,8 +353,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.67.1':
resolution: {integrity: sha512-8zaVNP70IbcB6pEmLoBxVA5WD0N5gQr12ylTdILtvds6rftKLCI1i2jx4RBztIy4FpZv0wIewJBtRvSUjK8Ysw==}
'@design.estate/dees-catalog@3.68.0':
resolution: {integrity: sha512-4jTq/pZmhLFS2jGsF8I+bqLV+P4O9bBAyNtF5Ga1omNCwZFQmITiwPZ2brOGvVFaVrMDi8VdY4I7FTMofF7Diw==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -4315,7 +4315,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260405.1
'@design.estate/dees-catalog': 3.67.1(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.68.0(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
@@ -4844,7 +4844,7 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.67.1(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.68.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
@@ -6900,7 +6900,7 @@ snapshots:
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.67.1(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.68.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0

View File

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

View File

@@ -32,6 +32,7 @@ export class OpsServer {
private sourceProfileHandler!: handlers.SourceProfileHandler;
private targetProfileHandler!: handlers.TargetProfileHandler;
private networkTargetHandler!: handlers.NetworkTargetHandler;
private usersHandler!: handlers.UsersHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -94,6 +95,7 @@ export class OpsServer {
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
this.usersHandler = new handlers.UsersHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -52,6 +52,18 @@ export class AdminHandler {
role: 'admin',
});
}
/**
* Return a safe projection of the users Map — excludes password fields.
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public listUsers(): Array<{ id: string; username: string; role: string }> {
return Array.from(this.users.values()).map((user) => ({
id: user.id,
username: user.username,
role: user.role,
}));
}
private registerHandlers(): void {
// Admin Login Handler

View File

@@ -12,4 +12,5 @@ export * from './api-token.handler.js';
export * from './vpn.handler.js';
export * from './source-profile.handler.js';
export * from './target-profile.handler.js';
export * from './network-target.handler.js';
export * from './network-target.handler.js';
export * from './users.handler.js';

View File

@@ -0,0 +1,30 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
* so admin middleware enforces auth + role check before the handler runs.
* User data is owned by AdminHandler; this handler just exposes a safe
* projection of it via TypedRequest.
*/
export class UsersHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
}
private registerHandlers(): void {
const router = this.opsServerRef.adminRouter;
// List users (admin-only, read-only)
router.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
'listUsers',
async (_dataArg) => {
const users = this.opsServerRef.adminHandler.listUsers();
return { users };
},
),
);
}
}

View File

@@ -12,4 +12,5 @@ export * from './api-tokens.js';
export * from './vpn.js';
export * from './source-profiles.js';
export * from './target-profiles.js';
export * from './network-targets.js';
export * from './network-targets.js';
export * from './users.js';

View File

@@ -0,0 +1,23 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
/**
* List all OpsServer users (admin-only, read-only).
* Deliberately omits password/secret fields from the response.
*/
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListUsers
> {
method: 'listUsers';
request: {
identity: authInterfaces.IIdentity;
};
response: {
users: Array<{
id: string;
username: string;
role: string;
}>;
};
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.2.0',
version: '13.5.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', 'email', 'logs', 'access', 'security', 'certificates'];
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
@@ -242,6 +251,34 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
'soft'
);
// ============================================================================
// Users State (read-only list of OpsServer user accounts)
// ============================================================================
export interface IUser {
id: string;
username: string;
role: string;
}
export interface IUsersState {
users: IUser[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const usersStatePart = await appState.getStatePart<IUsersState>(
'users',
{
users: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft',
);
// Actions for state management
interface IActionContext {
identity: interfaces.data.IIdentity | null;
@@ -435,43 +472,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(() => {
routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
}, 100);
}
// If switching to remoteingress view, ensure we fetch edge data
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
setTimeout(() => {
remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
}, 100);
}
// 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,
@@ -1784,6 +1784,35 @@ export const fetchApiTokensAction = routeManagementStatePart.createAction(async
}
});
// Users (read-only list)
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListUsers
>('/typedrequest', 'listUsers');
const response = await request.fire({
identity: context.identity,
});
return {
...currentState,
users: response.users,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch users',
};
}
});
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1944,6 +1973,7 @@ async function dispatchCombinedRefreshActionInner() {
const context = getActionContext();
if (!context.identity) return;
const currentView = uiStatePart.getState()!.activeView;
const currentSubview = uiStatePart.getState()!.activeSubview;
try {
// Always fetch basic stats for dashboard widgets
@@ -2055,8 +2085,8 @@ async function dispatchCombinedRefreshActionInner() {
}
}
// Refresh remote ingress data if on remoteingress view
if (currentView === 'remoteingress') {
// Refresh remote ingress data if on the Network → Remote Ingress subview
if (currentView === 'network' && currentSubview === 'remoteingress') {
try {
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
} catch (error) {
@@ -2064,8 +2094,8 @@ async function dispatchCombinedRefreshActionInner() {
}
}
// Refresh VPN data if on vpn view
if (currentView === 'vpn') {
// Refresh VPN data if on the Network → VPN subview
if (currentView === 'network' && currentSubview === 'vpn') {
try {
await vpnStatePart.dispatchAction(fetchVpnAction, null);
} catch (error) {

View File

@@ -0,0 +1,2 @@
export * from './ops-view-apitokens.js';
export * from './ops-view-users.js';

View File

@@ -1,6 +1,6 @@
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import {
DeesElement,
@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
const { apiTokens } = this.routeState;
return html`
<dees-heading level="2">API Tokens</dees-heading>
<dees-heading level="hr">API Tokens</dees-heading>
<div class="apiTokensContainer">
<dees-table

View File

@@ -0,0 +1,140 @@
import * as appstate from '../../appstate.js';
import { viewHostCss } from '../shared/css.js';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ops-view-users')
export class OpsViewUsers extends DeesElement {
@state() accessor usersState: appstate.IUsersState = {
users: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
@state() accessor loginState: appstate.ILoginState = {
identity: null,
isLoggedIn: false,
};
constructor() {
super();
const usersSub = appstate.usersStatePart
.select((s) => s)
.subscribe((usersState) => {
this.usersState = usersState;
});
this.rxSubscriptions.push(usersSub);
const loginSub = appstate.loginStatePart
.select((s) => s)
.subscribe((loginState) => {
this.loginState = loginState;
// Re-fetch users when user logs in (fixes race condition where
// the view is created before authentication completes)
if (loginState.isLoggedIn) {
appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.usersContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.roleBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.roleBadge.admin {
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
}
.roleBadge.user {
background: ${cssManager.bdTheme('#e0f2fe', '#0c4a6e')};
color: ${cssManager.bdTheme('#075985', '#7dd3fc')};
}
.sessionBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.userIdCell {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
font-size: 11px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
`,
];
public render(): TemplateResult {
const { users } = this.usersState;
const currentUserId = this.loginState.identity?.userId;
return html`
<dees-heading level="2">Users</dees-heading>
<div class="usersContainer">
<dees-table
.heading1=${'Users'}
.heading2=${'OpsServer user accounts'}
.data=${users}
.dataName=${'user'}
.searchable=${true}
.showColumnFilters=${true}
.displayFunction=${(user: appstate.IUser) => ({
ID: html`<span class="userIdCell">${user.id}</span>`,
Username: user.username,
Role: this.renderRoleBadge(user.role),
Session: user.id === currentUserId
? html`<span class="sessionBadge">current</span>`
: '',
})}
></dees-table>
</div>
`;
}
private renderRoleBadge(role: string): TemplateResult {
const cls = role === 'admin' ? 'admin' : 'user';
return html`<span class="roleBadge ${cls}">${role}</span>`;
}
async firstUpdated() {
if (this.loginState.isLoggedIn) {
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './ops-view-emails.js';
export * from './ops-view-email-security.js';

View File

@@ -0,0 +1,160 @@
import * as appstate from '../../appstate.js';
import { viewHostCss } from '../shared/css.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-email-security': OpsViewEmailSecurity;
}
}
@customElement('ops-view-email-security')
export class OpsViewEmailSecurity 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,
viewHostCss,
css`
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

@@ -1,8 +1,8 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import * as plugins from '../../plugins.js';
import * as appstate from '../../appstate.js';
import * as shared from '../shared/index.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
declare global {
interface HTMLElementTagNameMap {
@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
public render() {
return html`
<dees-heading level="2">Email Operations</dees-heading>
<dees-heading level="hr">Email Log</dees-heading>
<div class="viewContainer">
${this.currentView === 'detail' && this.selectedEmail
? html`

View File

@@ -1,16 +1,9 @@
export * from './ops-dashboard.js';
export * from './ops-view-overview.js';
export * from './ops-view-network.js';
export * from './ops-view-emails.js';
export * from './overview/index.js';
export * from './network/index.js';
export * from './email/index.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 './access/index.js';
export * from './security/index.js';
export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';
export * from './ops-view-sourceprofiles.js';
export * from './ops-view-networktargets.js';
export * from './ops-view-targetprofiles.js';
export * from './shared/index.js';
export * from './shared/index.js';

View File

@@ -0,0 +1,7 @@
export * from './ops-view-network-activity.js';
export * from './ops-view-routes.js';
export * from './ops-view-sourceprofiles.js';
export * from './ops-view-networktargets.js';
export * from './ops-view-targetprofiles.js';
export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';

View File

@@ -1,12 +1,12 @@
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 { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-network': OpsViewNetwork;
'ops-view-network-activity': OpsViewNetworkActivity;
}
}
@@ -26,14 +26,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 +50,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 +101,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 +148,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);
@@ -285,8 +285,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 +307,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 +323,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>`,
@@ -348,6 +347,7 @@ export class OpsViewNetwork extends DeesElement {
heading1="Recent Network Activity"
heading2="Recent network requests"
searchable
.showColumnFilters=${true}
.pagination=${true}
.paginationSize=${50}
dataName="request"
@@ -358,7 +358,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 +401,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 +427,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 +521,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 +595,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 {
@@ -617,6 +607,8 @@ export class OpsViewNetwork extends DeesElement {
}}
heading1="Top Connected IPs"
heading2="IPs with most active connections and bandwidth"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ip"
></dees-table>
@@ -632,7 +624,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, '');
@@ -668,6 +659,7 @@ export class OpsViewNetwork extends DeesElement {
heading1="Backend Protocols"
heading2="Auto-detected backend protocols and connection pool health"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="backend"
></dees-table>
@@ -735,12 +727,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 +755,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

@@ -7,9 +7,9 @@ import {
state,
cssManager,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
@@ -64,7 +64,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

@@ -7,9 +7,9 @@ import {
state,
cssManager,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
];
return html`
<dees-heading level="2">Remote Ingress</dees-heading>
<dees-heading level="hr">Remote Ingress</dees-heading>
${this.riState.newEdgeId ? html`
<div class="secretDialog">

View File

@@ -1,6 +1,6 @@
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
import {
@@ -200,7 +200,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,9 @@ import {
state,
cssManager,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
@@ -64,7 +64,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,10 @@ import {
state,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import * as plugins from '../../plugins.js';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
@@ -77,7 +77,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

@@ -7,10 +7,10 @@ import {
state,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import * as plugins from '../../plugins.js';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
/**
@@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
];
return html`
<dees-heading level="2">VPN</dees-heading>
<dees-heading level="hr">VPN</dees-heading>
<div class="vpnContainer">
${this.vpnState.newClientConfig ? html`

View File

@@ -11,22 +11,46 @@ import {
state,
type TemplateResult
} from '@design.estate/dees-element';
import type { IView } from '@design.estate/dees-catalog';
// Import view components
import { OpsViewOverview } from './ops-view-overview.js';
import { OpsViewNetwork } from './ops-view-network.js';
import { OpsViewEmails } from './ops-view-emails.js';
// Top-level / flat views
import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewConfig } from './ops-view-config.js';
import { OpsViewRoutes } from './ops-view-routes.js';
import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './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';
// Overview group
import { OpsViewOverview } from './overview/ops-view-overview.js';
import { OpsViewConfig } from './overview/ops-view-config.js';
// Network group
import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
import { OpsViewRoutes } from './network/ops-view-routes.js';
import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
import { OpsViewRemoteIngress } from './network/ops-view-remoteingress.js';
import { OpsViewVpn } from './network/ops-view-vpn.js';
// Email group
import { OpsViewEmails } from './email/ops-view-emails.js';
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
// Access group
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
import { OpsViewUsers } from './access/ops-view-users.js';
// Security group
import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js';
import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js';
import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js';
/**
* Extended IView with explicit URL slug. Without an explicit `slug`, the URL
* slug is derived from `name.toLowerCase().replace(/\s+/g, '')`.
*/
interface ITabbedView extends IView {
slug?: string;
subViews?: ITabbedView[];
}
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -37,6 +61,7 @@ export class OpsDashboard extends DeesElement {
@state() accessor uiState: appstate.IUiState = {
activeView: 'overview',
activeSubview: null,
sidebarCollapsed: false,
autoRefresh: true,
refreshInterval: 1000,
@@ -49,27 +74,36 @@ export class OpsDashboard extends DeesElement {
error: null,
};
// Store viewTabs as a property to maintain object references
private viewTabs = [
// Store viewTabs as a property to maintain object references (used for === selectedView identity)
private viewTabs: ITabbedView[] = [
{
name: 'Overview',
iconName: 'lucide:layoutDashboard',
element: OpsViewOverview,
},
{
name: 'Configuration',
iconName: 'lucide:settings',
element: OpsViewConfig,
subViews: [
{ slug: 'stats', name: 'Stats', iconName: 'lucide:activity', element: OpsViewOverview },
{ slug: 'configuration', name: 'Configuration', iconName: 'lucide:settings', element: OpsViewConfig },
],
},
{
name: 'Network',
iconName: 'lucide:network',
element: OpsViewNetwork,
subViews: [
{ slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
{ slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
{ slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
{ slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
{ slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
{ slug: 'remoteingress', name: 'Remote Ingress', iconName: 'lucide:globe', element: OpsViewRemoteIngress },
{ slug: 'vpn', name: 'VPN', iconName: 'lucide:shield', element: OpsViewVpn },
],
},
{
name: 'Emails',
name: 'Email',
iconName: 'lucide:mail',
element: OpsViewEmails,
subViews: [
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
],
},
{
name: 'Logs',
@@ -77,52 +111,49 @@ export class OpsDashboard extends DeesElement {
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',
element: OpsViewApiTokens,
name: 'Access',
iconName: 'lucide:keyRound',
subViews: [
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
],
},
{
name: 'Security',
iconName: 'lucide:shield',
element: OpsViewSecurity,
subViews: [
{ slug: 'overview', name: 'Overview', iconName: 'lucide:eye', element: OpsViewSecurityOverview },
{ slug: 'blocked', name: 'Blocked IPs', iconName: 'lucide:shieldBan', element: OpsViewSecurityBlocked },
{ slug: 'authentication', name: 'Authentication', iconName: 'lucide:lock', element: OpsViewSecurityAuthentication },
],
},
{
name: 'Certificates',
iconName: 'lucide:badgeCheck',
element: OpsViewCertificates,
},
{
name: 'RemoteIngress',
iconName: 'lucide:globe',
element: OpsViewRemoteIngress,
},
{
name: 'VPN',
iconName: 'lucide:shield',
element: OpsViewVpn,
},
];
/** URL slug for a view (explicit `slug` field, or lowercased name with spaces stripped). */
private slugFor(view: ITabbedView): string {
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '');
}
/** Find the parent group of a subview, or undefined for top-level views. */
private findParent(view: ITabbedView): ITabbedView | undefined {
return this.viewTabs.find((v) => v.subViews?.includes(view));
}
/** Look up a view (or subview) by its URL slug pair. */
private findViewBySlug(viewSlug: string, subSlug: string | null): ITabbedView | undefined {
const top = this.viewTabs.find((v) => this.slugFor(v) === viewSlug);
if (!top) return undefined;
if (subSlug && top.subViews) {
return top.subViews.find((sv) => this.slugFor(sv) === subSlug) ?? top;
}
return top;
}
private get globalMessages() {
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
const config = this.configState.config;
@@ -138,17 +169,19 @@ export class OpsDashboard extends DeesElement {
}
/**
* Get the current view tab based on the UI state's activeView.
* Get the current view tab based on the UI state's activeView/activeSubview.
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
*/
private get currentViewTab() {
return this.viewTabs.find(t => t.name.toLowerCase() === this.uiState.activeView) || this.viewTabs[0];
private get currentViewTab(): ITabbedView {
return (
this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0]
);
}
constructor() {
super();
document.title = 'DCRouter OpsServer';
// Subscribe to login state
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg)
@@ -161,7 +194,7 @@ export class OpsDashboard extends DeesElement {
}
});
this.rxSubscriptions.push(loginSubscription);
// Subscribe to config state (for global warnings)
const configSubscription = appstate.configStatePart
.select((stateArg) => stateArg)
@@ -176,38 +209,27 @@ export class OpsDashboard extends DeesElement {
.subscribe((uiState) => {
this.uiState = uiState;
// Sync appdash view when state changes (e.g., from URL navigation)
this.syncAppdashView(uiState.activeView);
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
});
this.rxSubscriptions.push(uiSubscription);
}
/**
* Sync the dees-simple-appdash view selection with the current state.
* This is needed when the URL changes and we need to update the UI.
* This is needed when the URL changes externally (back/forward, deep link).
*/
private syncAppdashView(viewName: string): void {
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash) return;
const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName);
if (!targetTab) return;
const targetView = this.findViewBySlug(viewSlug, subviewSlug);
if (!targetView) return;
// Check if we need to switch (avoid unnecessary updates)
if (appDash.selectedView === targetTab) return;
if (appDash.selectedView === targetView) return;
// Update the selected view programmatically
appDash.selectedView = targetTab;
// Update the displayed content
const content = appDash.shadowRoot?.querySelector('.appcontent');
if (content) {
if (appDash.currentView) {
appDash.currentView.remove();
}
const view = new targetTab.element();
content.appendChild(view);
appDash.currentView = view;
}
// Use loadView to update both selectedView and the mounted element.
// It will dispatch view-select; our handler skips when state already matches.
appDash.loadView(targetView);
}
public static styles = [
@@ -249,7 +271,7 @@ export class OpsDashboard extends DeesElement {
public async firstUpdated() {
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
simpleLogin.addEventListener('login', (e: Event) => {
// Handle logout event
// Handle login event
const detail = (e as CustomEvent).detail;
this.login(detail.data.username, detail.data.password);
});
@@ -258,9 +280,24 @@ export class OpsDashboard extends DeesElement {
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
if (appDash) {
appDash.addEventListener('view-select', (e: Event) => {
const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
// Use router for navigation instead of direct state update
appRouter.navigateToView(viewName);
const view = (e as CustomEvent).detail.view as ITabbedView;
const parent = this.findParent(view);
const currentState = appstate.uiStatePart.getState();
if (parent) {
const parentSlug = this.slugFor(parent);
const subSlug = this.slugFor(view);
// Skip if already on this exact subview — preserves URL on initial mount
if (currentState?.activeView === parentSlug && currentState?.activeSubview === subSlug) {
return;
}
appRouter.navigateToView(parentSlug, subSlug);
} else {
const slug = this.slugFor(view);
if (currentState?.activeView === slug && !currentState?.activeSubview) {
return;
}
appRouter.navigateToView(slug);
}
});
// Handle logout event
@@ -306,12 +343,12 @@ export class OpsDashboard extends DeesElement {
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
form.setStatus('pending', 'Logging in...');
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
username,
password,
});
if (state.identity) {
console.log('Login successful');
this.loginState = state;
@@ -325,4 +362,4 @@ export class OpsDashboard extends DeesElement {
form!.reset();
}
}
}
}

View File

@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
const { summary } = this.certState;
return html`
<dees-heading level="2">Certificates</dees-heading>
<dees-heading level="hr">Certificates</dees-heading>
<div class="certificatesContainer">
${this.renderStatsTiles(summary)}

View File

@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
public render() {
return html`
<dees-heading level="2">Logs</dees-heading>
<dees-heading level="hr">Logs</dees-heading>
<dees-chart-log
.label=${'Application Logs'}

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,2 @@
export * from './ops-view-overview.js';
export * from './ops-view-config.js';

View File

@@ -1,7 +1,7 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import * as plugins from '../../plugins.js';
import * as shared from '../shared/index.js';
import * as appstate from '../../appstate.js';
import { appRouter } from '../../router.js';
import {
DeesElement,
@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
public render() {
return html`
<dees-heading level="2">Configuration</dees-heading>
<dees-heading level="hr">Configuration</dees-heading>
${this.configState.isLoading
? html`
@@ -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`
@@ -181,7 +181,7 @@ export class OpsViewConfig extends DeesElement {
}
const actions: IConfigSectionAction[] = [
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'email', subview: 'log' } },
];
return html`
@@ -305,7 +305,7 @@ export class OpsViewConfig extends DeesElement {
];
const actions: IConfigSectionAction[] = [
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } },
];
return html`

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import * as plugins from '../../plugins.js';
import * as shared from '../shared/index.js';
import * as appstate from '../../appstate.js';
import {
DeesElement,
@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
public render() {
return html`
<dees-heading level="2">Overview</dees-heading>
<dees-heading level="hr">Stats</dees-heading>
${this.statsState.isLoading ? html`
<div class="loadingMessage">

View File

@@ -0,0 +1,3 @@
export * from './ops-view-security-overview.js';
export * from './ops-view-security-blocked.js';
export * from './ops-view-security-authentication.js';

View File

@@ -0,0 +1,121 @@
import * as appstate from '../../appstate.js';
import { viewHostCss } from '../shared/css.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,
viewHostCss,
css`
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,118 @@
import * as appstate from '../../appstate.js';
import { viewHostCss } from '../shared/css.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,
viewHostCss,
css`
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,172 @@
import * as appstate from '../../appstate.js';
import { viewHostCss } from '../shared/css.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,
viewHostCss,
css`
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

@@ -3,9 +3,37 @@ 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 = ['logs', 'certificates'] as const;
export type TValidView = typeof validViews[number];
// Tabbed views and their valid subviews
const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
email: ['log', 'security'] as const,
access: ['apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const,
};
// Default subview when user visits the bare parent URL
const defaultSubview: Record<string, string> = {
overview: 'stats',
network: 'activity',
email: 'log',
access: 'apitokens',
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 +53,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 +85,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 +102,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 +143,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}`);
}
}