Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac3a42fc41 | |||
| c23f16149c | |||
| 529a4bae00 | |||
| 49606ae007 | |||
| 31a6510d8b | |||
| b5e760ae07 | |||
| ea32babaac | |||
| a4ddedaf46 | |||
| 7ce09c53ca | |||
| 69be2295f1 |
44
changelog.md
44
changelog.md
@@ -1,5 +1,49 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-17 - 6.9.0 - feat(certificates)
|
||||||
|
add certificate import, export, and deletion support (server handlers, request types, and UI)
|
||||||
|
|
||||||
|
- Add typed request handlers in opsserver: deleteCertificate, exportCertificate, importCertificate (ts/opsserver/handlers/certificate.handler.ts)
|
||||||
|
- Implement deleteCertificate/exportCertificate/importCertificate functions handling storage paths, in-memory status map updates, backoff clearing, validation, and SmartAcme-compatible /certs/ and /proxy-certs/ formats
|
||||||
|
- Add request interfaces IReq_DeleteCertificate, IReq_ExportCertificate, IReq_ImportCertificate (ts_interfaces/requests/certificate.ts)
|
||||||
|
- Add web app actions deleteCertificateAction, importCertificateAction and fetchCertificateExport to call new typed requests (ts_web/appstate.ts)
|
||||||
|
- Update certificates UI to support Import, Export, and Delete actions and add downloadJsonFile helper (ts_web/elements/ops-view-certificates.ts)
|
||||||
|
|
||||||
|
## 2026-02-17 - 6.8.0 - feat(remote-ingress)
|
||||||
|
support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI
|
||||||
|
|
||||||
|
- Add autoDerivePorts flag to IRemoteIngress with default true and migration to set existing stored edges to autoDerivePorts = true
|
||||||
|
- RemoteIngressManager: getEffectiveListenPorts now returns the union of manual + derived ports when autoDerivePorts is enabled; added getPortBreakdown to return manual vs derived lists
|
||||||
|
- API handlers updated: create/update requests accept autoDerivePorts; responses now include effectiveListenPorts, manualPorts, and derivedPorts (secrets still masked)
|
||||||
|
- Web UI updated: create and edit dialogs include an Auto-derive checkbox; port badges now visually distinguish manual vs derived ports; added updateRemoteIngressAction
|
||||||
|
- Non-breaking change: new field defaults to true so existing behavior is preserved
|
||||||
|
|
||||||
|
## 2026-02-17 - 6.7.0 - feat(remote-ingress)
|
||||||
|
Support auto-derived effective listen ports, make listenPorts optional, add toggle action and refine remote ingress creation/management UI
|
||||||
|
|
||||||
|
- Add effectiveListenPorts?: number[] to IRemoteIngress interface (present in API responses)
|
||||||
|
- Make createRemoteIngressAction.listenPorts optional and update creation modal to allow empty ports (auto-derived)
|
||||||
|
- Add toggleRemoteIngressAction to enable/disable remote ingress edges and wire up Enable/Disable row/context-menu actions
|
||||||
|
- Update getPortsHtml to prefer manual listenPorts, fall back to effectiveListenPorts, show '(auto)' when derived and 'none' when no ports
|
||||||
|
- Standardize UI actions to use inRow/contextmenu and actionFunc signatures; update create modal to use explicit Cancel/Create menu options and collect form data programmatically
|
||||||
|
|
||||||
|
## 2026-02-17 - 6.6.1 - fix(icons)
|
||||||
|
standardize icon identifiers to lucide-prefixed names across operational views
|
||||||
|
|
||||||
|
- Replaced legacy/ambiguous icon names with 'lucide:...' identifiers in four UI modules: ts_web/elements/ops-view-certificates.ts, ops-view-network.ts, ops-view-overview.ts, and ops-view-security.ts.
|
||||||
|
- Updated common action/menu icons (e.g. arrowsRotate -> lucide:RefreshCw, magnifyingGlass -> lucide:Search, copy -> lucide:Copy, fileExport -> lucide:FileOutput).
|
||||||
|
- Mapped dashboard/tile icons to lucide equivalents (e.g. server -> lucide:Server, networkWired/sitemap -> lucide:Network, download/upload -> lucide:Download/Upload, microchip/memory -> lucide:Cpu/MemoryStick).
|
||||||
|
- Normalized alert and status icons to lucide names (e.g. triangleExclamation -> lucide:TriangleAlert, shield/userShield -> lucide:Shield/ShieldCheck, clock/clockRotateLeft -> lucide:Clock/History).
|
||||||
|
|
||||||
|
## 2026-02-17 - 6.6.0 - feat(remoteingress)
|
||||||
|
derive effective remote ingress listen ports from route configs and expose them via ops API
|
||||||
|
|
||||||
|
- Derive listen ports from SmartProxy route configs with remoteIngress.enabled; supports optional edgeFilter to target edges by id or tags.
|
||||||
|
- Add RemoteIngressManager.setRoutes(), derivePortsForEdge(), and getEffectiveListenPorts() which falls back to manual listenPorts when present.
|
||||||
|
- dcrouter now supplies route configs to RemoteIngressManager during initialization and when updating SmartProxy configuration to keep derived ports in sync.
|
||||||
|
- Ops API now returns effectiveListenPorts for edges; createRemoteIngress.listenPorts is optional and createEdge defaults listenPorts to an empty array.
|
||||||
|
- Bump dependency @serve.zone/remoteingress to ^3.0.4 to align types/behavior.
|
||||||
|
|
||||||
## 2026-02-16 - 6.5.0 - feat(ops-view-remoteingress)
|
## 2026-02-16 - 6.5.0 - feat(ops-view-remoteingress)
|
||||||
add 'Create Edge Node' header action to remote ingress table and remove duplicate createNewAction
|
add 'Create Edge Node' header action to remote ingress table and remove duplicate createNewAction
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "6.5.0",
|
"version": "6.9.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": {
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"@push.rocks/smartstate": "^2.0.30",
|
"@push.rocks/smartstate": "^2.0.30",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^3.0.2",
|
"@serve.zone/remoteingress": "^3.0.4",
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
"@tsclass/tsclass": "^9.3.0",
|
||||||
"lru-cache": "^11.2.6",
|
"lru-cache": "^11.2.6",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -96,8 +96,8 @@ importers:
|
|||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
'@serve.zone/remoteingress':
|
'@serve.zone/remoteingress':
|
||||||
specifier: ^3.0.2
|
specifier: ^3.0.4
|
||||||
version: 3.0.2
|
version: 3.0.4
|
||||||
'@tsclass/tsclass':
|
'@tsclass/tsclass':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0
|
version: 9.3.0
|
||||||
@@ -1340,8 +1340,8 @@ packages:
|
|||||||
'@serve.zone/interfaces@5.3.0':
|
'@serve.zone/interfaces@5.3.0':
|
||||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||||
|
|
||||||
'@serve.zone/remoteingress@3.0.2':
|
'@serve.zone/remoteingress@3.0.4':
|
||||||
resolution: {integrity: sha512-FnwNVO0Dn9xuNv0t81u6pjCizSeCyMjkRKm6wN5qycCdGFoJmFbBamHqV01KtK1KcgDTd7LX+PowSqKReNrBGw==}
|
resolution: {integrity: sha512-ZD66Y8fvW7SjealziOlhaC7+Y/3gxQkZlj/X8rxgVHmGhlc/YQtn6H6LNVazbM88BXK5ns004Qo6ongAB6Ho0Q==}
|
||||||
|
|
||||||
'@sindresorhus/is@5.6.0':
|
'@sindresorhus/is@5.6.0':
|
||||||
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
|
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
|
||||||
@@ -6830,7 +6830,7 @@ snapshots:
|
|||||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||||
'@tsclass/tsclass': 9.3.0
|
'@tsclass/tsclass': 9.3.0
|
||||||
|
|
||||||
'@serve.zone/remoteingress@3.0.2':
|
'@serve.zone/remoteingress@3.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/qenv': 6.1.3
|
'@push.rocks/qenv': 6.1.3
|
||||||
'@push.rocks/smartrust': 1.2.1
|
'@push.rocks/smartrust': 1.2.1
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '6.5.0',
|
version: '6.9.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -955,10 +955,15 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
this.options.smartProxyConfig = config;
|
this.options.smartProxyConfig = config;
|
||||||
|
|
||||||
|
// Update routes on RemoteIngressManager so derived ports stay in sync
|
||||||
|
if (this.remoteIngressManager && config.routes) {
|
||||||
|
this.remoteIngressManager.setRoutes(config.routes as any[]);
|
||||||
|
}
|
||||||
|
|
||||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
||||||
await this.setupSmartProxy();
|
await this.setupSmartProxy();
|
||||||
|
|
||||||
console.log('SmartProxy configuration updated');
|
console.log('SmartProxy configuration updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1587,6 +1592,10 @@ export class DcRouter {
|
|||||||
this.remoteIngressManager = new RemoteIngressManager(this.storageManager);
|
this.remoteIngressManager = new RemoteIngressManager(this.storageManager);
|
||||||
await this.remoteIngressManager.initialize();
|
await this.remoteIngressManager.initialize();
|
||||||
|
|
||||||
|
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
||||||
|
const currentRoutes = this.options.smartProxyConfig?.routes || [];
|
||||||
|
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||||
|
|
||||||
// Create and start the tunnel manager
|
// Create and start the tunnel manager
|
||||||
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
||||||
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
|
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
|
||||||
|
|||||||
@@ -42,6 +42,36 @@ export class CertificateHandler {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Delete certificate
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||||
|
'deleteCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.deleteCertificate(dataArg.domain);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export certificate
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||||
|
'exportCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.exportCertificate(dataArg.domain);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import certificate
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||||
|
'importCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.importCertificate(dataArg.cert);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -324,4 +354,154 @@ export class CertificateHandler {
|
|||||||
|
|
||||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete certificate data for a domain from storage
|
||||||
|
*/
|
||||||
|
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Delete from all known storage paths
|
||||||
|
const paths = [
|
||||||
|
`/proxy-certs/${domain}`,
|
||||||
|
`/proxy-certs/${cleanDomain}`,
|
||||||
|
`/certs/${cleanDomain}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
try {
|
||||||
|
await dcRouter.storageManager.delete(path);
|
||||||
|
} catch {
|
||||||
|
// Path may not exist — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear from in-memory status map
|
||||||
|
dcRouter.certificateStatusMap.delete(domain);
|
||||||
|
|
||||||
|
// Clear backoff info
|
||||||
|
if (dcRouter.certProvisionScheduler) {
|
||||||
|
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: `Certificate data deleted for '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export certificate data for a domain as ICert-shaped JSON
|
||||||
|
*/
|
||||||
|
private async exportCertificate(domain: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
cert?: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Try SmartAcme /certs/ path first (has full ICert fields)
|
||||||
|
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
||||||
|
if (certData && certData.publicKey && certData.privateKey) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cert: {
|
||||||
|
id: certData.id || plugins.crypto.randomUUID(),
|
||||||
|
domainName: certData.domainName || domain,
|
||||||
|
created: certData.created || Date.now(),
|
||||||
|
validUntil: certData.validUntil || 0,
|
||||||
|
privateKey: certData.privateKey,
|
||||||
|
publicKey: certData.publicKey,
|
||||||
|
csr: certData.csr || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try /proxy-certs/ with original domain
|
||||||
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
||||||
|
if (!certData || !certData.publicKey) {
|
||||||
|
// Try with clean domain
|
||||||
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certData && certData.publicKey && certData.privateKey) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cert: {
|
||||||
|
id: plugins.crypto.randomUUID(),
|
||||||
|
domainName: domain,
|
||||||
|
created: certData.validFrom || Date.now(),
|
||||||
|
validUntil: certData.validUntil || 0,
|
||||||
|
privateKey: certData.privateKey,
|
||||||
|
publicKey: certData.publicKey,
|
||||||
|
csr: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: `No certificate data found for '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a certificate from ICert-shaped JSON
|
||||||
|
*/
|
||||||
|
private async importCertificate(cert: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
// Validate PEM content
|
||||||
|
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
|
||||||
|
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
|
||||||
|
}
|
||||||
|
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
|
||||||
|
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Save to /certs/ (SmartAcme-compatible path)
|
||||||
|
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
|
||||||
|
id: cert.id,
|
||||||
|
domainName: cert.domainName,
|
||||||
|
created: cert.created,
|
||||||
|
validUntil: cert.validUntil,
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
csr: cert.csr || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also save to /proxy-certs/ (proxy-cert format)
|
||||||
|
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
|
||||||
|
domain: cert.domainName,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
ca: undefined,
|
||||||
|
validUntil: cert.validUntil,
|
||||||
|
validFrom: cert.created,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update in-memory status map
|
||||||
|
dcRouter.certificateStatusMap.set(cert.domainName, {
|
||||||
|
status: 'valid',
|
||||||
|
source: 'static',
|
||||||
|
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
|
||||||
|
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
|
||||||
|
routeNames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ export class RemoteIngressHandler {
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { edges: [] };
|
return { edges: [] };
|
||||||
}
|
}
|
||||||
// Return edges without secrets
|
// Return edges without secrets, enriched with effective listen ports and breakdown
|
||||||
const edges = manager.getAllEdges().map((e) => ({
|
const edges = manager.getAllEdges().map((e) => {
|
||||||
...e,
|
const breakdown = manager.getPortBreakdown(e);
|
||||||
secret: '********', // Never expose secrets via API
|
return {
|
||||||
}));
|
...e,
|
||||||
|
secret: '********', // Never expose secrets via API
|
||||||
|
effectiveListenPorts: manager.getEffectiveListenPorts(e),
|
||||||
|
manualPorts: breakdown.manual,
|
||||||
|
derivedPorts: breakdown.derived,
|
||||||
|
};
|
||||||
|
});
|
||||||
return { edges };
|
return { edges };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -47,8 +53,9 @@ export class RemoteIngressHandler {
|
|||||||
|
|
||||||
const edge = await manager.createEdge(
|
const edge = await manager.createEdge(
|
||||||
dataArg.name,
|
dataArg.name,
|
||||||
dataArg.listenPorts,
|
dataArg.listenPorts || [],
|
||||||
dataArg.tags,
|
dataArg.tags,
|
||||||
|
dataArg.autoDerivePorts ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync allowed edges with the hub
|
// Sync allowed edges with the hub
|
||||||
@@ -101,6 +108,7 @@ export class RemoteIngressHandler {
|
|||||||
const edge = await manager.updateEdge(dataArg.id, {
|
const edge = await manager.updateEdge(dataArg.id, {
|
||||||
name: dataArg.name,
|
name: dataArg.name,
|
||||||
listenPorts: dataArg.listenPorts,
|
listenPorts: dataArg.listenPorts,
|
||||||
|
autoDerivePorts: dataArg.autoDerivePorts,
|
||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
tags: dataArg.tags,
|
tags: dataArg.tags,
|
||||||
});
|
});
|
||||||
@@ -114,7 +122,17 @@ export class RemoteIngressHandler {
|
|||||||
await tunnelManager.syncAllowedEdges();
|
await tunnelManager.syncAllowedEdges();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, edge: { ...edge, secret: '********' } };
|
const breakdown = manager.getPortBreakdown(edge);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
edge: {
|
||||||
|
...edge,
|
||||||
|
secret: '********',
|
||||||
|
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
|
||||||
|
manualPorts: breakdown.manual,
|
||||||
|
derivedPorts: breakdown.derived,
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
import type { StorageManager } from '../storage/classes.storagemanager.js';
|
||||||
import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
|
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
|
|
||||||
const STORAGE_PREFIX = '/remote-ingress/';
|
const STORAGE_PREFIX = '/remote-ingress/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
|
||||||
|
*/
|
||||||
|
function extractPorts(portRange: number | number[] | Array<{ from: number; to: number }>): number[] {
|
||||||
|
const ports = new Set<number>();
|
||||||
|
if (typeof portRange === 'number') {
|
||||||
|
ports.add(portRange);
|
||||||
|
} else if (Array.isArray(portRange)) {
|
||||||
|
for (const entry of portRange) {
|
||||||
|
if (typeof entry === 'number') {
|
||||||
|
ports.add(entry);
|
||||||
|
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
|
||||||
|
for (let p = entry.from; p <= entry.to; p++) {
|
||||||
|
ports.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ports].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages CRUD for remote ingress edge registrations.
|
* Manages CRUD for remote ingress edge registrations.
|
||||||
* Persists edge configs via StorageManager and provides
|
* Persists edge configs via StorageManager and provides
|
||||||
@@ -12,6 +33,7 @@ const STORAGE_PREFIX = '/remote-ingress/';
|
|||||||
export class RemoteIngressManager {
|
export class RemoteIngressManager {
|
||||||
private storageManager: StorageManager;
|
private storageManager: StorageManager;
|
||||||
private edges: Map<string, IRemoteIngress> = new Map();
|
private edges: Map<string, IRemoteIngress> = new Map();
|
||||||
|
private routes: IDcRouterRouteConfig[] = [];
|
||||||
|
|
||||||
constructor(storageManager: StorageManager) {
|
constructor(storageManager: StorageManager) {
|
||||||
this.storageManager = storageManager;
|
this.storageManager = storageManager;
|
||||||
@@ -25,18 +47,87 @@ export class RemoteIngressManager {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
|
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
|
||||||
if (edge) {
|
if (edge) {
|
||||||
|
// Migration: old edges without autoDerivePorts default to true
|
||||||
|
if ((edge as any).autoDerivePorts === undefined) {
|
||||||
|
edge.autoDerivePorts = true;
|
||||||
|
await this.storageManager.setJSON(key, edge);
|
||||||
|
}
|
||||||
this.edges.set(edge.id, edge);
|
this.edges.set(edge.id, edge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the current route configs for port derivation.
|
||||||
|
*/
|
||||||
|
public setRoutes(routes: IDcRouterRouteConfig[]): void {
|
||||||
|
this.routes = routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
|
||||||
|
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
|
||||||
|
* When edgeFilter is absent, the route applies to all edges.
|
||||||
|
*/
|
||||||
|
public derivePortsForEdge(edgeId: string, edgeTags?: string[]): number[] {
|
||||||
|
const ports = new Set<number>();
|
||||||
|
|
||||||
|
for (const route of this.routes) {
|
||||||
|
if (!route.remoteIngress?.enabled) continue;
|
||||||
|
|
||||||
|
// Apply edge filter if present
|
||||||
|
const filter = route.remoteIngress.edgeFilter;
|
||||||
|
if (filter && filter.length > 0) {
|
||||||
|
const idMatch = filter.includes(edgeId);
|
||||||
|
const tagMatch = edgeTags?.some((tag) => filter.includes(tag)) ?? false;
|
||||||
|
if (!idMatch && !tagMatch) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ports from the route match
|
||||||
|
if (route.match?.ports) {
|
||||||
|
for (const p of extractPorts(route.match.ports)) {
|
||||||
|
ports.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ports].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective listen ports for an edge.
|
||||||
|
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
|
||||||
|
*/
|
||||||
|
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
|
||||||
|
const manualPorts = edge.listenPorts || [];
|
||||||
|
const shouldDerive = edge.autoDerivePorts !== false;
|
||||||
|
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
|
||||||
|
const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags);
|
||||||
|
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get manual and derived port breakdown for an edge (used in API responses).
|
||||||
|
* Derived ports exclude any ports already present in the manual list.
|
||||||
|
*/
|
||||||
|
public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } {
|
||||||
|
const manual = edge.listenPorts || [];
|
||||||
|
const shouldDerive = edge.autoDerivePorts !== false;
|
||||||
|
if (!shouldDerive) return { manual, derived: [] };
|
||||||
|
const manualSet = new Set(manual);
|
||||||
|
const allDerived = this.derivePortsForEdge(edge.id, edge.tags);
|
||||||
|
const derived = allDerived.filter((p) => !manualSet.has(p));
|
||||||
|
return { manual, derived };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new edge registration.
|
* Create a new edge registration.
|
||||||
*/
|
*/
|
||||||
public async createEdge(
|
public async createEdge(
|
||||||
name: string,
|
name: string,
|
||||||
listenPorts: number[],
|
listenPorts: number[] = [],
|
||||||
tags?: string[],
|
tags?: string[],
|
||||||
|
autoDerivePorts: boolean = true,
|
||||||
): Promise<IRemoteIngress> {
|
): Promise<IRemoteIngress> {
|
||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const secret = plugins.crypto.randomBytes(32).toString('hex');
|
const secret = plugins.crypto.randomBytes(32).toString('hex');
|
||||||
@@ -48,6 +139,7 @@ export class RemoteIngressManager {
|
|||||||
secret,
|
secret,
|
||||||
listenPorts,
|
listenPorts,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
autoDerivePorts,
|
||||||
tags: tags || [],
|
tags: tags || [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -80,6 +172,7 @@ export class RemoteIngressManager {
|
|||||||
updates: {
|
updates: {
|
||||||
name?: string;
|
name?: string;
|
||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
|
autoDerivePorts?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
},
|
},
|
||||||
@@ -91,6 +184,7 @@ export class RemoteIngressManager {
|
|||||||
|
|
||||||
if (updates.name !== undefined) edge.name = updates.name;
|
if (updates.name !== undefined) edge.name = updates.name;
|
||||||
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
|
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
|
||||||
|
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
|
||||||
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
|
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
|
||||||
if (updates.tags !== undefined) edge.tags = updates.tags;
|
if (updates.tags !== undefined) edge.tags = updates.tags;
|
||||||
edge.updatedAt = Date.now();
|
edge.updatedAt = Date.now();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stored remote ingress edge registration.
|
* A stored remote ingress edge registration.
|
||||||
*/
|
*/
|
||||||
@@ -7,9 +9,17 @@ export interface IRemoteIngress {
|
|||||||
secret: string;
|
secret: string;
|
||||||
listenPorts: number[];
|
listenPorts: number[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
|
||||||
|
autoDerivePorts: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
/** Effective ports (union of manual + derived) — only present in API responses. */
|
||||||
|
effectiveListenPorts?: number[];
|
||||||
|
/** Ports explicitly set by the user — only present in API responses. */
|
||||||
|
manualPorts?: number[];
|
||||||
|
/** Ports auto-derived from route configs — only present in API responses. */
|
||||||
|
derivedPorts?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,3 +33,25 @@ export interface IRemoteIngressStatus {
|
|||||||
lastHeartbeat: number | null;
|
lastHeartbeat: number | null;
|
||||||
connectedAt: number | null;
|
connectedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level remote ingress configuration.
|
||||||
|
* When attached to a route, signals that traffic for this route
|
||||||
|
* should be accepted from remote edge nodes.
|
||||||
|
*/
|
||||||
|
export interface IRouteRemoteIngress {
|
||||||
|
/** Whether this route receives traffic from edge nodes */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Optional filter: only edges whose id or tags match get this route's ports.
|
||||||
|
* When absent, the route applies to all edges. */
|
||||||
|
edgeFilter?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended route config used within dcrouter.
|
||||||
|
* Adds the optional `remoteIngress` property to SmartProxy's IRouteConfig.
|
||||||
|
* SmartProxy ignores unknown properties at runtime.
|
||||||
|
*/
|
||||||
|
export type IDcRouterRouteConfig = IRouteConfig & {
|
||||||
|
remoteIngress?: IRouteRemoteIngress;
|
||||||
|
};
|
||||||
|
|||||||
@@ -74,3 +74,68 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete a certificate by domain
|
||||||
|
export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteCertificate
|
||||||
|
> {
|
||||||
|
method: 'deleteCertificate';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
domain: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a certificate as ICert JSON
|
||||||
|
export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ExportCertificate
|
||||||
|
> {
|
||||||
|
method: 'exportCertificate';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
domain: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
cert?: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import a certificate from ICert JSON
|
||||||
|
export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ImportCertificate
|
||||||
|
> {
|
||||||
|
method: 'importCertificate';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
cert: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
name: string;
|
name: string;
|
||||||
listenPorts: number[];
|
listenPorts?: number[];
|
||||||
|
autoDerivePorts?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -57,6 +58,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
|
autoDerivePorts?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '6.5.0',
|
version: '6.9.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -780,6 +780,80 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const deleteCertificateAction = certificateStatePart.createAction<string>(
|
||||||
|
async (statePartArg, domain) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteCertificate
|
||||||
|
>('/typedrequest', 'deleteCertificate');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fetch overview after deletion
|
||||||
|
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
|
return statePartArg.getState();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete certificate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const importCertificateAction = certificateStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
}>(
|
||||||
|
async (statePartArg, cert) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ImportCertificate
|
||||||
|
>('/typedrequest', 'importCertificate');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
cert,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fetch overview after import
|
||||||
|
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
|
return statePartArg.getState();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to import certificate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function fetchCertificateExport(domain: string) {
|
||||||
|
const context = getActionContext();
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ExportCertificate
|
||||||
|
>('/typedrequest', 'exportCertificate');
|
||||||
|
|
||||||
|
return request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Remote Ingress Actions
|
// Remote Ingress Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -821,7 +895,8 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn
|
|||||||
|
|
||||||
export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||||
name: string;
|
name: string;
|
||||||
listenPorts: number[];
|
listenPorts?: number[];
|
||||||
|
autoDerivePorts?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
@@ -836,6 +911,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
identity: context.identity,
|
identity: context.identity,
|
||||||
name: dataArg.name,
|
name: dataArg.name,
|
||||||
listenPorts: dataArg.listenPorts,
|
listenPorts: dataArg.listenPorts,
|
||||||
|
autoDerivePorts: dataArg.autoDerivePorts,
|
||||||
tags: dataArg.tags,
|
tags: dataArg.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -883,6 +959,40 @@ export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<str
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
listenPorts?: number[];
|
||||||
|
autoDerivePorts?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateRemoteIngress
|
||||||
|
>('/typedrequest', 'updateRemoteIngress');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
listenPorts: dataArg.listenPorts,
|
||||||
|
autoDerivePorts: dataArg.autoDerivePorts,
|
||||||
|
tags: dataArg.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||||
|
return statePartArg.getState();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to update edge',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
|
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
|
||||||
async (statePartArg, edgeId) => {
|
async (statePartArg, edgeId) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
@@ -924,6 +1034,34 @@ export const clearNewEdgeSecretAction = remoteIngressStatePart.createAction(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>(async (statePartArg, dataArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_UpdateRemoteIngress
|
||||||
|
>('/typedrequest', 'updateRemoteIngress');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id: dataArg.id,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||||
|
return statePartArg.getState();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to toggle edge',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Combined refresh action for efficient polling
|
// Combined refresh action for efficient polling
|
||||||
async function dispatchCombinedRefreshAction() {
|
async function dispatchCombinedRefreshAction() {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
title: 'Total Certificates',
|
title: 'Total Certificates',
|
||||||
value: summary.total,
|
value: summary.total,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'shieldHalved',
|
icon: 'lucide:ShieldHalf',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -183,7 +183,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
title: 'Valid',
|
title: 'Valid',
|
||||||
value: summary.valid,
|
value: summary.valid,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'check',
|
icon: 'lucide:Check',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -191,7 +191,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
title: 'Expiring Soon',
|
title: 'Expiring Soon',
|
||||||
value: summary.expiring,
|
value: summary.expiring,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'clock',
|
icon: 'lucide:Clock',
|
||||||
color: '#f59e0b',
|
color: '#f59e0b',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -199,7 +199,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
title: 'Failed / Expired',
|
title: 'Failed / Expired',
|
||||||
value: summary.failed + summary.expired,
|
value: summary.failed + summary.expired,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'triangleExclamation',
|
icon: 'lucide:TriangleAlert',
|
||||||
color: '#ef4444',
|
color: '#ef4444',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -211,7 +211,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
.gridActions=${[
|
.gridActions=${[
|
||||||
{
|
{
|
||||||
name: 'Refresh',
|
name: 'Refresh',
|
||||||
iconName: 'arrowsRotate',
|
iconName: 'lucide:RefreshCw',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await appstate.certificateStatePart.dispatchAction(
|
await appstate.certificateStatePart.dispatchAction(
|
||||||
appstate.fetchCertificateOverviewAction,
|
appstate.fetchCertificateOverviewAction,
|
||||||
@@ -241,9 +241,64 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
: '',
|
: '',
|
||||||
})}
|
})}
|
||||||
.dataActions=${[
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Import Certificate',
|
||||||
|
iconName: 'lucide:upload',
|
||||||
|
type: ['header'],
|
||||||
|
actionFunc: async () => {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Import Certificate',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-fileupload
|
||||||
|
key="certJsonFile"
|
||||||
|
label="Certificate JSON (.tsclass.cert.json)"
|
||||||
|
accept=".json"
|
||||||
|
.multiple=${false}
|
||||||
|
required
|
||||||
|
></dees-input-fileupload>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Import',
|
||||||
|
iconName: 'lucide:upload',
|
||||||
|
action: async (modal) => {
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
try {
|
||||||
|
const form = modal.shadowRoot.querySelector('dees-form') as any;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
const files = formData.certJsonFile;
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
DeesToast.show({ message: 'Please select a JSON file.', type: 'warning', duration: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = files[0];
|
||||||
|
const text = await file.text();
|
||||||
|
const cert = JSON.parse(text);
|
||||||
|
if (!cert.domainName || !cert.publicKey || !cert.privateKey) {
|
||||||
|
DeesToast.show({ message: 'Invalid cert JSON: missing domainName, publicKey, or privateKey.', type: 'error', duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await appstate.certificateStatePart.dispatchAction(
|
||||||
|
appstate.importCertificateAction,
|
||||||
|
cert,
|
||||||
|
);
|
||||||
|
DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 });
|
||||||
|
modal.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
DeesToast.show({ message: `Import failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Reprovision',
|
name: 'Reprovision',
|
||||||
iconName: 'arrowsRotate',
|
iconName: 'lucide:RefreshCw',
|
||||||
type: ['inRow'],
|
type: ['inRow'],
|
||||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
const cert = actionData.item;
|
const cert = actionData.item;
|
||||||
@@ -268,9 +323,66 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Export',
|
||||||
|
iconName: 'lucide:download',
|
||||||
|
type: ['contextmenu'],
|
||||||
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
const cert = actionData.item;
|
||||||
|
try {
|
||||||
|
const response = await appstate.fetchCertificateExport(cert.domain);
|
||||||
|
if (response.success && response.cert) {
|
||||||
|
const safeDomain = cert.domain.replace(/\*/g, '_wildcard');
|
||||||
|
this.downloadJsonFile(`${safeDomain}.tsclass.cert.json`, response.cert);
|
||||||
|
DeesToast.show({ message: `Certificate exported for ${cert.domain}`, type: 'success', duration: 3000 });
|
||||||
|
} else {
|
||||||
|
DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
DeesToast.show({ message: `Export failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash-2',
|
||||||
|
type: ['contextmenu'],
|
||||||
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
|
const cert = actionData.item;
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Delete Certificate: ${cert.domain}`,
|
||||||
|
content: html`
|
||||||
|
<div style="padding: 20px; font-size: 14px;">
|
||||||
|
<p>Are you sure you want to delete the certificate data for <strong>${cert.domain}</strong>?</p>
|
||||||
|
<p style="color: #f59e0b; margin-top: 12px;">Note: The certificate may remain in proxy memory until the next restart or reprovisioning.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash-2',
|
||||||
|
action: async (modal) => {
|
||||||
|
try {
|
||||||
|
await appstate.certificateStatePart.dispatchAction(
|
||||||
|
appstate.deleteCertificateAction,
|
||||||
|
cert.domain,
|
||||||
|
);
|
||||||
|
DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 });
|
||||||
|
modal.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
DeesToast.show({ message: `Delete failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'View Details',
|
name: 'View Details',
|
||||||
iconName: 'magnifyingGlass',
|
iconName: 'lucide:Search',
|
||||||
type: ['doubleClick', 'contextmenu'],
|
type: ['doubleClick', 'contextmenu'],
|
||||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
const cert = actionData.item;
|
const cert = actionData.item;
|
||||||
@@ -289,7 +401,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
menuOptions: [
|
menuOptions: [
|
||||||
{
|
{
|
||||||
name: 'Copy Domain',
|
name: 'Copy Domain',
|
||||||
iconName: 'copy',
|
iconName: 'lucide:Copy',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await navigator.clipboard.writeText(cert.domain);
|
await navigator.clipboard.writeText(cert.domain);
|
||||||
},
|
},
|
||||||
@@ -309,6 +421,19 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private downloadJsonFile(filename: string, data: any): void {
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
private renderRoutePills(routeNames: string[]): TemplateResult {
|
private renderRoutePills(routeNames: string[]): TemplateResult {
|
||||||
const maxShow = 3;
|
const maxShow = 3;
|
||||||
const visible = routeNames.slice(0, maxShow);
|
const visible = routeNames.slice(0, maxShow);
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
.dataActions=${[
|
.dataActions=${[
|
||||||
{
|
{
|
||||||
name: 'View Details',
|
name: 'View Details',
|
||||||
iconName: 'magnifyingGlass',
|
iconName: 'lucide:Search',
|
||||||
type: ['inRow', 'doubleClick', 'contextmenu'],
|
type: ['inRow', 'doubleClick', 'contextmenu'],
|
||||||
actionFunc: async (actionData) => {
|
actionFunc: async (actionData) => {
|
||||||
await this.showRequestDetails(actionData.item);
|
await this.showRequestDetails(actionData.item);
|
||||||
@@ -336,7 +336,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
menuOptions: [
|
menuOptions: [
|
||||||
{
|
{
|
||||||
name: 'Copy Request ID',
|
name: 'Copy Request ID',
|
||||||
iconName: 'copy',
|
iconName: 'lucide:Copy',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await navigator.clipboard.writeText(request.id);
|
await navigator.clipboard.writeText(request.id);
|
||||||
}
|
}
|
||||||
@@ -429,13 +429,13 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
title: 'Active Connections',
|
title: 'Active Connections',
|
||||||
value: activeConnections,
|
value: activeConnections,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'plug',
|
icon: 'lucide:Plug',
|
||||||
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
||||||
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
|
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'View Details',
|
name: 'View Details',
|
||||||
iconName: 'magnifyingGlass',
|
iconName: 'lucide:Search',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -446,7 +446,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
title: 'Requests/sec',
|
title: 'Requests/sec',
|
||||||
value: reqPerSec,
|
value: reqPerSec,
|
||||||
type: 'trend',
|
type: 'trend',
|
||||||
icon: 'chartLine',
|
icon: 'lucide:ChartLine',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
trendData: trendData,
|
trendData: trendData,
|
||||||
description: `Total: ${this.formatNumber(this.networkState.requestsTotal || 0)} requests`,
|
description: `Total: ${this.formatNumber(this.networkState.requestsTotal || 0)} requests`,
|
||||||
@@ -457,7 +457,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
value: this.formatBitsPerSecond(throughput.in),
|
value: this.formatBitsPerSecond(throughput.in),
|
||||||
unit: '',
|
unit: '',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'download',
|
icon: 'lucide:Download',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.in || 0)}`,
|
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.in || 0)}`,
|
||||||
},
|
},
|
||||||
@@ -467,7 +467,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
value: this.formatBitsPerSecond(throughput.out),
|
value: this.formatBitsPerSecond(throughput.out),
|
||||||
unit: '',
|
unit: '',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'upload',
|
icon: 'lucide:Upload',
|
||||||
color: '#8b5cf6',
|
color: '#8b5cf6',
|
||||||
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.out || 0)}`,
|
description: `Total: ${this.formatBytes(this.networkState.totalBytes?.out || 0)}`,
|
||||||
},
|
},
|
||||||
@@ -480,7 +480,7 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
.gridActions=${[
|
.gridActions=${[
|
||||||
{
|
{
|
||||||
name: 'Export Data',
|
name: 'Export Data',
|
||||||
iconName: 'fileExport',
|
iconName: 'lucide:FileOutput',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
console.log('Export feature coming soon');
|
console.log('Export feature coming soon');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Server Status',
|
title: 'Server Status',
|
||||||
value: this.statsState.serverStats.uptime ? 'Online' : 'Offline',
|
value: this.statsState.serverStats.uptime ? 'Online' : 'Offline',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
icon: 'server',
|
icon: 'lucide:Server',
|
||||||
color: this.statsState.serverStats.uptime ? '#22c55e' : '#ef4444',
|
color: this.statsState.serverStats.uptime ? '#22c55e' : '#ef4444',
|
||||||
description: `Uptime: ${this.formatUptime(this.statsState.serverStats.uptime)}`,
|
description: `Uptime: ${this.formatUptime(this.statsState.serverStats.uptime)}`,
|
||||||
},
|
},
|
||||||
@@ -172,7 +172,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Active Connections',
|
title: 'Active Connections',
|
||||||
value: this.statsState.serverStats.activeConnections,
|
value: this.statsState.serverStats.activeConnections,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'networkWired',
|
icon: 'lucide:Network',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
description: `Total: ${this.statsState.serverStats.totalConnections}`,
|
description: `Total: ${this.statsState.serverStats.totalConnections}`,
|
||||||
},
|
},
|
||||||
@@ -181,7 +181,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Throughput In',
|
title: 'Throughput In',
|
||||||
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesInPerSecond || 0),
|
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesInPerSecond || 0),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
icon: 'download',
|
icon: 'lucide:Download',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesIn || 0)}`,
|
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesIn || 0)}`,
|
||||||
},
|
},
|
||||||
@@ -190,7 +190,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Throughput Out',
|
title: 'Throughput Out',
|
||||||
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesOutPerSecond || 0),
|
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesOutPerSecond || 0),
|
||||||
type: 'text',
|
type: 'text',
|
||||||
icon: 'upload',
|
icon: 'lucide:Upload',
|
||||||
color: '#8b5cf6',
|
color: '#8b5cf6',
|
||||||
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesOut || 0)}`,
|
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesOut || 0)}`,
|
||||||
},
|
},
|
||||||
@@ -199,7 +199,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'CPU Usage',
|
title: 'CPU Usage',
|
||||||
value: cpuUsage,
|
value: cpuUsage,
|
||||||
type: 'gauge',
|
type: 'gauge',
|
||||||
icon: 'microchip',
|
icon: 'lucide:Cpu',
|
||||||
gaugeOptions: {
|
gaugeOptions: {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
@@ -215,7 +215,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Memory Usage',
|
title: 'Memory Usage',
|
||||||
value: memoryUsage,
|
value: memoryUsage,
|
||||||
type: 'percentage',
|
type: 'percentage',
|
||||||
icon: 'memory',
|
icon: 'lucide:MemoryStick',
|
||||||
color: memoryUsage > 80 ? '#ef4444' : memoryUsage > 60 ? '#f59e0b' : '#22c55e',
|
color: memoryUsage > 80 ? '#ef4444' : memoryUsage > 60 ? '#f59e0b' : '#22c55e',
|
||||||
description: this.statsState.serverStats.memoryUsage.actualUsageBytes !== undefined && this.statsState.serverStats.memoryUsage.maxMemoryMB !== undefined
|
description: this.statsState.serverStats.memoryUsage.actualUsageBytes !== undefined && this.statsState.serverStats.memoryUsage.maxMemoryMB !== undefined
|
||||||
? `${this.formatBytes(this.statsState.serverStats.memoryUsage.actualUsageBytes)} / ${this.formatBytes(this.statsState.serverStats.memoryUsage.maxMemoryMB * 1024 * 1024)}`
|
? `${this.formatBytes(this.statsState.serverStats.memoryUsage.actualUsageBytes)} / ${this.formatBytes(this.statsState.serverStats.memoryUsage.maxMemoryMB * 1024 * 1024)}`
|
||||||
@@ -229,7 +229,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
.gridActions=${[
|
.gridActions=${[
|
||||||
{
|
{
|
||||||
name: 'Refresh',
|
name: 'Refresh',
|
||||||
iconName: 'arrowsRotate',
|
iconName: 'lucide:RefreshCw',
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||||
},
|
},
|
||||||
@@ -251,7 +251,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Emails Sent',
|
title: 'Emails Sent',
|
||||||
value: this.statsState.emailStats.sent,
|
value: this.statsState.emailStats.sent,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'paperPlane',
|
icon: 'lucide:Send',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
description: `Delivery rate: ${(deliveryRate * 100).toFixed(1)}%`,
|
description: `Delivery rate: ${(deliveryRate * 100).toFixed(1)}%`,
|
||||||
},
|
},
|
||||||
@@ -260,7 +260,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Emails Received',
|
title: 'Emails Received',
|
||||||
value: this.statsState.emailStats.received,
|
value: this.statsState.emailStats.received,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'envelope',
|
icon: 'lucide:Mail',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -268,7 +268,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Queued',
|
title: 'Queued',
|
||||||
value: this.statsState.emailStats.queued,
|
value: this.statsState.emailStats.queued,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'clock',
|
icon: 'lucide:Clock',
|
||||||
color: '#f59e0b',
|
color: '#f59e0b',
|
||||||
description: 'Pending delivery',
|
description: 'Pending delivery',
|
||||||
},
|
},
|
||||||
@@ -277,7 +277,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Failed',
|
title: 'Failed',
|
||||||
value: this.statsState.emailStats.failed,
|
value: this.statsState.emailStats.failed,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'triangleExclamation',
|
icon: 'lucide:TriangleAlert',
|
||||||
color: '#ef4444',
|
color: '#ef4444',
|
||||||
description: `Bounce rate: ${(bounceRate * 100).toFixed(1)}%`,
|
description: `Bounce rate: ${(bounceRate * 100).toFixed(1)}%`,
|
||||||
},
|
},
|
||||||
@@ -300,7 +300,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'DNS Queries',
|
title: 'DNS Queries',
|
||||||
value: this.statsState.dnsStats.totalQueries,
|
value: this.statsState.dnsStats.totalQueries,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'globe',
|
icon: 'lucide:Globe',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
description: 'Total queries handled',
|
description: 'Total queries handled',
|
||||||
},
|
},
|
||||||
@@ -309,7 +309,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Cache Hit Rate',
|
title: 'Cache Hit Rate',
|
||||||
value: cacheHitRate,
|
value: cacheHitRate,
|
||||||
type: 'percentage',
|
type: 'percentage',
|
||||||
icon: 'database',
|
icon: 'lucide:Database',
|
||||||
color: cacheHitRate > 80 ? '#22c55e' : cacheHitRate > 60 ? '#f59e0b' : '#ef4444',
|
color: cacheHitRate > 80 ? '#22c55e' : cacheHitRate > 60 ? '#f59e0b' : '#ef4444',
|
||||||
description: `${this.statsState.dnsStats.cacheHits} hits / ${this.statsState.dnsStats.cacheMisses} misses`,
|
description: `${this.statsState.dnsStats.cacheHits} hits / ${this.statsState.dnsStats.cacheMisses} misses`,
|
||||||
},
|
},
|
||||||
@@ -318,7 +318,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
title: 'Active Domains',
|
title: 'Active Domains',
|
||||||
value: this.statsState.dnsStats.activeDomains,
|
value: this.statsState.dnsStats.activeDomains,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'sitemap',
|
icon: 'lucide:Network',
|
||||||
color: '#8b5cf6',
|
color: '#8b5cf6',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -327,7 +327,7 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
value: this.statsState.dnsStats.averageResponseTime.toFixed(1),
|
value: this.statsState.dnsStats.averageResponseTime.toFixed(1),
|
||||||
unit: 'ms',
|
unit: 'ms',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'clockRotateLeft',
|
icon: 'lucide:History',
|
||||||
color: this.statsState.dnsStats.averageResponseTime < 50 ? '#22c55e' : '#f59e0b',
|
color: this.statsState.dnsStats.averageResponseTime < 50 ? '#22c55e' : '#f59e0b',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -114,6 +114,17 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||||
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portBadge.manual {
|
||||||
|
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||||
|
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.portBadge.derived {
|
||||||
|
background: ${cssManager.bdTheme('#ecfdf5', '#022c22')};
|
||||||
|
color: ${cssManager.bdTheme('#047857', '#34d399')};
|
||||||
|
border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')};
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -187,7 +198,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
name: edge.name,
|
name: edge.name,
|
||||||
status: this.getEdgeStatusHtml(edge),
|
status: this.getEdgeStatusHtml(edge),
|
||||||
publicIp: this.getEdgePublicIp(edge.id),
|
publicIp: this.getEdgePublicIp(edge.id),
|
||||||
ports: this.getPortsHtml(edge.listenPorts),
|
ports: this.getPortsHtml(edge),
|
||||||
tunnels: this.getEdgeTunnelCount(edge.id),
|
tunnels: this.getEdgeTunnelCount(edge.id),
|
||||||
lastHeartbeat: this.getLastHeartbeat(edge.id),
|
lastHeartbeat: this.getLastHeartbeat(edge.id),
|
||||||
})}
|
})}
|
||||||
@@ -198,42 +209,137 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
type: ['header'],
|
type: ['header'],
|
||||||
actionFunc: async () => {
|
actionFunc: async () => {
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
const result = await DeesModal.createAndShow({
|
const modal = await DeesModal.createAndShow({
|
||||||
heading: 'Create Edge Node',
|
heading: 'Create Edge Node',
|
||||||
content: html`
|
content: html`
|
||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||||
<dees-input-text .key=${'listenPorts'} .label=${'Listen Ports (comma-separated)'} .required=${true} .value=${'443,25'}></dees-input-text>
|
<dees-input-text .key=${'listenPorts'} .label=${'Additional Manual Ports (comma-separated, optional)'}></dees-input-text>
|
||||||
|
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
|
||||||
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
|
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
menuOptions: [],
|
menuOptions: [
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
const formData = result as any;
|
|
||||||
const ports = (formData.name ? formData.listenPorts : '443')
|
|
||||||
.split(',')
|
|
||||||
.map((p: string) => parseInt(p.trim(), 10))
|
|
||||||
.filter((p: number) => !isNaN(p));
|
|
||||||
const tags = formData.tags
|
|
||||||
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
|
||||||
: undefined;
|
|
||||||
await appstate.remoteIngressStatePart.dispatchAction(
|
|
||||||
appstate.createRemoteIngressAction,
|
|
||||||
{
|
{
|
||||||
name: formData.name,
|
name: 'Cancel',
|
||||||
listenPorts: ports,
|
iconName: 'lucide:x',
|
||||||
tags,
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
},
|
},
|
||||||
);
|
{
|
||||||
}
|
name: 'Create',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
const name = formData.name;
|
||||||
|
if (!name) return;
|
||||||
|
const portsStr = formData.listenPorts?.trim();
|
||||||
|
const listenPorts = portsStr
|
||||||
|
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
||||||
|
: undefined;
|
||||||
|
const autoDerivePorts = formData.autoDerivePorts !== false;
|
||||||
|
const tags = formData.tags
|
||||||
|
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
await appstate.remoteIngressStatePart.dispatchAction(
|
||||||
|
appstate.createRemoteIngressAction,
|
||||||
|
{ name, listenPorts, autoDerivePorts, tags },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Enable',
|
||||||
|
iconName: 'lucide:play',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||||
|
await appstate.remoteIngressStatePart.dispatchAction(
|
||||||
|
appstate.toggleRemoteIngressAction,
|
||||||
|
{ id: edge.id, enabled: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Disable',
|
||||||
|
iconName: 'lucide:pause',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||||
|
await appstate.remoteIngressStatePart.dispatchAction(
|
||||||
|
appstate.toggleRemoteIngressAction,
|
||||||
|
{ id: edge.id, enabled: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit',
|
||||||
|
iconName: 'lucide:pencil',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Edit Edge: ${edge.name}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports (comma-separated)'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
||||||
|
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
|
||||||
|
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated)'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
iconName: 'lucide:check',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
const portsStr = formData.listenPorts?.trim();
|
||||||
|
const listenPorts = portsStr
|
||||||
|
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
||||||
|
: [];
|
||||||
|
const autoDerivePorts = formData.autoDerivePorts !== false;
|
||||||
|
const tags = formData.tags
|
||||||
|
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
await appstate.remoteIngressStatePart.dispatchAction(
|
||||||
|
appstate.updateRemoteIngressAction,
|
||||||
|
{
|
||||||
|
id: edge.id,
|
||||||
|
name: formData.name || edge.name,
|
||||||
|
listenPorts,
|
||||||
|
autoDerivePorts,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Regenerate Secret',
|
name: 'Regenerate Secret',
|
||||||
iconName: 'lucide:key',
|
iconName: 'lucide:key',
|
||||||
type: ['row'],
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
action: async (edge: interfaces.data.IRemoteIngress) => {
|
actionFunc: async (actionData: any) => {
|
||||||
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||||
await appstate.remoteIngressStatePart.dispatchAction(
|
await appstate.remoteIngressStatePart.dispatchAction(
|
||||||
appstate.regenerateRemoteIngressSecretAction,
|
appstate.regenerateRemoteIngressSecretAction,
|
||||||
edge.id,
|
edge.id,
|
||||||
@@ -243,8 +349,9 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
type: ['row'],
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
action: async (edge: interfaces.data.IRemoteIngress) => {
|
actionFunc: async (actionData: any) => {
|
||||||
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||||
await appstate.remoteIngressStatePart.dispatchAction(
|
await appstate.remoteIngressStatePart.dispatchAction(
|
||||||
appstate.deleteRemoteIngressAction,
|
appstate.deleteRemoteIngressAction,
|
||||||
edge.id,
|
edge.id,
|
||||||
@@ -277,8 +384,13 @@ export class OpsViewRemoteIngress extends DeesElement {
|
|||||||
return status?.publicIp || '-';
|
return status?.publicIp || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPortsHtml(ports: number[]): TemplateResult {
|
private getPortsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
|
||||||
return html`<div class="portsDisplay">${ports.map(p => html`<span class="portBadge">${p}</span>`)}</div>`;
|
const manualPorts = edge.manualPorts || [];
|
||||||
|
const derivedPorts = edge.derivedPorts || [];
|
||||||
|
if (manualPorts.length === 0 && derivedPorts.length === 0) {
|
||||||
|
return html`<span style="color: var(--text-muted, #6b7280); font-size: 12px;">none</span>`;
|
||||||
|
}
|
||||||
|
return html`<div class="portsDisplay">${manualPorts.map(p => html`<span class="portBadge manual">${p}</span>`)}${derivedPorts.map(p => html`<span class="portBadge derived">${p}</span>`)}${derivedPorts.length > 0 ? html`<span style="font-size: 11px; color: var(--text-muted, #6b7280); align-self: center;">(auto)</span>` : ''}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEdgeTunnelCount(edgeId: string): number {
|
private getEdgeTunnelCount(edgeId: string): number {
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Threat Level',
|
title: 'Threat Level',
|
||||||
value: threatScore,
|
value: threatScore,
|
||||||
type: 'gauge',
|
type: 'gauge',
|
||||||
icon: 'shield',
|
icon: 'lucide:Shield',
|
||||||
gaugeOptions: {
|
gaugeOptions: {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
@@ -273,7 +273,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Blocked Threats',
|
title: 'Blocked Threats',
|
||||||
value: metrics.blockedIPs.length + metrics.spamDetected,
|
value: metrics.blockedIPs.length + metrics.spamDetected,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'userShield',
|
icon: 'lucide:ShieldCheck',
|
||||||
color: '#ef4444',
|
color: '#ef4444',
|
||||||
description: 'Total threats blocked today',
|
description: 'Total threats blocked today',
|
||||||
},
|
},
|
||||||
@@ -282,7 +282,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Active Sessions',
|
title: 'Active Sessions',
|
||||||
value: 0,
|
value: 0,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'users',
|
icon: 'lucide:Users',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
description: 'Current authenticated sessions',
|
description: 'Current authenticated sessions',
|
||||||
},
|
},
|
||||||
@@ -291,7 +291,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Auth Failures',
|
title: 'Auth Failures',
|
||||||
value: metrics.authenticationFailures,
|
value: metrics.authenticationFailures,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lockOpen',
|
icon: 'lucide:LockOpen',
|
||||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||||
description: 'Failed login attempts today',
|
description: 'Failed login attempts today',
|
||||||
},
|
},
|
||||||
@@ -355,7 +355,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Authentication Failures',
|
title: 'Authentication Failures',
|
||||||
value: metrics.authenticationFailures,
|
value: metrics.authenticationFailures,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lockOpen',
|
icon: 'lucide:LockOpen',
|
||||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||||
description: 'Failed authentication attempts today',
|
description: 'Failed authentication attempts today',
|
||||||
},
|
},
|
||||||
@@ -364,7 +364,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Successful Logins',
|
title: 'Successful Logins',
|
||||||
value: 0,
|
value: 0,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lock',
|
icon: 'lucide:Lock',
|
||||||
color: '#22c55e',
|
color: '#22c55e',
|
||||||
description: 'Successful logins today',
|
description: 'Successful logins today',
|
||||||
},
|
},
|
||||||
@@ -399,7 +399,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Malware Detection',
|
title: 'Malware Detection',
|
||||||
value: metrics.malwareDetected,
|
value: metrics.malwareDetected,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'virusSlash',
|
icon: 'lucide:BugOff',
|
||||||
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
|
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
|
||||||
description: 'Malware detected',
|
description: 'Malware detected',
|
||||||
},
|
},
|
||||||
@@ -408,7 +408,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Phishing Detection',
|
title: 'Phishing Detection',
|
||||||
value: metrics.phishingDetected,
|
value: metrics.phishingDetected,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'fishFins',
|
icon: 'lucide:Fish',
|
||||||
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
|
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
|
||||||
description: 'Phishing attempts detected',
|
description: 'Phishing attempts detected',
|
||||||
},
|
},
|
||||||
@@ -417,7 +417,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Suspicious Activities',
|
title: 'Suspicious Activities',
|
||||||
value: metrics.suspiciousActivities,
|
value: metrics.suspiciousActivities,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'triangleExclamation',
|
icon: 'lucide:TriangleAlert',
|
||||||
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
|
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
|
||||||
description: 'Suspicious activities detected',
|
description: 'Suspicious activities detected',
|
||||||
},
|
},
|
||||||
@@ -426,7 +426,7 @@ export class OpsViewSecurity extends DeesElement {
|
|||||||
title: 'Spam Detection',
|
title: 'Spam Detection',
|
||||||
value: metrics.spamDetected,
|
value: metrics.spamDetected,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'ban',
|
icon: 'lucide:Ban',
|
||||||
color: '#f59e0b',
|
color: '#f59e0b',
|
||||||
description: 'Spam emails blocked',
|
description: 'Spam emails blocked',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user