@serve.zone/dcrouter 13.21.1 → 13.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist_serve/bundle.js +26 -4
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +6 -0
  4. package/dist_ts/classes.dcrouter.js +83 -5
  5. package/dist_ts/config/classes.route-config-manager.d.ts +1 -1
  6. package/dist_ts/config/classes.route-config-manager.js +2 -2
  7. package/dist_ts/db/documents/classes.ip-intelligence.doc.d.ts +25 -0
  8. package/dist_ts/db/documents/classes.ip-intelligence.doc.js +175 -0
  9. package/dist_ts/db/documents/classes.security-block-rule.doc.d.ts +17 -0
  10. package/dist_ts/db/documents/classes.security-block-rule.doc.js +124 -0
  11. package/dist_ts/db/documents/classes.security-policy-audit.doc.d.ts +11 -0
  12. package/dist_ts/db/documents/classes.security-policy-audit.doc.js +95 -0
  13. package/dist_ts/db/documents/index.d.ts +3 -0
  14. package/dist_ts/db/documents/index.js +4 -1
  15. package/dist_ts/monitoring/classes.metricsmanager.js +2 -1
  16. package/dist_ts/opsserver/handlers/config.handler.js +2 -1
  17. package/dist_ts/opsserver/handlers/remoteingress.handler.js +3 -1
  18. package/dist_ts/opsserver/handlers/security.handler.js +42 -1
  19. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +10 -0
  20. package/dist_ts/remoteingress/classes.remoteingress-manager.js +9 -1
  21. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +3 -0
  22. package/dist_ts/remoteingress/classes.tunnel-manager.js +23 -5
  23. package/dist_ts/security/classes.security-policy-manager.d.ts +41 -0
  24. package/dist_ts/security/classes.security-policy-manager.js +283 -0
  25. package/dist_ts/security/index.d.ts +1 -0
  26. package/dist_ts/security/index.js +2 -1
  27. package/dist_ts_apiclient/classes.remoteingress.d.ts +2 -0
  28. package/dist_ts_apiclient/classes.remoteingress.js +7 -1
  29. package/dist_ts_interfaces/data/index.d.ts +1 -0
  30. package/dist_ts_interfaces/data/index.js +2 -1
  31. package/dist_ts_interfaces/data/remoteingress.d.ts +51 -0
  32. package/dist_ts_interfaces/data/security-policy.d.ts +32 -0
  33. package/dist_ts_interfaces/data/security-policy.js +2 -0
  34. package/dist_ts_interfaces/requests/config.d.ts +1 -0
  35. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  36. package/dist_ts_interfaces/requests/index.js +2 -1
  37. package/dist_ts_interfaces/requests/security-policy.d.ts +64 -0
  38. package/dist_ts_interfaces/requests/security-policy.js +2 -0
  39. package/dist_ts_web/00_commitinfo_data.js +1 -1
  40. package/dist_ts_web/elements/network/ops-view-remoteingress.d.ts +5 -0
  41. package/dist_ts_web/elements/network/ops-view-remoteingress.js +69 -1
  42. package/package.json +3 -3
  43. package/ts/00_commitinfo_data.ts +1 -1
  44. package/ts/classes.dcrouter.ts +106 -6
  45. package/ts/config/classes.route-config-manager.ts +2 -2
  46. package/ts/db/documents/classes.ip-intelligence.doc.ts +75 -0
  47. package/ts/db/documents/classes.security-block-rule.doc.ts +52 -0
  48. package/ts/db/documents/classes.security-policy-audit.doc.ts +33 -0
  49. package/ts/db/documents/index.ts +3 -0
  50. package/ts/monitoring/classes.metricsmanager.ts +2 -0
  51. package/ts/opsserver/handlers/config.handler.ts +1 -0
  52. package/ts/opsserver/handlers/remoteingress.handler.ts +2 -0
  53. package/ts/opsserver/handlers/security.handler.ts +69 -0
  54. package/ts/remoteingress/classes.remoteingress-manager.ts +15 -2
  55. package/ts/remoteingress/classes.tunnel-manager.ts +25 -5
  56. package/ts/security/classes.security-policy-manager.ts +315 -0
  57. package/ts/security/index.ts +7 -1
  58. package/ts_apiclient/classes.remoteingress.ts +6 -0
  59. package/ts_web/00_commitinfo_data.ts +1 -1
  60. package/ts_web/elements/network/ops-view-remoteingress.ts +68 -0
@@ -0,0 +1,315 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
3
+ import { IpIntelligenceDoc, SecurityBlockRuleDoc, SecurityPolicyAuditDoc } from '../db/index.js';
4
+ import type {
5
+ IIpIntelligenceRecord,
6
+ ISecurityBlockRule,
7
+ ISecurityCompiledPolicy,
8
+ TSecurityBlockRuleMatchMode,
9
+ TSecurityBlockRuleType,
10
+ } from '../../ts_interfaces/data/security-policy.js';
11
+
12
+ export interface ISecurityPolicyManagerOptions {
13
+ intelligenceRefreshMs?: number;
14
+ onPolicyChanged?: () => void | Promise<void>;
15
+ }
16
+
17
+ export interface IRemoteIngressFirewallSnapshot {
18
+ blockedIps?: string[];
19
+ }
20
+
21
+ export class SecurityPolicyManager {
22
+ private readonly smartNetwork = new plugins.smartnetwork.SmartNetwork({
23
+ cacheTtl: 24 * 60 * 60 * 1000,
24
+ });
25
+ private readonly intelligenceRefreshMs: number;
26
+ private readonly inFlightObservations = new Set<string>();
27
+ private readonly onPolicyChanged?: () => void | Promise<void>;
28
+
29
+ constructor(options: ISecurityPolicyManagerOptions = {}) {
30
+ this.intelligenceRefreshMs = options.intelligenceRefreshMs ?? 24 * 60 * 60 * 1000;
31
+ this.onPolicyChanged = options.onPolicyChanged;
32
+ }
33
+
34
+ public async start(): Promise<void> {
35
+ logger.log('info', 'SecurityPolicyManager started');
36
+ }
37
+
38
+ public async stop(): Promise<void> {
39
+ await this.smartNetwork.stop();
40
+ }
41
+
42
+ public async observeIps(ips: string[]): Promise<void> {
43
+ const uniqueIps = [...new Set(ips.map((ip) => this.normalizeIp(ip)).filter(Boolean) as string[])];
44
+ await Promise.allSettled(uniqueIps.map((ip) => this.observeIp(ip)));
45
+ }
46
+
47
+ public async observeIp(ipAddress: string): Promise<void> {
48
+ const ip = this.normalizeIp(ipAddress);
49
+ if (!ip || !this.isPublicIp(ip) || this.inFlightObservations.has(ip)) {
50
+ return;
51
+ }
52
+
53
+ this.inFlightObservations.add(ip);
54
+ try {
55
+ const now = Date.now();
56
+ let doc = await IpIntelligenceDoc.findByIp(ip);
57
+ if (doc && now - doc.updatedAt < this.intelligenceRefreshMs) {
58
+ if (now - doc.lastSeenAt > 60_000) {
59
+ doc.lastSeenAt = now;
60
+ doc.seenCount = (doc.seenCount || 0) + 1;
61
+ await doc.save();
62
+ }
63
+ return;
64
+ }
65
+
66
+ const intelligence = await this.smartNetwork.getIpIntelligence(ip);
67
+ if (!doc) {
68
+ doc = new IpIntelligenceDoc();
69
+ doc.ipAddress = ip;
70
+ doc.firstSeenAt = now;
71
+ }
72
+ Object.assign(doc, intelligence);
73
+ doc.lastSeenAt = now;
74
+ doc.updatedAt = now;
75
+ doc.seenCount = (doc.seenCount || 0) + 1;
76
+ await doc.save();
77
+
78
+ if (await this.matchesAnyReactiveRule(doc)) {
79
+ await this.notifyPolicyChanged();
80
+ }
81
+ } catch (err) {
82
+ logger.log('warn', `Failed to enrich IP ${ip}: ${(err as Error).message}`);
83
+ } finally {
84
+ this.inFlightObservations.delete(ip);
85
+ }
86
+ }
87
+
88
+ public async listBlockRules(): Promise<ISecurityBlockRule[]> {
89
+ return (await SecurityBlockRuleDoc.findAll()).map((doc) => this.ruleFromDoc(doc));
90
+ }
91
+
92
+ public async listIpIntelligence(): Promise<IIpIntelligenceRecord[]> {
93
+ return (await IpIntelligenceDoc.findAll()).map((doc) => ({
94
+ ipAddress: doc.ipAddress,
95
+ asn: doc.asn,
96
+ asnOrg: doc.asnOrg,
97
+ registrantOrg: doc.registrantOrg,
98
+ registrantCountry: doc.registrantCountry,
99
+ networkRange: doc.networkRange,
100
+ abuseContact: doc.abuseContact,
101
+ country: doc.country,
102
+ countryCode: doc.countryCode,
103
+ city: doc.city,
104
+ latitude: doc.latitude,
105
+ longitude: doc.longitude,
106
+ accuracyRadius: doc.accuracyRadius,
107
+ timezone: doc.timezone,
108
+ firstSeenAt: doc.firstSeenAt,
109
+ lastSeenAt: doc.lastSeenAt,
110
+ updatedAt: doc.updatedAt,
111
+ seenCount: doc.seenCount,
112
+ }));
113
+ }
114
+
115
+ public async createBlockRule(input: {
116
+ type: TSecurityBlockRuleType;
117
+ value: string;
118
+ matchMode?: TSecurityBlockRuleMatchMode;
119
+ reason?: string;
120
+ enabled?: boolean;
121
+ }, actor = 'system'): Promise<ISecurityBlockRule> {
122
+ const now = Date.now();
123
+ const doc = new SecurityBlockRuleDoc();
124
+ doc.id = plugins.uuid.v4();
125
+ doc.type = input.type;
126
+ doc.value = input.value.trim();
127
+ doc.matchMode = input.matchMode;
128
+ doc.reason = input.reason;
129
+ doc.enabled = input.enabled ?? true;
130
+ doc.createdAt = now;
131
+ doc.updatedAt = now;
132
+ doc.createdBy = actor;
133
+ await doc.save();
134
+ await this.writeAudit('createBlockRule', actor, { rule: this.ruleFromDoc(doc) });
135
+ await this.notifyPolicyChanged();
136
+ return this.ruleFromDoc(doc);
137
+ }
138
+
139
+ public async updateBlockRule(id: string, patch: Partial<Pick<ISecurityBlockRule, 'value' | 'matchMode' | 'reason' | 'enabled'>>, actor = 'system'): Promise<ISecurityBlockRule | null> {
140
+ const doc = await SecurityBlockRuleDoc.findById(id);
141
+ if (!doc) {
142
+ return null;
143
+ }
144
+ if (patch.value !== undefined) doc.value = patch.value.trim();
145
+ if (patch.matchMode !== undefined) doc.matchMode = patch.matchMode;
146
+ if (patch.reason !== undefined) doc.reason = patch.reason;
147
+ if (patch.enabled !== undefined) doc.enabled = patch.enabled;
148
+ doc.updatedAt = Date.now();
149
+ await doc.save();
150
+ await this.writeAudit('updateBlockRule', actor, { id, patch });
151
+ await this.notifyPolicyChanged();
152
+ return this.ruleFromDoc(doc);
153
+ }
154
+
155
+ public async deleteBlockRule(id: string, actor = 'system'): Promise<boolean> {
156
+ const doc = await SecurityBlockRuleDoc.findById(id);
157
+ if (!doc) {
158
+ return false;
159
+ }
160
+ await doc.delete();
161
+ await this.writeAudit('deleteBlockRule', actor, { id });
162
+ await this.notifyPolicyChanged();
163
+ return true;
164
+ }
165
+
166
+ public async compilePolicy(): Promise<ISecurityCompiledPolicy> {
167
+ const rules = await SecurityBlockRuleDoc.findEnabled();
168
+ const intelligenceDocs = await IpIntelligenceDoc.findAll();
169
+ const blockedIps = new Set<string>();
170
+ const blockedCidrs = new Set<string>();
171
+
172
+ for (const rule of rules) {
173
+ const normalizedValue = rule.value.trim();
174
+ if (!normalizedValue) continue;
175
+
176
+ if (rule.type === 'ip') {
177
+ const ip = this.normalizeIp(normalizedValue);
178
+ if (ip && plugins.net.isIP(ip)) blockedIps.add(ip);
179
+ continue;
180
+ }
181
+
182
+ if (rule.type === 'cidr') {
183
+ const cidr = this.normalizeCidr(normalizedValue);
184
+ if (cidr) blockedCidrs.add(cidr);
185
+ continue;
186
+ }
187
+
188
+ for (const doc of intelligenceDocs) {
189
+ if (!this.ruleMatchesIntelligence(rule, doc)) continue;
190
+ const cidr = this.normalizeCidr(doc.networkRange || '');
191
+ if (cidr) {
192
+ blockedCidrs.add(cidr);
193
+ } else if (this.normalizeIp(doc.ipAddress)) {
194
+ blockedIps.add(this.normalizeIp(doc.ipAddress)!);
195
+ }
196
+ }
197
+ }
198
+
199
+ return {
200
+ blockedIps: [...blockedIps].sort(),
201
+ blockedCidrs: [...blockedCidrs].sort(),
202
+ };
203
+ }
204
+
205
+ public async compileSmartProxyPolicy(): Promise<ISecurityCompiledPolicy> {
206
+ return await this.compilePolicy();
207
+ }
208
+
209
+ public async compileRemoteIngressFirewall(): Promise<IRemoteIngressFirewallSnapshot | undefined> {
210
+ const policy = await this.compilePolicy();
211
+ const blockedIps = [
212
+ ...policy.blockedIps.filter((ip) => plugins.net.isIP(ip) === 4),
213
+ ...policy.blockedCidrs.filter((cidr) => plugins.net.isIP(cidr.split('/')[0]) === 4),
214
+ ];
215
+ return blockedIps.length > 0 ? { blockedIps } : undefined;
216
+ }
217
+
218
+ private async matchesAnyReactiveRule(doc: IpIntelligenceDoc): Promise<boolean> {
219
+ const rules = await SecurityBlockRuleDoc.findEnabled();
220
+ return rules.some((rule) => rule.type === 'asn' || rule.type === 'organization'
221
+ ? this.ruleMatchesIntelligence(rule, doc)
222
+ : false);
223
+ }
224
+
225
+ private ruleMatchesIntelligence(rule: SecurityBlockRuleDoc, doc: IpIntelligenceDoc): boolean {
226
+ const value = rule.value.trim().toLowerCase();
227
+ if (!value) return false;
228
+
229
+ if (rule.type === 'asn') {
230
+ return String(doc.asn ?? '') === value.replace(/^as/i, '');
231
+ }
232
+
233
+ if (rule.type === 'organization') {
234
+ const candidates = [doc.asnOrg, doc.registrantOrg]
235
+ .filter(Boolean)
236
+ .map((candidate) => candidate!.toLowerCase());
237
+ if (rule.matchMode === 'exact') {
238
+ return candidates.some((candidate) => candidate === value);
239
+ }
240
+ return candidates.some((candidate) => candidate.includes(value));
241
+ }
242
+
243
+ return false;
244
+ }
245
+
246
+ private normalizeIp(ipAddress: string): string | undefined {
247
+ const ip = ipAddress.trim();
248
+ if (ip.startsWith('::ffff:')) {
249
+ return ip.slice('::ffff:'.length);
250
+ }
251
+ return plugins.net.isIP(ip) ? ip : undefined;
252
+ }
253
+
254
+ private normalizeCidr(value: string): string | undefined {
255
+ const [rawIp, rawPrefix] = value.trim().split('/');
256
+ if (!rawIp || !rawPrefix) return undefined;
257
+ const ip = this.normalizeIp(rawIp);
258
+ if (!ip) return undefined;
259
+ const prefix = Number(rawPrefix);
260
+ const maxPrefix = plugins.net.isIP(ip) === 4 ? 32 : 128;
261
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) return undefined;
262
+ return `${ip}/${prefix}`;
263
+ }
264
+
265
+ private isPublicIp(ip: string): boolean {
266
+ const family = plugins.net.isIP(ip);
267
+ if (family === 4) {
268
+ const parts = ip.split('.').map((part) => Number(part));
269
+ const [a, b] = parts;
270
+ if (a === 10 || a === 127 || a === 0 || a >= 224) return false;
271
+ if (a === 100 && b >= 64 && b <= 127) return false;
272
+ if (a === 169 && b === 254) return false;
273
+ if (a === 172 && b >= 16 && b <= 31) return false;
274
+ if (a === 192 && b === 168) return false;
275
+ return true;
276
+ }
277
+ if (family === 6) {
278
+ const lower = ip.toLowerCase();
279
+ if (lower === '::1' || lower === '::') return false;
280
+ if (lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return false;
281
+ return true;
282
+ }
283
+ return false;
284
+ }
285
+
286
+ private ruleFromDoc(doc: SecurityBlockRuleDoc): ISecurityBlockRule {
287
+ return {
288
+ id: doc.id,
289
+ type: doc.type,
290
+ value: doc.value,
291
+ matchMode: doc.matchMode,
292
+ enabled: doc.enabled,
293
+ reason: doc.reason,
294
+ createdAt: doc.createdAt,
295
+ updatedAt: doc.updatedAt,
296
+ createdBy: doc.createdBy,
297
+ };
298
+ }
299
+
300
+ private async writeAudit(action: string, actor: string, details: Record<string, unknown>): Promise<void> {
301
+ const doc = new SecurityPolicyAuditDoc();
302
+ doc.id = plugins.uuid.v4();
303
+ doc.action = action;
304
+ doc.actor = actor;
305
+ doc.details = details;
306
+ doc.createdAt = Date.now();
307
+ await doc.save();
308
+ }
309
+
310
+ private async notifyPolicyChanged(): Promise<void> {
311
+ if (this.onPolicyChanged) {
312
+ await this.onPolicyChanged();
313
+ }
314
+ }
315
+ }
@@ -18,4 +18,10 @@ export {
18
18
  ThreatCategory,
19
19
  type IScanResult,
20
20
  type IContentScannerOptions
21
- } from './classes.contentscanner.js';
21
+ } from './classes.contentscanner.js';
22
+
23
+ export {
24
+ SecurityPolicyManager,
25
+ type ISecurityPolicyManagerOptions,
26
+ type IRemoteIngressFirewallSnapshot,
27
+ } from './classes.security-policy-manager.js';
@@ -9,12 +9,14 @@ export class RemoteIngress {
9
9
  public name: string;
10
10
  public secret: string;
11
11
  public listenPorts: number[];
12
+ public listenPortsUdp?: number[];
12
13
  public enabled: boolean;
13
14
  public autoDerivePorts: boolean;
14
15
  public tags?: string[];
15
16
  public createdAt: number;
16
17
  public updatedAt: number;
17
18
  public effectiveListenPorts?: number[];
19
+ public effectiveListenPortsUdp?: number[];
18
20
  public manualPorts?: number[];
19
21
  public derivedPorts?: number[];
20
22
 
@@ -24,12 +26,14 @@ export class RemoteIngress {
24
26
  this.name = data.name;
25
27
  this.secret = data.secret;
26
28
  this.listenPorts = data.listenPorts;
29
+ this.listenPortsUdp = data.listenPortsUdp;
27
30
  this.enabled = data.enabled;
28
31
  this.autoDerivePorts = data.autoDerivePorts;
29
32
  this.tags = data.tags;
30
33
  this.createdAt = data.createdAt;
31
34
  this.updatedAt = data.updatedAt;
32
35
  this.effectiveListenPorts = data.effectiveListenPorts;
36
+ this.effectiveListenPortsUdp = data.effectiveListenPortsUdp;
33
37
  this.manualPorts = data.manualPorts;
34
38
  this.derivedPorts = data.derivedPorts;
35
39
  }
@@ -52,11 +56,13 @@ export class RemoteIngress {
52
56
  const edge = response.edge;
53
57
  this.name = edge.name;
54
58
  this.listenPorts = edge.listenPorts;
59
+ this.listenPortsUdp = edge.listenPortsUdp;
55
60
  this.enabled = edge.enabled;
56
61
  this.autoDerivePorts = edge.autoDerivePorts;
57
62
  this.tags = edge.tags;
58
63
  this.updatedAt = edge.updatedAt;
59
64
  this.effectiveListenPorts = edge.effectiveListenPorts;
65
+ this.effectiveListenPortsUdp = edge.effectiveListenPortsUdp;
60
66
  this.manualPorts = edge.manualPorts;
61
67
  this.derivedPorts = edge.derivedPorts;
62
68
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.21.1',
6
+ version: '13.23.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -125,6 +125,18 @@ export class OpsViewRemoteIngress extends DeesElement {
125
125
  color: ${cssManager.bdTheme('#047857', '#34d399')};
126
126
  border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')};
127
127
  }
128
+
129
+ .metricStack {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: 2px;
133
+ font-size: 12px;
134
+ line-height: 1.35;
135
+ }
136
+
137
+ .metricMuted {
138
+ color: var(--text-muted, #6b7280);
139
+ }
128
140
  `,
129
141
  ];
130
142
 
@@ -226,9 +238,13 @@ export class OpsViewRemoteIngress extends DeesElement {
226
238
  .displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
227
239
  name: edge.name,
228
240
  status: this.getEdgeStatusHtml(edge),
241
+ transport: this.getTransportHtml(edge.id),
229
242
  publicIp: this.getEdgePublicIp(edge.id),
230
243
  ports: this.getPortsHtml(edge),
231
244
  tunnels: this.getEdgeTunnelCount(edge.id),
245
+ window: this.getWindowHtml(edge.id),
246
+ queues: this.getQueuesHtml(edge.id),
247
+ traffic: this.getTrafficHtml(edge.id),
232
248
  lastHeartbeat: this.getLastHeartbeat(edge.id),
233
249
  })}
234
250
  .dataActions=${[
@@ -459,6 +475,46 @@ export class OpsViewRemoteIngress extends DeesElement {
459
475
  return status?.activeTunnels || 0;
460
476
  }
461
477
 
478
+ private getTransportHtml(edgeId: string): TemplateResult | string {
479
+ const status = this.getEdgeStatus(edgeId);
480
+ if (!status?.connected) return '-';
481
+ const mode = status.transportMode || 'unknown';
482
+ const label = mode === 'quic' ? 'QUIC' : mode === 'tcpTls' ? 'TCP/TLS' : mode;
483
+ return html`<div class="metricStack"><strong>${label}</strong><span class="metricMuted">${status.fallbackUsed ? 'fallback' : status.performance?.profile || 'default'}</span></div>`;
484
+ }
485
+
486
+ private getWindowHtml(edgeId: string): TemplateResult | string {
487
+ const status = this.getEdgeStatus(edgeId);
488
+ if (!status?.connected || !status.flowControl) return '-';
489
+ if (!status.flowControl.applies) {
490
+ return html`<div class="metricStack"><span>native QUIC</span><span class="metricMuted">max ${status.performance?.maxStreamsPerEdge || '-'} streams</span></div>`;
491
+ }
492
+ return html`
493
+ <div class="metricStack">
494
+ <span>${this.formatBytes(status.flowControl.currentWindowBytes)} window</span>
495
+ <span class="metricMuted">${this.formatBytes(status.flowControl.estimatedInFlightBytes)} est. in-flight</span>
496
+ </div>
497
+ `;
498
+ }
499
+
500
+ private getQueuesHtml(edgeId: string): TemplateResult | string {
501
+ const status = this.getEdgeStatus(edgeId);
502
+ if (!status?.connected || !status.queues) return '-';
503
+ return html`<div class="metricStack"><span>C ${status.queues.ctrlQueueDepth} / D ${status.queues.dataQueueDepth}</span><span class="metricMuted">S ${status.queues.sustainedQueueDepth}</span></div>`;
504
+ }
505
+
506
+ private getTrafficHtml(edgeId: string): TemplateResult | string {
507
+ const status = this.getEdgeStatus(edgeId);
508
+ if (!status?.connected || !status.traffic) return '-';
509
+ const drops = (status.traffic.rejectedStreams || 0) + (status.udp?.droppedDatagrams || 0);
510
+ return html`
511
+ <div class="metricStack">
512
+ <span>${this.formatBytes(status.traffic.bytesIn)} in / ${this.formatBytes(status.traffic.bytesOut)} out</span>
513
+ <span class="metricMuted">${drops} rejected/dropped</span>
514
+ </div>
515
+ `;
516
+ }
517
+
462
518
  private getLastHeartbeat(edgeId: string): string {
463
519
  const status = this.getEdgeStatus(edgeId);
464
520
  if (!status?.lastHeartbeat) return '-';
@@ -467,4 +523,16 @@ export class OpsViewRemoteIngress extends DeesElement {
467
523
  if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
468
524
  return `${Math.floor(ago / 3600000)}h ago`;
469
525
  }
526
+
527
+ private formatBytes(bytes: number): string {
528
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
529
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
530
+ let value = bytes;
531
+ let unitIndex = 0;
532
+ while (value >= 1024 && unitIndex < units.length - 1) {
533
+ value = value / 1024;
534
+ unitIndex++;
535
+ }
536
+ return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
537
+ }
470
538
  }