Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77fe9451e | |||
| 7971bd249e | |||
| 6099563acd | |||
| bf4c181026 | |||
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 | |||
| 8ab7343606 | |||
| f04feec273 |
53
changelog.md
53
changelog.md
@@ -1,5 +1,58 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
|
||||||
|
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
|
||||||
|
|
||||||
|
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
|
||||||
|
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
|
||||||
|
|
||||||
|
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
|
||||||
|
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
|
||||||
|
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
|
||||||
|
- add regression tests for certificate domain derivation and force-renew wildcard handling
|
||||||
|
|
||||||
## 2026-04-07 - 13.1.2 - fix(deps)
|
## 2026-04-07 - 13.1.2 - fix(deps)
|
||||||
bump @serve.zone/catalog to ^2.12.3
|
bump @serve.zone/catalog to ^2.12.3
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "13.1.2",
|
"version": "13.5.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"@api.global/typedserver": "^8.4.6",
|
"@api.global/typedserver": "^8.4.6",
|
||||||
"@api.global/typedsocket": "^4.1.2",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@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",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.4.0",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
|||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.1.0
|
version: 7.1.0
|
||||||
'@design.estate/dees-catalog':
|
'@design.estate/dees-catalog':
|
||||||
specifier: ^3.67.1
|
specifier: ^3.68.0
|
||||||
version: 3.67.1(@tiptap/pm@2.27.2)
|
version: 3.68.0(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-element':
|
'@design.estate/dees-element':
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
@@ -353,8 +353,8 @@ packages:
|
|||||||
'@configvault.io/interfaces@1.0.17':
|
'@configvault.io/interfaces@1.0.17':
|
||||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||||
|
|
||||||
'@design.estate/dees-catalog@3.67.1':
|
'@design.estate/dees-catalog@3.68.0':
|
||||||
resolution: {integrity: sha512-8zaVNP70IbcB6pEmLoBxVA5WD0N5gQr12ylTdILtvds6rftKLCI1i2jx4RBztIy4FpZv0wIewJBtRvSUjK8Ysw==}
|
resolution: {integrity: sha512-4jTq/pZmhLFS2jGsF8I+bqLV+P4O9bBAyNtF5Ga1omNCwZFQmITiwPZ2brOGvVFaVrMDi8VdY4I7FTMofF7Diw==}
|
||||||
|
|
||||||
'@design.estate/dees-comms@1.0.30':
|
'@design.estate/dees-comms@1.0.30':
|
||||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||||
@@ -4315,7 +4315,7 @@ snapshots:
|
|||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||||
'@cloudflare/workers-types': 4.20260405.1
|
'@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
|
'@design.estate/dees-comms': 1.0.30
|
||||||
'@push.rocks/lik': 6.4.0
|
'@push.rocks/lik': 6.4.0
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
@@ -4844,7 +4844,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@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:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
@@ -6900,7 +6900,7 @@ snapshots:
|
|||||||
|
|
||||||
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
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-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.0
|
'@design.estate/dees-wcctools': 3.8.0
|
||||||
|
|||||||
196
test/test.cert-renewal.ts
Normal file
196
test/test.cert-renewal.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
|
||||||
|
// Used by the force-renew sibling-propagation logic to identify which routes
|
||||||
|
// share a single underlying ACME certificate.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
|
||||||
|
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
|
||||||
|
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('example.com')).toEqual('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
|
||||||
|
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
|
||||||
|
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
|
||||||
|
// This is the core property: outline.task.vc and *.task.vc must yield
|
||||||
|
// the same cert identity, otherwise sibling propagation cannot work.
|
||||||
|
const subdomain = deriveCertDomainName('outline.task.vc');
|
||||||
|
const wildcard = deriveCertDomainName('*.task.vc');
|
||||||
|
expect(subdomain).toEqual(wildcard);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
|
||||||
|
// Matches smartacme's "deeper domains not supported" behavior.
|
||||||
|
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
|
||||||
|
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
|
||||||
|
expect(deriveCertDomainName('vc')).toBeUndefined();
|
||||||
|
expect(deriveCertDomainName('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
|
||||||
|
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
|
||||||
|
//
|
||||||
|
// This is the regression test for Bug 1: previously the call passed only
|
||||||
|
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
|
||||||
|
// and break every sibling subdomain.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
|
||||||
|
|
||||||
|
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
|
||||||
|
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
|
||||||
|
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
|
||||||
|
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
|
||||||
|
function makeStubOpsServer(opts: {
|
||||||
|
routes: Array<{ name: string; domains: string[] }>;
|
||||||
|
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
|
||||||
|
}) {
|
||||||
|
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
|
||||||
|
const router = {
|
||||||
|
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes = opts.routes.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
match: { domains: r.domains, ports: 443 },
|
||||||
|
action: { type: 'forward', tls: { certificate: 'auto' } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dcRouterRef: any = {
|
||||||
|
smartProxy: {
|
||||||
|
routeManager: { getRoutes: () => routes },
|
||||||
|
},
|
||||||
|
smartAcme: opts.smartAcmeStub,
|
||||||
|
findRouteNamesForDomain: (domain: string) =>
|
||||||
|
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
|
||||||
|
certificateStatusMap: new Map<string, any>(),
|
||||||
|
certProvisionScheduler: null,
|
||||||
|
routeConfigManager: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const opsServerRef: any = {
|
||||||
|
viewRouter: router,
|
||||||
|
adminRouter: router,
|
||||||
|
dcRouterRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { opsServerRef, dcRouterRef, captured };
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
|
||||||
|
const calls: Array<{ domain: string; options: any }> = [];
|
||||||
|
|
||||||
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||||
|
routes: [
|
||||||
|
{ name: 'outline-route', domains: ['outline.task.vc'] },
|
||||||
|
{ name: 'pr-route', domains: ['pr.task.vc'] },
|
||||||
|
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
|
||||||
|
],
|
||||||
|
smartAcmeStub: {
|
||||||
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||||||
|
calls.push({ domain, options });
|
||||||
|
// Return a cert object shaped like SmartacmeCert
|
||||||
|
return {
|
||||||
|
id: 'test-id',
|
||||||
|
domainName: 'task.vc',
|
||||||
|
created: Date.now(),
|
||||||
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||||
|
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||||
|
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||||
|
csr: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
|
||||||
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||||
|
|
||||||
|
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
|
||||||
|
const handler = new CertificateHandler(opsServerRef);
|
||||||
|
|
||||||
|
// Invoke the private reprovision method directly. The Bug 1 fix is verified
|
||||||
|
// by inspecting the captured smartAcme call options regardless of whether
|
||||||
|
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
|
||||||
|
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
|
||||||
|
|
||||||
|
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
|
||||||
|
// The Bug 1 fix is verified by the captured smartAcme call regardless.
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(calls[0].domain).toEqual('outline.task.vc');
|
||||||
|
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
|
||||||
|
const calls: Array<{ domain: string; options: any }> = [];
|
||||||
|
|
||||||
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||||
|
routes: [
|
||||||
|
{ name: 'wildcard-route', domains: ['*.task.vc'] },
|
||||||
|
],
|
||||||
|
smartAcmeStub: {
|
||||||
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||||||
|
calls.push({ domain, options });
|
||||||
|
return {
|
||||||
|
id: 'test-id',
|
||||||
|
domainName: 'task.vc',
|
||||||
|
created: Date.now(),
|
||||||
|
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
|
||||||
|
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
|
||||||
|
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
|
||||||
|
csr: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||||
|
|
||||||
|
const handler = new CertificateHandler(opsServerRef);
|
||||||
|
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
|
||||||
|
|
||||||
|
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(calls[0].domain).toEqual('*.task.vc');
|
||||||
|
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
|
||||||
|
const calls: Array<{ domain: string; options: any }> = [];
|
||||||
|
|
||||||
|
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
|
||||||
|
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
|
||||||
|
smartAcmeStub: {
|
||||||
|
getCertificateForDomain: async (domain: string, options: any) => {
|
||||||
|
calls.push({ domain, options });
|
||||||
|
return {} as any;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
dcRouterRef.smartProxy.updateRoutes = async () => {};
|
||||||
|
|
||||||
|
const handler = new CertificateHandler(opsServerRef);
|
||||||
|
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
|
||||||
|
|
||||||
|
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
|
||||||
|
// applyRoutes and lets the cert provisioning pipeline handle it.
|
||||||
|
expect(calls.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.1.2',
|
version: '13.5.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class OpsServer {
|
|||||||
private sourceProfileHandler!: handlers.SourceProfileHandler;
|
private sourceProfileHandler!: handlers.SourceProfileHandler;
|
||||||
private targetProfileHandler!: handlers.TargetProfileHandler;
|
private targetProfileHandler!: handlers.TargetProfileHandler;
|
||||||
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||||
|
private usersHandler!: handlers.UsersHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -94,6 +95,7 @@ export class OpsServer {
|
|||||||
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
||||||
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||||
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||||
|
this.usersHandler = new handlers.UsersHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ export class AdminHandler {
|
|||||||
role: 'admin',
|
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 {
|
private registerHandlers(): void {
|
||||||
// Admin Login Handler
|
// Admin Login Handler
|
||||||
|
|||||||
@@ -2,6 +2,28 @@ import * as plugins from '../../plugins.js';
|
|||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
|
||||||
|
* @push.rocks/smartacme. Inlined here because the original is `private` on
|
||||||
|
* SmartAcme. The cert identity ('task.vc' for both 'outline.task.vc' and
|
||||||
|
* '*.task.vc') is what AcmeCertDoc is keyed by, so two route domains with
|
||||||
|
* the same identity share the same underlying ACME cert.
|
||||||
|
*
|
||||||
|
* Returns undefined for domains with 4+ levels (matching smartacme's
|
||||||
|
* "deeper domains not supported" behavior) and for malformed inputs.
|
||||||
|
*
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function deriveCertDomainName(domain: string): string | undefined {
|
||||||
|
if (domain.startsWith('*.')) {
|
||||||
|
return domain.slice(2);
|
||||||
|
}
|
||||||
|
const parts = domain.split('.');
|
||||||
|
if (parts.length < 2 || parts.length > 3) return undefined;
|
||||||
|
return parts.slice(-2).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
export class CertificateHandler {
|
export class CertificateHandler {
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
@@ -363,12 +385,34 @@ export class CertificateHandler {
|
|||||||
|
|
||||||
// If forceRenew, order a fresh cert from ACME now so it's already in
|
// If forceRenew, order a fresh cert from ACME now so it's already in
|
||||||
// AcmeCertDoc by the time certProvisionFunction is invoked below.
|
// AcmeCertDoc by the time certProvisionFunction is invoked below.
|
||||||
|
//
|
||||||
|
// includeWildcard: when forcing a non-wildcard subdomain renewal, we still
|
||||||
|
// want the wildcard SAN in the order so the new cert keeps covering every
|
||||||
|
// sibling. Without this, smartacme defaults to includeWildcard: false and
|
||||||
|
// the re-issued cert would have only the base domain as SAN, breaking every
|
||||||
|
// sibling subdomain that was previously covered by the same wildcard cert.
|
||||||
if (forceRenew && dcRouter.smartAcme) {
|
if (forceRenew && dcRouter.smartAcme) {
|
||||||
|
let newCert: plugins.smartacme.Cert;
|
||||||
try {
|
try {
|
||||||
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true });
|
newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
|
||||||
|
forceRenew: true,
|
||||||
|
includeWildcard: !domain.startsWith('*.'),
|
||||||
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
|
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate the freshly-issued cert PEM to every sibling route domain that
|
||||||
|
// shares the same cert identity. Without this, the rust hot-swap (keyed by
|
||||||
|
// exact domain in `loaded_certs`) only fires for the clicked route via the
|
||||||
|
// fire-and-forget cert provisioning path, leaving siblings serving the
|
||||||
|
// stale in-memory cert until the next background reload completes.
|
||||||
|
try {
|
||||||
|
await this.propagateCertToSiblings(domain, newCert);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Best-effort: failure here doesn't undo the cert issuance, just log.
|
||||||
|
logger.log('warn', `Failed to propagate force-renewed cert to siblings of ${domain}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear status map entry so it gets refreshed by the certificate-issued event
|
// Clear status map entry so it gets refreshed by the certificate-issued event
|
||||||
@@ -392,6 +436,86 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a force-renew, walk every route in the smartproxy that resolves to
|
||||||
|
* the same cert identity as `forcedDomain` and write the freshly-issued cert
|
||||||
|
* PEM into ProxyCertDoc for each. This guarantees that the next applyRoutes
|
||||||
|
* → provisionCertificatesViaCallback iteration will hot-swap every sibling's
|
||||||
|
* rust loaded_certs entry with the new (correct) PEM, rather than relying on
|
||||||
|
* the in-memory cert returned by smartacme's per-domain cache.
|
||||||
|
*
|
||||||
|
* Why this is necessary:
|
||||||
|
* Rust's `loaded_certs` is a HashMap<domain, TlsCertConfig>. Each
|
||||||
|
* bridge.loadCertificate(domain, ...) only swaps that one entry. The
|
||||||
|
* fire-and-forget cert provisioning path triggered by updateRoutes does
|
||||||
|
* eventually iterate every auto-cert route, but it returns the cached
|
||||||
|
* (broken pre-fix) cert from smartacme's per-domain mutex. With this
|
||||||
|
* helper, ProxyCertDoc is updated synchronously to the correct PEM before
|
||||||
|
* applyRoutes runs, so even the transient window stays consistent.
|
||||||
|
*/
|
||||||
|
private async propagateCertToSiblings(
|
||||||
|
forcedDomain: string,
|
||||||
|
newCert: plugins.smartacme.Cert,
|
||||||
|
): Promise<void> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
|
const certIdentity = deriveCertDomainName(forcedDomain);
|
||||||
|
if (!certIdentity) return;
|
||||||
|
|
||||||
|
// Collect every route domain whose cert identity matches.
|
||||||
|
const affected = new Set<string>();
|
||||||
|
for (const route of smartProxy.routeManager.getRoutes()) {
|
||||||
|
if (!route.match.domains) continue;
|
||||||
|
const routeDomains = Array.isArray(route.match.domains)
|
||||||
|
? route.match.domains
|
||||||
|
: [route.match.domains];
|
||||||
|
for (const routeDomain of routeDomains) {
|
||||||
|
if (deriveCertDomainName(routeDomain) === certIdentity) {
|
||||||
|
affected.add(routeDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affected.size === 0) return;
|
||||||
|
|
||||||
|
// Parse expiry from PEM (defense-in-depth — same pattern as
|
||||||
|
// ts/classes.dcrouter.ts:988-995 and the existing certStore.save callback).
|
||||||
|
let validUntil = newCert.validUntil;
|
||||||
|
let validFrom: number | undefined;
|
||||||
|
if (newCert.publicKey) {
|
||||||
|
try {
|
||||||
|
const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
|
||||||
|
validUntil = new Date(x509.validTo).getTime();
|
||||||
|
validFrom = new Date(x509.validFrom).getTime();
|
||||||
|
} catch { /* fall back to smartacme's value */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist new cert PEM under each affected route domain
|
||||||
|
for (const routeDomain of affected) {
|
||||||
|
let doc = await ProxyCertDoc.findByDomain(routeDomain);
|
||||||
|
if (!doc) {
|
||||||
|
doc = new ProxyCertDoc();
|
||||||
|
doc.domain = routeDomain;
|
||||||
|
}
|
||||||
|
doc.publicKey = newCert.publicKey;
|
||||||
|
doc.privateKey = newCert.privateKey;
|
||||||
|
doc.ca = '';
|
||||||
|
doc.validUntil = validUntil || 0;
|
||||||
|
doc.validFrom = validFrom || 0;
|
||||||
|
await doc.save();
|
||||||
|
|
||||||
|
// Clear status so the next event refresh shows the new cert
|
||||||
|
dcRouter.certificateStatusMap.delete(routeDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete certificate data for a domain from storage
|
* Delete certificate data for a domain from storage
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ export * from './api-token.handler.js';
|
|||||||
export * from './vpn.handler.js';
|
export * from './vpn.handler.js';
|
||||||
export * from './source-profile.handler.js';
|
export * from './source-profile.handler.js';
|
||||||
export * from './target-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';
|
||||||
30
ts/opsserver/handlers/users.handler.ts
Normal file
30
ts/opsserver/handlers/users.handler.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,5 @@ export * from './api-tokens.js';
|
|||||||
export * from './vpn.js';
|
export * from './vpn.js';
|
||||||
export * from './source-profiles.js';
|
export * from './source-profiles.js';
|
||||||
export * from './target-profiles.js';
|
export * from './target-profiles.js';
|
||||||
export * from './network-targets.js';
|
export * from './network-targets.js';
|
||||||
|
export * from './users.js';
|
||||||
23
ts_interfaces/requests/users.ts
Normal file
23
ts_interfaces/requests/users.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.1.2',
|
version: '13.5.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface IConfigState {
|
|||||||
|
|
||||||
export interface IUiState {
|
export interface IUiState {
|
||||||
activeView: string;
|
activeView: string;
|
||||||
|
activeSubview: string | null;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
autoRefresh: boolean;
|
autoRefresh: boolean;
|
||||||
refreshInterval: number; // milliseconds
|
refreshInterval: number; // milliseconds
|
||||||
@@ -116,16 +117,24 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
|||||||
// Determine initial view from URL path
|
// Determine initial view from URL path
|
||||||
const getInitialView = (): string => {
|
const getInitialView = (): string => {
|
||||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
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 segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
return validViews.includes(view) ? view : 'overview';
|
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>(
|
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||||
'ui',
|
'ui',
|
||||||
{
|
{
|
||||||
activeView: getInitialView(),
|
activeView: getInitialView(),
|
||||||
|
activeSubview: getInitialSubview(),
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
autoRefresh: true,
|
autoRefresh: true,
|
||||||
refreshInterval: 1000, // 1 second
|
refreshInterval: 1000, // 1 second
|
||||||
@@ -242,6 +251,34 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
|||||||
'soft'
|
'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
|
// Actions for state management
|
||||||
interface IActionContext {
|
interface IActionContext {
|
||||||
identity: interfaces.data.IIdentity | null;
|
identity: interfaces.data.IIdentity | null;
|
||||||
@@ -435,43 +472,6 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
}, 100);
|
}, 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 {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
activeView: viewName,
|
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) {
|
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -1944,6 +1973,7 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
if (!context.identity) return;
|
if (!context.identity) return;
|
||||||
const currentView = uiStatePart.getState()!.activeView;
|
const currentView = uiStatePart.getState()!.activeView;
|
||||||
|
const currentSubview = uiStatePart.getState()!.activeSubview;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Always fetch basic stats for dashboard widgets
|
// Always fetch basic stats for dashboard widgets
|
||||||
@@ -2055,8 +2085,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh remote ingress data if on remoteingress view
|
// Refresh remote ingress data if on the Network → Remote Ingress subview
|
||||||
if (currentView === 'remoteingress') {
|
if (currentView === 'network' && currentSubview === 'remoteingress') {
|
||||||
try {
|
try {
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2064,8 +2094,8 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh VPN data if on vpn view
|
// Refresh VPN data if on the Network → VPN subview
|
||||||
if (currentView === 'vpn') {
|
if (currentView === 'network' && currentSubview === 'vpn') {
|
||||||
try {
|
try {
|
||||||
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
await vpnStatePart.dispatchAction(fetchVpnAction, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
2
ts_web/elements/access/index.ts
Normal file
2
ts_web/elements/access/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ops-view-apitokens.js';
|
||||||
|
export * from './ops-view-users.js';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
const { apiTokens } = this.routeState;
|
const { apiTokens } = this.routeState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">API Tokens</dees-heading>
|
<dees-heading level="hr">API Tokens</dees-heading>
|
||||||
|
|
||||||
<div class="apiTokensContainer">
|
<div class="apiTokensContainer">
|
||||||
<dees-table
|
<dees-table
|
||||||
@@ -109,6 +109,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
.data=${apiTokens}
|
.data=${apiTokens}
|
||||||
.dataName=${'token'}
|
.dataName=${'token'}
|
||||||
.searchable=${true}
|
.searchable=${true}
|
||||||
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
||||||
name: token.name,
|
name: token.name,
|
||||||
scopes: this.renderScopePills(token.scopes),
|
scopes: this.renderScopePills(token.scopes),
|
||||||
140
ts_web/elements/access/ops-view-users.ts
Normal file
140
ts_web/elements/access/ops-view-users.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
ts_web/elements/email/index.ts
Normal file
2
ts_web/elements/email/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ops-view-emails.js';
|
||||||
|
export * from './ops-view-email-security.js';
|
||||||
160
ts_web/elements/email/ops-view-email-security.ts
Normal file
160
ts_web/elements/email/ops-view-email-security.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as shared from './shared/index.js';
|
import * as shared from '../shared/index.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Email Operations</dees-heading>
|
<dees-heading level="hr">Email Log</dees-heading>
|
||||||
<div class="viewContainer">
|
<div class="viewContainer">
|
||||||
${this.currentView === 'detail' && this.selectedEmail
|
${this.currentView === 'detail' && this.selectedEmail
|
||||||
? html`
|
? html`
|
||||||
@@ -1,16 +1,9 @@
|
|||||||
export * from './ops-dashboard.js';
|
export * from './ops-dashboard.js';
|
||||||
export * from './ops-view-overview.js';
|
export * from './overview/index.js';
|
||||||
export * from './ops-view-network.js';
|
export * from './network/index.js';
|
||||||
export * from './ops-view-emails.js';
|
export * from './email/index.js';
|
||||||
export * from './ops-view-logs.js';
|
export * from './ops-view-logs.js';
|
||||||
export * from './ops-view-config.js';
|
export * from './access/index.js';
|
||||||
export * from './ops-view-routes.js';
|
export * from './security/index.js';
|
||||||
export * from './ops-view-apitokens.js';
|
|
||||||
export * from './ops-view-security.js';
|
|
||||||
export * from './ops-view-certificates.js';
|
export * from './ops-view-certificates.js';
|
||||||
export * from './ops-view-remoteingress.js';
|
export * from './shared/index.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';
|
|
||||||
|
|||||||
7
ts_web/elements/network/index.ts
Normal file
7
ts_web/elements/network/index.ts
Normal 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';
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
'ops-view-network': OpsViewNetwork;
|
'ops-view-network-activity': OpsViewNetworkActivity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,14 +26,14 @@ interface INetworkRequest {
|
|||||||
route?: string;
|
route?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('ops-view-network')
|
@customElement('ops-view-network-activity')
|
||||||
export class OpsViewNetwork extends DeesElement {
|
export class OpsViewNetworkActivity extends DeesElement {
|
||||||
/** How far back the traffic chart shows */
|
/** How far back the traffic chart shows */
|
||||||
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
/** How often a new data point is added */
|
/** How often a new data point is added */
|
||||||
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
|
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
|
||||||
/** Derived: max data points the buffer holds */
|
/** 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()
|
@state()
|
||||||
accessor statsState = appstate.statsStatePart.getState()!;
|
accessor statsState = appstate.statsStatePart.getState()!;
|
||||||
@@ -50,10 +50,10 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
|
accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
|
||||||
|
|
||||||
// Track if we need to update the chart to avoid unnecessary re-renders
|
// Track if we need to update the chart to avoid unnecessary re-renders
|
||||||
private lastChartUpdate = 0;
|
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 trafficUpdateTimer: any = null;
|
||||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||||
@@ -101,17 +101,17 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
this.updateNetworkData();
|
this.updateNetworkData();
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(statsUnsubscribe);
|
this.rxSubscriptions.push(statsUnsubscribe);
|
||||||
|
|
||||||
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
|
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
|
||||||
this.networkState = state;
|
this.networkState = state;
|
||||||
this.updateNetworkData();
|
this.updateNetworkData();
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(networkUnsubscribe);
|
this.rxSubscriptions.push(networkUnsubscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeTrafficData() {
|
private initializeTrafficData() {
|
||||||
const now = Date.now();
|
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
|
// Initialize with empty data points for both in and out
|
||||||
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => {
|
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,
|
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
|
// Use history as the chart data, keeping the most recent points within the window
|
||||||
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
|
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
|
||||||
@@ -285,8 +285,8 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Network Activity</dees-heading>
|
<dees-heading level="hr">Network Activity</dees-heading>
|
||||||
|
|
||||||
<div class="networkContainer">
|
<div class="networkContainer">
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
${this.renderNetworkStats()}
|
${this.renderNetworkStats()}
|
||||||
@@ -307,7 +307,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
.realtimeMode=${true}
|
.realtimeMode=${true}
|
||||||
.rollingWindow=${OpsViewNetwork.CHART_WINDOW_MS}
|
.rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS}
|
||||||
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
||||||
></dees-chart-area>
|
></dees-chart-area>
|
||||||
|
|
||||||
@@ -347,6 +347,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
heading1="Recent Network Activity"
|
heading1="Recent Network Activity"
|
||||||
heading2="Recent network requests"
|
heading2="Recent network requests"
|
||||||
searchable
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
.pagination=${true}
|
.pagination=${true}
|
||||||
.paginationSize=${50}
|
.paginationSize=${50}
|
||||||
dataName="request"
|
dataName="request"
|
||||||
@@ -357,7 +358,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
|
|
||||||
private async showRequestDetails(request: INetworkRequest) {
|
private async showRequestDetails(request: INetworkRequest) {
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
await DeesModal.createAndShow({
|
await DeesModal.createAndShow({
|
||||||
heading: 'Request Details',
|
heading: 'Request Details',
|
||||||
content: html`
|
content: html`
|
||||||
@@ -400,10 +401,10 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
if (!statusCode) {
|
if (!statusCode) {
|
||||||
return html`<span class="statusBadge warning">N/A</span>`;
|
return html`<span class="statusBadge warning">N/A</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
|
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
|
||||||
statusCode >= 400 ? 'error' : 'warning';
|
statusCode >= 400 ? 'error' : 'warning';
|
||||||
|
|
||||||
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
|
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,26 +427,26 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
let size = bytes;
|
let size = bytes;
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
size /= 1024;
|
size /= 1024;
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatBitsPerSecond(bytesPerSecond: number): string {
|
private formatBitsPerSecond(bytesPerSecond: number): string {
|
||||||
const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits
|
const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits
|
||||||
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
|
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
|
||||||
let size = bitsPerSecond;
|
let size = bitsPerSecond;
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
|
|
||||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||||
size /= 1000; // Use 1000 for bits (not 1024)
|
size /= 1000; // Use 1000 for bits (not 1024)
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,18 +521,9 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
.tiles=${tiles}
|
.tiles=${tiles}
|
||||||
.minTileWidth=${200}
|
.minTileWidth=${200}
|
||||||
.gridActions=${[
|
|
||||||
{
|
|
||||||
name: 'Export Data',
|
|
||||||
iconName: 'lucide:FileOutput',
|
|
||||||
action: async () => {
|
|
||||||
console.log('Export feature coming soon');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
></dees-statsgrid>
|
></dees-statsgrid>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -615,6 +607,8 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
}}
|
}}
|
||||||
heading1="Top Connected IPs"
|
heading1="Top Connected IPs"
|
||||||
heading2="IPs with most active connections and bandwidth"
|
heading2="IPs with most active connections and bandwidth"
|
||||||
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
.pagination=${false}
|
.pagination=${false}
|
||||||
dataName="ip"
|
dataName="ip"
|
||||||
></dees-table>
|
></dees-table>
|
||||||
@@ -665,6 +659,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
heading1="Backend Protocols"
|
heading1="Backend Protocols"
|
||||||
heading2="Auto-detected backend protocols and connection pool health"
|
heading2="Auto-detected backend protocols and connection pool health"
|
||||||
searchable
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
.pagination=${false}
|
.pagination=${false}
|
||||||
dataName="backend"
|
dataName="backend"
|
||||||
></dees-table>
|
></dees-table>
|
||||||
@@ -732,12 +727,12 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
// Only update if connections changed significantly
|
// Only update if connections changed significantly
|
||||||
const newConnectionCount = this.networkState.connections.length;
|
const newConnectionCount = this.networkState.connections.length;
|
||||||
const oldConnectionCount = this.networkRequests.length;
|
const oldConnectionCount = this.networkRequests.length;
|
||||||
|
|
||||||
// Check if we need to update the network requests array
|
// Check if we need to update the network requests array
|
||||||
const shouldUpdate = newConnectionCount !== oldConnectionCount ||
|
const shouldUpdate = newConnectionCount !== oldConnectionCount ||
|
||||||
newConnectionCount === 0 ||
|
newConnectionCount === 0 ||
|
||||||
(newConnectionCount > 0 && this.networkRequests.length === 0);
|
(newConnectionCount > 0 && this.networkRequests.length === 0);
|
||||||
|
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
// Convert connection data to network requests format
|
// Convert connection data to network requests format
|
||||||
if (newConnectionCount > 0) {
|
if (newConnectionCount > 0) {
|
||||||
@@ -760,62 +755,62 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
this.networkRequests = [];
|
this.networkRequests = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load server-side throughput history into chart (once)
|
// Load server-side throughput history into chart (once)
|
||||||
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
|
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
|
||||||
this.loadThroughputHistory();
|
this.loadThroughputHistory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private startTrafficUpdateTimer() {
|
private startTrafficUpdateTimer() {
|
||||||
this.stopTrafficUpdateTimer(); // Clear any existing timer
|
this.stopTrafficUpdateTimer(); // Clear any existing timer
|
||||||
this.trafficUpdateTimer = setInterval(() => {
|
this.trafficUpdateTimer = setInterval(() => {
|
||||||
this.addTrafficDataPoint();
|
this.addTrafficDataPoint();
|
||||||
}, OpsViewNetwork.UPDATE_INTERVAL_MS);
|
}, OpsViewNetworkActivity.UPDATE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addTrafficDataPoint() {
|
private addTrafficDataPoint() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Throttle chart updates to avoid excessive re-renders
|
// Throttle chart updates to avoid excessive re-renders
|
||||||
if (now - this.lastChartUpdate < this.chartUpdateThreshold) {
|
if (now - this.lastChartUpdate < this.chartUpdateThreshold) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const throughput = this.calculateThroughput();
|
const throughput = this.calculateThroughput();
|
||||||
|
|
||||||
// Convert to Mbps (bytes * 8 / 1,000,000)
|
// Convert to Mbps (bytes * 8 / 1,000,000)
|
||||||
const throughputInMbps = (throughput.in * 8) / 1000000;
|
const throughputInMbps = (throughput.in * 8) / 1000000;
|
||||||
const throughputOutMbps = (throughput.out * 8) / 1000000;
|
const throughputOutMbps = (throughput.out * 8) / 1000000;
|
||||||
|
|
||||||
// Add new data points
|
// Add new data points
|
||||||
const timestamp = new Date(now).toISOString();
|
const timestamp = new Date(now).toISOString();
|
||||||
|
|
||||||
const newDataPointIn = {
|
const newDataPointIn = {
|
||||||
x: timestamp,
|
x: timestamp,
|
||||||
y: Math.round(throughputInMbps * 10) / 10
|
y: Math.round(throughputInMbps * 10) / 10
|
||||||
};
|
};
|
||||||
|
|
||||||
const newDataPointOut = {
|
const newDataPointOut = {
|
||||||
x: timestamp,
|
x: timestamp,
|
||||||
y: Math.round(throughputOutMbps * 10) / 10
|
y: Math.round(throughputOutMbps * 10) / 10
|
||||||
};
|
};
|
||||||
|
|
||||||
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
// 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.trafficDataIn.shift();
|
||||||
this.trafficDataOut.shift();
|
this.trafficDataOut.shift();
|
||||||
}
|
}
|
||||||
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
||||||
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
||||||
|
|
||||||
this.lastChartUpdate = now;
|
this.lastChartUpdate = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopTrafficUpdateTimer() {
|
private stopTrafficUpdateTimer() {
|
||||||
if (this.trafficUpdateTimer) {
|
if (this.trafficUpdateTimer) {
|
||||||
clearInterval(this.trafficUpdateTimer);
|
clearInterval(this.trafficUpdateTimer);
|
||||||
this.trafficUpdateTimer = null;
|
this.trafficUpdateTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -64,13 +64,14 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Network Targets</dees-heading>
|
<dees-heading level="hr">Network Targets</dees-heading>
|
||||||
<div class="targetsContainer">
|
<div class="targetsContainer">
|
||||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
<dees-table
|
<dees-table
|
||||||
.heading1=${'Network Targets'}
|
.heading1=${'Network Targets'}
|
||||||
.heading2=${'Reusable host:port destinations for routes'}
|
.heading2=${'Reusable host:port destinations for routes'}
|
||||||
.data=${targets}
|
.data=${targets}
|
||||||
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({
|
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({
|
||||||
Name: target.name,
|
Name: target.name,
|
||||||
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,
|
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Remote Ingress</dees-heading>
|
<dees-heading level="hr">Remote Ingress</dees-heading>
|
||||||
|
|
||||||
${this.riState.newEdgeId ? html`
|
${this.riState.newEdgeId ? html`
|
||||||
<div class="secretDialog">
|
<div class="secretDialog">
|
||||||
@@ -220,6 +220,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
.heading1=${'Edge Nodes'}
|
.heading1=${'Edge Nodes'}
|
||||||
.heading2=${'Manage remote ingress edge registrations'}
|
.heading2=${'Manage remote ingress edge registrations'}
|
||||||
.data=${this.riState.edges}
|
.data=${this.riState.edges}
|
||||||
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
|
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
|
||||||
name: edge.name,
|
name: edge.name,
|
||||||
status: this.getEdgeStatusHtml(edge),
|
status: this.getEdgeStatusHtml(edge),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -200,7 +200,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Route Management</dees-heading>
|
<dees-heading level="hr">Route Management</dees-heading>
|
||||||
|
|
||||||
<div class="routesContainer">
|
<div class="routesContainer">
|
||||||
<dees-statsgrid
|
<dees-statsgrid
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -64,13 +64,14 @@ export class OpsViewSourceProfiles extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Source Profiles</dees-heading>
|
<dees-heading level="hr">Source Profiles</dees-heading>
|
||||||
<div class="profilesContainer">
|
<div class="profilesContainer">
|
||||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
<dees-table
|
<dees-table
|
||||||
.heading1=${'Source Profiles'}
|
.heading1=${'Source Profiles'}
|
||||||
.heading2=${'Reusable source configurations for routes'}
|
.heading2=${'Reusable source configurations for routes'}
|
||||||
.data=${profiles}
|
.data=${profiles}
|
||||||
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
|
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
|
||||||
Name: profile.name,
|
Name: profile.name,
|
||||||
Description: profile.description || '-',
|
Description: profile.description || '-',
|
||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -77,13 +77,14 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Target Profiles</dees-heading>
|
<dees-heading level="hr">Target Profiles</dees-heading>
|
||||||
<div class="profilesContainer">
|
<div class="profilesContainer">
|
||||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||||
<dees-table
|
<dees-table
|
||||||
.heading1=${'Target Profiles'}
|
.heading1=${'Target Profiles'}
|
||||||
.heading2=${'Define what resources VPN clients can access'}
|
.heading2=${'Define what resources VPN clients can access'}
|
||||||
.data=${profiles}
|
.data=${profiles}
|
||||||
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
|
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
|
||||||
Name: profile.name,
|
Name: profile.name,
|
||||||
Description: profile.description || '-',
|
Description: profile.description || '-',
|
||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
state,
|
state,
|
||||||
cssManager,
|
cssManager,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
import { viewHostCss } from './shared/css.js';
|
import { viewHostCss } from '../shared/css.js';
|
||||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">VPN</dees-heading>
|
<dees-heading level="hr">VPN</dees-heading>
|
||||||
<div class="vpnContainer">
|
<div class="vpnContainer">
|
||||||
|
|
||||||
${this.vpnState.newClientConfig ? html`
|
${this.vpnState.newClientConfig ? html`
|
||||||
@@ -305,6 +305,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
.heading1=${'VPN Clients'}
|
.heading1=${'VPN Clients'}
|
||||||
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
||||||
.data=${clients}
|
.data=${clients}
|
||||||
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(client: interfaces.data.IVpnClient) => {
|
.displayFunction=${(client: interfaces.data.IVpnClient) => {
|
||||||
const conn = this.getConnectedInfo(client);
|
const conn = this.getConnectedInfo(client);
|
||||||
let statusHtml;
|
let statusHtml;
|
||||||
@@ -11,22 +11,46 @@ import {
|
|||||||
state,
|
state,
|
||||||
type TemplateResult
|
type TemplateResult
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
import type { IView } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
// Import view components
|
// Top-level / flat views
|
||||||
import { OpsViewOverview } from './ops-view-overview.js';
|
|
||||||
import { OpsViewNetwork } from './ops-view-network.js';
|
|
||||||
import { OpsViewEmails } from './ops-view-emails.js';
|
|
||||||
import { OpsViewLogs } from './ops-view-logs.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 { OpsViewCertificates } from './ops-view-certificates.js';
|
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
|
||||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
// Overview group
|
||||||
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js';
|
import { OpsViewOverview } from './overview/ops-view-overview.js';
|
||||||
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
|
import { OpsViewConfig } from './overview/ops-view-config.js';
|
||||||
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.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')
|
@customElement('ops-dashboard')
|
||||||
export class OpsDashboard extends DeesElement {
|
export class OpsDashboard extends DeesElement {
|
||||||
@@ -37,6 +61,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
|
|
||||||
@state() accessor uiState: appstate.IUiState = {
|
@state() accessor uiState: appstate.IUiState = {
|
||||||
activeView: 'overview',
|
activeView: 'overview',
|
||||||
|
activeSubview: null,
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
autoRefresh: true,
|
autoRefresh: true,
|
||||||
refreshInterval: 1000,
|
refreshInterval: 1000,
|
||||||
@@ -49,27 +74,36 @@ export class OpsDashboard extends DeesElement {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store viewTabs as a property to maintain object references
|
// Store viewTabs as a property to maintain object references (used for === selectedView identity)
|
||||||
private viewTabs = [
|
private viewTabs: ITabbedView[] = [
|
||||||
{
|
{
|
||||||
name: 'Overview',
|
name: 'Overview',
|
||||||
iconName: 'lucide:layoutDashboard',
|
iconName: 'lucide:layoutDashboard',
|
||||||
element: OpsViewOverview,
|
subViews: [
|
||||||
},
|
{ slug: 'stats', name: 'Stats', iconName: 'lucide:activity', element: OpsViewOverview },
|
||||||
{
|
{ slug: 'configuration', name: 'Configuration', iconName: 'lucide:settings', element: OpsViewConfig },
|
||||||
name: 'Configuration',
|
],
|
||||||
iconName: 'lucide:settings',
|
|
||||||
element: OpsViewConfig,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Network',
|
name: 'Network',
|
||||||
iconName: 'lucide: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',
|
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',
|
name: 'Logs',
|
||||||
@@ -77,52 +111,49 @@ export class OpsDashboard extends DeesElement {
|
|||||||
element: OpsViewLogs,
|
element: OpsViewLogs,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Routes',
|
name: 'Access',
|
||||||
iconName: 'lucide:route',
|
iconName: 'lucide:keyRound',
|
||||||
element: OpsViewRoutes,
|
subViews: [
|
||||||
},
|
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
|
||||||
{
|
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
|
||||||
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: 'Security',
|
name: 'Security',
|
||||||
iconName: 'lucide:shield',
|
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',
|
name: 'Certificates',
|
||||||
iconName: 'lucide:badgeCheck',
|
iconName: 'lucide:badgeCheck',
|
||||||
element: OpsViewCertificates,
|
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() {
|
private get globalMessages() {
|
||||||
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
|
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
|
||||||
const config = this.configState.config;
|
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.
|
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
|
||||||
*/
|
*/
|
||||||
private get currentViewTab() {
|
private get currentViewTab(): ITabbedView {
|
||||||
return this.viewTabs.find(t => t.name.toLowerCase() === this.uiState.activeView) || this.viewTabs[0];
|
return (
|
||||||
|
this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
document.title = 'DCRouter OpsServer';
|
document.title = 'DCRouter OpsServer';
|
||||||
|
|
||||||
// Subscribe to login state
|
// Subscribe to login state
|
||||||
const loginSubscription = appstate.loginStatePart
|
const loginSubscription = appstate.loginStatePart
|
||||||
.select((stateArg) => stateArg)
|
.select((stateArg) => stateArg)
|
||||||
@@ -161,7 +194,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(loginSubscription);
|
this.rxSubscriptions.push(loginSubscription);
|
||||||
|
|
||||||
// Subscribe to config state (for global warnings)
|
// Subscribe to config state (for global warnings)
|
||||||
const configSubscription = appstate.configStatePart
|
const configSubscription = appstate.configStatePart
|
||||||
.select((stateArg) => stateArg)
|
.select((stateArg) => stateArg)
|
||||||
@@ -176,38 +209,27 @@ export class OpsDashboard extends DeesElement {
|
|||||||
.subscribe((uiState) => {
|
.subscribe((uiState) => {
|
||||||
this.uiState = uiState;
|
this.uiState = uiState;
|
||||||
// Sync appdash view when state changes (e.g., from URL navigation)
|
// 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);
|
this.rxSubscriptions.push(uiSubscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync the dees-simple-appdash view selection with the current state.
|
* 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;
|
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||||
if (!appDash) return;
|
if (!appDash) return;
|
||||||
|
|
||||||
const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName);
|
const targetView = this.findViewBySlug(viewSlug, subviewSlug);
|
||||||
if (!targetTab) return;
|
if (!targetView) return;
|
||||||
|
|
||||||
// Check if we need to switch (avoid unnecessary updates)
|
if (appDash.selectedView === targetView) return;
|
||||||
if (appDash.selectedView === targetTab) return;
|
|
||||||
|
|
||||||
// Update the selected view programmatically
|
// Use loadView to update both selectedView and the mounted element.
|
||||||
appDash.selectedView = targetTab;
|
// It will dispatch view-select; our handler skips when state already matches.
|
||||||
|
appDash.loadView(targetView);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
@@ -249,7 +271,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
public async firstUpdated() {
|
public async firstUpdated() {
|
||||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||||
simpleLogin.addEventListener('login', (e: Event) => {
|
simpleLogin.addEventListener('login', (e: Event) => {
|
||||||
// Handle logout event
|
// Handle login event
|
||||||
const detail = (e as CustomEvent).detail;
|
const detail = (e as CustomEvent).detail;
|
||||||
this.login(detail.data.username, detail.data.password);
|
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');
|
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
|
||||||
if (appDash) {
|
if (appDash) {
|
||||||
appDash.addEventListener('view-select', (e: Event) => {
|
appDash.addEventListener('view-select', (e: Event) => {
|
||||||
const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
|
const view = (e as CustomEvent).detail.view as ITabbedView;
|
||||||
// Use router for navigation instead of direct state update
|
const parent = this.findParent(view);
|
||||||
appRouter.navigateToView(viewName);
|
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
|
// Handle logout event
|
||||||
@@ -306,12 +343,12 @@ export class OpsDashboard extends DeesElement {
|
|||||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||||
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
|
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
|
||||||
form.setStatus('pending', 'Logging in...');
|
form.setStatus('pending', 'Logging in...');
|
||||||
|
|
||||||
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.identity) {
|
if (state.identity) {
|
||||||
console.log('Login successful');
|
console.log('Login successful');
|
||||||
this.loginState = state;
|
this.loginState = state;
|
||||||
@@ -325,4 +362,4 @@ export class OpsDashboard extends DeesElement {
|
|||||||
form!.reset();
|
form!.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
const { summary } = this.certState;
|
const { summary } = this.certState;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Certificates</dees-heading>
|
<dees-heading level="hr">Certificates</dees-heading>
|
||||||
|
|
||||||
<div class="certificatesContainer">
|
<div class="certificatesContainer">
|
||||||
${this.renderStatsTiles(summary)}
|
${this.renderStatsTiles(summary)}
|
||||||
@@ -228,6 +228,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<dees-table
|
<dees-table
|
||||||
.data=${this.certState.certificates}
|
.data=${this.certState.certificates}
|
||||||
|
.showColumnFilters=${true}
|
||||||
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
||||||
Domain: cert.domain,
|
Domain: cert.domain,
|
||||||
Routes: this.renderRoutePills(cert.routeNames),
|
Routes: this.renderRoutePills(cert.routeNames),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Logs</dees-heading>
|
<dees-heading level="hr">Logs</dees-heading>
|
||||||
|
|
||||||
<dees-chart-log
|
<dees-chart-log
|
||||||
.label=${'Application Logs'}
|
.label=${'Application Logs'}
|
||||||
|
|||||||
@@ -1,453 +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)}
|
|
||||||
.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 }))}
|
|
||||||
.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}
|
|
||||||
.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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
ts_web/elements/overview/index.ts
Normal file
2
ts_web/elements/overview/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ops-view-overview.js';
|
||||||
|
export * from './ops-view-config.js';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as shared from './shared/index.js';
|
import * as shared from '../shared/index.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
import { appRouter } from '../router.js';
|
import { appRouter } from '../../router.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Configuration</dees-heading>
|
<dees-heading level="hr">Configuration</dees-heading>
|
||||||
|
|
||||||
${this.configState.isLoading
|
${this.configState.isLoading
|
||||||
? html`
|
? 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."
|
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
|
||||||
@navigate=${(e: CustomEvent) => {
|
@navigate=${(e: CustomEvent) => {
|
||||||
if (e.detail?.view) {
|
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[] = [
|
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`
|
return html`
|
||||||
@@ -181,7 +181,7 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions: IConfigSectionAction[] = [
|
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`
|
return html`
|
||||||
@@ -305,7 +305,7 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const actions: IConfigSectionAction[] = [
|
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`
|
return html`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import * as shared from './shared/index.js';
|
import * as shared from '../shared/index.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../../appstate.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return html`
|
return html`
|
||||||
<dees-heading level="2">Overview</dees-heading>
|
<dees-heading level="hr">Stats</dees-heading>
|
||||||
|
|
||||||
${this.statsState.isLoading ? html`
|
${this.statsState.isLoading ? html`
|
||||||
<div class="loadingMessage">
|
<div class="loadingMessage">
|
||||||
3
ts_web/elements/security/index.ts
Normal file
3
ts_web/elements/security/index.ts
Normal 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';
|
||||||
121
ts_web/elements/security/ops-view-security-authentication.ts
Normal file
121
ts_web/elements/security/ops-view-security-authentication.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
ts_web/elements/security/ops-view-security-blocked.ts
Normal file
118
ts_web/elements/security/ops-view-security-blocked.ts
Normal 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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
ts_web/elements/security/ops-view-security-overview.ts
Normal file
172
ts_web/elements/security/ops-view-security-overview.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,37 @@ import * as appstate from './appstate.js';
|
|||||||
|
|
||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
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 {
|
class AppRouter {
|
||||||
private router: InstanceType<typeof SmartRouter>;
|
private router: InstanceType<typeof SmartRouter>;
|
||||||
@@ -25,12 +53,27 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupRoutes(): void {
|
private setupRoutes(): void {
|
||||||
for (const view of validViews) {
|
// Flat views
|
||||||
|
for (const view of flatViews) {
|
||||||
this.router.on(`/${view}`, async () => {
|
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
|
// Root redirect
|
||||||
this.router.on('/', async () => {
|
this.router.on('/', async () => {
|
||||||
this.navigateTo('/overview');
|
this.navigateTo('/overview');
|
||||||
@@ -42,7 +85,9 @@ class AppRouter {
|
|||||||
if (this.suppressStateUpdate) return;
|
if (this.suppressStateUpdate) return;
|
||||||
|
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const expectedPath = `/${uiState.activeView}`;
|
const expectedPath = uiState.activeSubview
|
||||||
|
? `/${uiState.activeView}/${uiState.activeSubview}`
|
||||||
|
: `/${uiState.activeView}`;
|
||||||
|
|
||||||
if (currentPath !== expectedPath) {
|
if (currentPath !== expectedPath) {
|
||||||
this.suppressStateUpdate = true;
|
this.suppressStateUpdate = true;
|
||||||
@@ -57,25 +102,38 @@ class AppRouter {
|
|||||||
|
|
||||||
if (!path || path === '/') {
|
if (!path || path === '/') {
|
||||||
this.router.pushUrl('/overview');
|
this.router.pushUrl('/overview');
|
||||||
} else {
|
return;
|
||||||
const segments = path.split('/').filter(Boolean);
|
}
|
||||||
const view = segments[0];
|
|
||||||
|
|
||||||
if (validViews.includes(view as TValidView)) {
|
const segments = path.split('/').filter(Boolean);
|
||||||
this.updateViewState(view as TValidView);
|
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 {
|
} 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;
|
this.suppressStateUpdate = true;
|
||||||
const currentState = appstate.uiStatePart.getState()!;
|
const currentState = appstate.uiStatePart.getState()!;
|
||||||
if (currentState.activeView !== view) {
|
if (currentState.activeView !== view || currentState.activeSubview !== subview) {
|
||||||
appstate.uiStatePart.setState({
|
appstate.uiStatePart.setState({
|
||||||
...currentState,
|
...currentState,
|
||||||
activeView: view,
|
activeView: view,
|
||||||
|
activeSubview: subview,
|
||||||
} as appstate.IUiState);
|
} as appstate.IUiState);
|
||||||
}
|
}
|
||||||
this.suppressStateUpdate = false;
|
this.suppressStateUpdate = false;
|
||||||
@@ -85,11 +143,17 @@ class AppRouter {
|
|||||||
this.router.pushUrl(path);
|
this.router.pushUrl(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public navigateToView(view: string): void {
|
public navigateToView(view: string, subview?: string): void {
|
||||||
if (validViews.includes(view as TValidView)) {
|
if (!isValidView(view)) {
|
||||||
this.navigateTo(`/${view}`);
|
|
||||||
} else {
|
|
||||||
this.navigateTo('/overview');
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user