@serve.zone/dcrouter 13.12.0 → 13.14.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 (33) hide show
  1. package/dist_serve/bundle.js +809 -779
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.js +6 -5
  4. package/dist_ts/dns/manager.dns.d.ts +46 -8
  5. package/dist_ts/dns/manager.dns.js +189 -36
  6. package/dist_ts/monitoring/classes.metricsmanager.d.ts +26 -0
  7. package/dist_ts/monitoring/classes.metricsmanager.js +72 -2
  8. package/dist_ts/opsserver/handlers/config.handler.js +2 -2
  9. package/dist_ts/opsserver/handlers/domain.handler.js +14 -1
  10. package/dist_ts/opsserver/handlers/security.handler.js +27 -23
  11. package/dist_ts/opsserver/handlers/stats.handler.js +22 -3
  12. package/dist_ts_interfaces/data/stats.d.ts +17 -1
  13. package/dist_ts_interfaces/requests/domains.d.ts +24 -0
  14. package/dist_ts_web/00_commitinfo_data.js +1 -1
  15. package/dist_ts_web/appstate.d.ts +13 -0
  16. package/dist_ts_web/appstate.js +62 -54
  17. package/dist_ts_web/elements/domains/ops-view-domains.d.ts +1 -0
  18. package/dist_ts_web/elements/domains/ops-view-domains.js +95 -1
  19. package/dist_ts_web/elements/network/ops-view-network-activity.d.ts +2 -20
  20. package/dist_ts_web/elements/network/ops-view-network-activity.js +65 -115
  21. package/package.json +1 -1
  22. package/ts/00_commitinfo_data.ts +1 -1
  23. package/ts/classes.dcrouter.ts +5 -4
  24. package/ts/dns/manager.dns.ts +219 -35
  25. package/ts/monitoring/classes.metricsmanager.ts +77 -1
  26. package/ts/opsserver/handlers/config.handler.ts +1 -1
  27. package/ts/opsserver/handlers/domain.handler.ts +18 -0
  28. package/ts/opsserver/handlers/security.handler.ts +27 -23
  29. package/ts/opsserver/handlers/stats.handler.ts +22 -2
  30. package/ts_web/00_commitinfo_data.ts +1 -1
  31. package/ts_web/appstate.ts +74 -57
  32. package/ts_web/elements/domains/ops-view-domains.ts +97 -0
  33. package/ts_web/elements/network/ops-view-network-activity.ts +67 -132
@@ -296,70 +296,99 @@ export class DnsManager {
296
296
  }
297
297
 
298
298
  /**
299
- * True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
300
- * to decide whether to wire SmartAcme with a DNS-01 handler.
299
+ * Find the DomainDoc that covers a given FQDN, regardless of source
300
+ * (dcrouter-hosted or provider-managed). Uses longest-suffix match.
301
301
  */
302
- public async hasAcmeCapableProvider(): Promise<boolean> {
303
- const providers = await DnsProviderDoc.findAll();
304
- return providers.length > 0;
302
+ public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
303
+ const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
304
+ const allDomains = await DomainDoc.findAll();
305
+ // Sort by name length descending for longest-match-wins
306
+ allDomains.sort((a, b) => b.name.length - a.name.length);
307
+ for (const domain of allDomains) {
308
+ if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
309
+ return domain;
310
+ }
311
+ }
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * Delete all DNS records matching a name and type under a domain.
317
+ * Used for ACME challenge cleanup (may have multiple TXT records at the same name).
318
+ */
319
+ public async deleteRecordsByNameAndType(
320
+ domainId: string,
321
+ name: string,
322
+ type: TDnsRecordType,
323
+ ): Promise<void> {
324
+ const records = await DnsRecordDoc.findByDomainId(domainId);
325
+ for (const rec of records) {
326
+ if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
327
+ await this.deleteRecord(rec.id);
328
+ }
329
+ }
330
+ }
331
+
332
+ /**
333
+ * True if any domain is under management (dcrouter-hosted or provider-managed).
334
+ * Used by setupSmartProxy() to decide whether to wire SmartAcme with a DNS-01 handler.
335
+ */
336
+ public async hasAnyManagedDomain(): Promise<boolean> {
337
+ const domains = await DomainDoc.findAll();
338
+ return domains.length > 0;
305
339
  }
306
340
 
307
341
  /**
308
- * Build an IConvenientDnsProvider that dispatches each ACME challenge to
309
- * the right provider client (whichever provider type owns the parent zone),
310
- * based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
311
- * interface, so any registered provider implementation works.
312
- * Returned object plugs directly into smartacme's Dns01Handler.
342
+ * Build an IConvenientDnsProvider that routes ACME DNS-01 challenges through
343
+ * the DnsManager abstraction. Challenges are dispatched via createRecord() /
344
+ * deleteRecord(), which transparently handle both dcrouter-hosted zones
345
+ * (embedded DnsServer) and provider-managed zones (e.g. Cloudflare API).
346
+ *
347
+ * Only domains under management (with a DomainDoc in DB) are supported —
348
+ * this acts as the management gate for certificate issuance.
313
349
  */
314
350
  public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
315
351
  const self = this;
316
352
  const adapter = {
317
353
  async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
318
- const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
319
- if (!client) {
354
+ const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
355
+ if (!domainDoc) {
320
356
  throw new Error(
321
- `DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
322
- 'Add one in the Domains > Providers UI before issuing certificates.',
357
+ `DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` +
358
+ 'Add the domain in Domains before issuing certificates.',
323
359
  );
324
360
  }
325
- // Clean any leftover challenge records first to avoid duplicates.
361
+ // Clean leftover challenge records first to avoid duplicates.
326
362
  try {
327
- const existing = await client.listRecords(dnsChallenge.hostName);
328
- for (const r of existing) {
329
- if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
330
- await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
331
- }
332
- }
363
+ await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
333
364
  } catch (err: unknown) {
334
365
  logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
335
366
  }
336
- await client.createRecord(dnsChallenge.hostName, {
367
+ // Create the challenge TXT record via the unified path
368
+ await self.createRecord({
369
+ domainId: domainDoc.id,
337
370
  name: dnsChallenge.hostName,
338
371
  type: 'TXT',
339
372
  value: dnsChallenge.challenge,
340
373
  ttl: 120,
374
+ createdBy: 'acme-dns01',
341
375
  });
342
376
  },
343
377
  async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
344
- const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
345
- if (!client) {
378
+ const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
379
+ if (!domainDoc) {
346
380
  // The domain may have been removed; nothing to clean up.
347
381
  return;
348
382
  }
349
383
  try {
350
- const existing = await client.listRecords(dnsChallenge.hostName);
351
- for (const r of existing) {
352
- if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
353
- await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
354
- }
355
- }
384
+ await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
356
385
  } catch (err: unknown) {
357
386
  logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
358
387
  }
359
388
  },
360
389
  async isDomainSupported(domain: string): Promise<boolean> {
361
- const client = await self.getProviderClientForDomain(domain);
362
- return !!client;
390
+ const domainDoc = await self.findDomainForFqdn(domain);
391
+ return !!domainDoc;
363
392
  },
364
393
  };
365
394
  return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
@@ -642,6 +671,151 @@ export class DnsManager {
642
671
  return await DnsRecordDoc.findById(id);
643
672
  }
644
673
 
674
+ // ==========================================================================
675
+ // Domain migration
676
+ // ==========================================================================
677
+
678
+ /**
679
+ * Migrate a domain between dcrouter-hosted and provider-managed.
680
+ * Transfers all records to the target and updates domain metadata.
681
+ */
682
+ public async migrateDomain(args: {
683
+ id: string;
684
+ targetSource: 'dcrouter' | 'provider';
685
+ targetProviderId?: string;
686
+ deleteExistingProviderRecords?: boolean;
687
+ }): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
688
+ const domain = await DomainDoc.findById(args.id);
689
+ if (!domain) return { success: false, message: 'Domain not found' };
690
+
691
+ if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) {
692
+ return { success: false, message: 'Domain is already in the target configuration' };
693
+ }
694
+
695
+ const records = await DnsRecordDoc.findByDomainId(domain.id);
696
+
697
+ if (args.targetSource === 'provider') {
698
+ return this.migrateToDnsProvider(domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false);
699
+ } else {
700
+ return this.migrateToDcrouter(domain, records);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Migrate domain from dcrouter-hosted (or another provider) to an external DNS provider.
706
+ */
707
+ private async migrateToDnsProvider(
708
+ domain: DomainDoc,
709
+ records: DnsRecordDoc[],
710
+ targetProviderId: string,
711
+ deleteExistingProviderRecords: boolean,
712
+ ): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
713
+ // Validate the target provider exists
714
+ const client = await this.getProviderClientById(targetProviderId);
715
+ if (!client) {
716
+ return { success: false, message: 'Target DNS provider not found' };
717
+ }
718
+
719
+ // Find the zone at the provider
720
+ const providerDomains = await client.listDomains();
721
+ const zone = providerDomains.find(
722
+ (z) => z.name.toLowerCase() === domain.name.toLowerCase(),
723
+ );
724
+ if (!zone) {
725
+ return { success: false, message: `Zone "${domain.name}" not found at the target provider` };
726
+ }
727
+
728
+ // Optionally delete existing records at the provider
729
+ if (deleteExistingProviderRecords) {
730
+ try {
731
+ const existingProviderRecords = await client.listRecords(domain.name);
732
+ for (const pr of existingProviderRecords) {
733
+ await client.deleteRecord(domain.name, pr.providerRecordId).catch(() => {});
734
+ }
735
+ logger.log('info', `Deleted ${existingProviderRecords.length} existing records at provider for ${domain.name}`);
736
+ } catch (err: unknown) {
737
+ logger.log('warn', `Failed to clean existing provider records for ${domain.name}: ${(err as Error).message}`);
738
+ }
739
+ }
740
+
741
+ // Push each local record to the provider
742
+ let migrated = 0;
743
+ for (const rec of records) {
744
+ try {
745
+ const providerRecord = await client.createRecord(domain.name, {
746
+ name: rec.name,
747
+ type: rec.type as any,
748
+ value: rec.value,
749
+ ttl: rec.ttl,
750
+ });
751
+ // Unregister from embedded DnsServer if it was dcrouter-hosted
752
+ if (domain.source === 'dcrouter') {
753
+ this.unregisterRecordFromDnsServer(rec);
754
+ }
755
+ // Update the record doc to synced
756
+ rec.source = 'synced' as TDnsRecordSource;
757
+ rec.providerRecordId = providerRecord.providerRecordId;
758
+ await rec.save();
759
+ migrated++;
760
+ } catch (err: unknown) {
761
+ logger.log('warn', `Failed to migrate record ${rec.name} ${rec.type} to provider: ${(err as Error).message}`);
762
+ }
763
+ }
764
+
765
+ // Update domain metadata
766
+ domain.source = 'provider';
767
+ domain.authoritative = false;
768
+ domain.providerId = targetProviderId;
769
+ domain.externalZoneId = zone.externalId;
770
+ domain.nameservers = zone.nameservers;
771
+ domain.lastSyncedAt = Date.now();
772
+ domain.updatedAt = Date.now();
773
+ await domain.save();
774
+
775
+ logger.log('info', `Domain ${domain.name} migrated to provider (${migrated} records)`);
776
+ return { success: true, recordsMigrated: migrated };
777
+ }
778
+
779
+ /**
780
+ * Migrate domain from provider-managed to dcrouter-hosted (authoritative).
781
+ */
782
+ private async migrateToDcrouter(
783
+ domain: DomainDoc,
784
+ records: DnsRecordDoc[],
785
+ ): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
786
+ // Register each record with the embedded DnsServer
787
+ let migrated = 0;
788
+ for (const rec of records) {
789
+ try {
790
+ this.registerRecordWithDnsServer(rec);
791
+ // Update the record doc to local
792
+ rec.source = 'local' as TDnsRecordSource;
793
+ rec.providerRecordId = undefined;
794
+ await rec.save();
795
+ migrated++;
796
+ } catch (err: unknown) {
797
+ logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`);
798
+ }
799
+ }
800
+
801
+ // Update domain metadata
802
+ domain.source = 'dcrouter';
803
+ domain.authoritative = true;
804
+ domain.providerId = undefined;
805
+ domain.externalZoneId = undefined;
806
+ domain.nameservers = undefined;
807
+ domain.lastSyncedAt = undefined;
808
+ domain.updatedAt = Date.now();
809
+ await domain.save();
810
+
811
+ logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
812
+ return { success: true, recordsMigrated: migrated };
813
+ }
814
+
815
+ // ==========================================================================
816
+ // Record CRUD
817
+ // ==========================================================================
818
+
645
819
  public async createRecord(args: {
646
820
  domainId: string;
647
821
  name: string;
@@ -759,14 +933,24 @@ export class DnsManager {
759
933
  }
760
934
  }
761
935
  }
762
- // For local records: smartdns has no unregister API in the pinned version,
763
- // so the record stays served until the next restart. The DB delete still
764
- // takes effect on restart, the record will not be re-registered.
936
+ // For dcrouter-hosted records: unregister the handler from the embedded DnsServer
937
+ // so the record stops being served immediately (not just after restart).
938
+ if (domain.source === 'dcrouter' && this.dnsServer) {
939
+ this.unregisterRecordFromDnsServer(doc);
940
+ }
765
941
 
766
942
  await doc.delete();
767
943
  return { success: true };
768
944
  }
769
945
 
946
+ /**
947
+ * Unregister a record's handler from the embedded DnsServer.
948
+ */
949
+ public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
950
+ if (!this.dnsServer) return;
951
+ this.dnsServer.unregisterHandler(rec.name, [rec.type]);
952
+ }
953
+
770
954
  // ==========================================================================
771
955
  // Internal helpers
772
956
  // ==========================================================================
@@ -553,12 +553,14 @@ export class MetricsManager {
553
553
  connectionsByIP: new Map<string, number>(),
554
554
  throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
555
555
  topIPs: [] as Array<{ ip: string; count: number }>,
556
+ topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
556
557
  totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
557
558
  throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
558
559
  throughputByIP: new Map<string, { in: number; out: number }>(),
559
560
  requestsPerSecond: 0,
560
561
  requestsTotal: 0,
561
562
  backends: [] as Array<any>,
563
+ domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
562
564
  };
563
565
  }
564
566
 
@@ -572,7 +574,7 @@ export class MetricsManager {
572
574
  bytesOutPerSecond: instantThroughput.out
573
575
  };
574
576
 
575
- // Get top IPs
577
+ // Get top IPs by connection count
576
578
  const topIPs = proxyMetrics.connections.topIPs(10);
577
579
 
578
580
  // Get total data transferred
@@ -699,10 +701,83 @@ export class MetricsManager {
699
701
  }
700
702
  }
701
703
 
704
+ // Build top 10 IPs by bandwidth (sorted by total throughput desc)
705
+ const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
706
+ for (const [ip, count] of connectionsByIP) {
707
+ allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
708
+ }
709
+ for (const [ip, tp] of throughputByIP) {
710
+ const existing = allIPData.get(ip);
711
+ if (existing) {
712
+ existing.bwIn = tp.in;
713
+ existing.bwOut = tp.out;
714
+ } else {
715
+ allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
716
+ }
717
+ }
718
+ const topIPsByBandwidth = Array.from(allIPData.entries())
719
+ .sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
720
+ .slice(0, 10)
721
+ .map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
722
+
723
+ // Build domain activity from per-route metrics
724
+ const connectionsByRoute = proxyMetrics.connections.byRoute();
725
+ const throughputByRoute = proxyMetrics.throughput.byRoute();
726
+
727
+ // Map route name → primary domain using dcrouter's route configs
728
+ const routeToDomain = new Map<string, string>();
729
+ if (this.dcRouter.smartProxy) {
730
+ for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
731
+ if (!route.name || !route.match.domains) continue;
732
+ const domains = Array.isArray(route.match.domains)
733
+ ? route.match.domains
734
+ : [route.match.domains];
735
+ if (domains.length > 0) {
736
+ routeToDomain.set(route.name, domains[0]);
737
+ }
738
+ }
739
+ }
740
+
741
+ // Aggregate metrics by domain
742
+ const domainAgg = new Map<string, {
743
+ activeConnections: number;
744
+ bytesInPerSec: number;
745
+ bytesOutPerSec: number;
746
+ routeCount: number;
747
+ }>();
748
+ for (const [routeName, activeConns] of connectionsByRoute) {
749
+ const domain = routeToDomain.get(routeName) || routeName;
750
+ const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
751
+ const existing = domainAgg.get(domain);
752
+ if (existing) {
753
+ existing.activeConnections += activeConns;
754
+ existing.bytesInPerSec += tp.in;
755
+ existing.bytesOutPerSec += tp.out;
756
+ existing.routeCount++;
757
+ } else {
758
+ domainAgg.set(domain, {
759
+ activeConnections: activeConns,
760
+ bytesInPerSec: tp.in,
761
+ bytesOutPerSec: tp.out,
762
+ routeCount: 1,
763
+ });
764
+ }
765
+ }
766
+ const domainActivity = Array.from(domainAgg.entries())
767
+ .map(([domain, data]) => ({
768
+ domain,
769
+ bytesInPerSecond: data.bytesInPerSec,
770
+ bytesOutPerSecond: data.bytesOutPerSec,
771
+ activeConnections: data.activeConnections,
772
+ routeCount: data.routeCount,
773
+ }))
774
+ .sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
775
+
702
776
  return {
703
777
  connectionsByIP,
704
778
  throughputRate,
705
779
  topIPs,
780
+ topIPsByBandwidth,
706
781
  totalDataTransferred,
707
782
  throughputHistory,
708
783
  throughputByIP,
@@ -711,6 +786,7 @@ export class MetricsManager {
711
786
  backends,
712
787
  frontendProtocols,
713
788
  backendProtocols,
789
+ domainActivity,
714
790
  };
715
791
  }, 1000); // 1s cache — matches typical dashboard poll interval
716
792
  }
@@ -127,7 +127,7 @@ export class ConfigHandler {
127
127
  // (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
128
128
  let dnsChallengeEnabled = false;
129
129
  try {
130
- dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
130
+ dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
131
131
  } catch {
132
132
  dnsChallengeEnabled = false;
133
133
  }
@@ -157,5 +157,23 @@ export class DomainHandler {
157
157
  },
158
158
  ),
159
159
  );
160
+
161
+ // Migrate domain between dcrouter-hosted and provider-managed
162
+ this.typedrouter.addTypedHandler(
163
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
164
+ 'migrateDomain',
165
+ async (dataArg) => {
166
+ await this.requireAuth(dataArg, 'domains:write');
167
+ const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
168
+ if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
169
+ return await dnsManager.migrateDomain({
170
+ id: dataArg.id,
171
+ targetSource: dataArg.targetSource,
172
+ targetProviderId: dataArg.targetProviderId,
173
+ deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords,
174
+ });
175
+ },
176
+ ),
177
+ );
160
178
  }
161
179
  }
@@ -51,8 +51,8 @@ export class SecurityHandler {
51
51
  startTime: conn.startTime,
52
52
  protocol: conn.type === 'http' ? 'https' : conn.type as any,
53
53
  state: conn.status as any,
54
- bytesReceived: Math.floor(conn.bytesTransferred / 2),
55
- bytesSent: Math.floor(conn.bytesTransferred / 2),
54
+ bytesReceived: (conn as any)._throughputIn || 0,
55
+ bytesSent: (conn as any)._throughputOut || 0,
56
56
  }));
57
57
 
58
58
  const summary = {
@@ -96,9 +96,11 @@ export class SecurityHandler {
96
96
  connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
97
97
  throughputRate: networkStats.throughputRate,
98
98
  topIPs: networkStats.topIPs,
99
+ topIPsByBandwidth: networkStats.topIPsByBandwidth,
99
100
  totalDataTransferred: networkStats.totalDataTransferred,
100
101
  throughputHistory: networkStats.throughputHistory || [],
101
102
  throughputByIP,
103
+ domainActivity: networkStats.domainActivity || [],
102
104
  requestsPerSecond: networkStats.requestsPerSecond || 0,
103
105
  requestsTotal: networkStats.requestsTotal || 0,
104
106
  backends: networkStats.backends || [],
@@ -110,9 +112,11 @@ export class SecurityHandler {
110
112
  connectionsByIP: [],
111
113
  throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
112
114
  topIPs: [],
115
+ topIPsByBandwidth: [],
113
116
  totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
114
117
  throughputHistory: [],
115
118
  throughputByIP: [],
119
+ domainActivity: [],
116
120
  requestsPerSecond: 0,
117
121
  requestsTotal: 0,
118
122
  backends: [],
@@ -251,31 +255,31 @@ export class SecurityHandler {
251
255
  const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
252
256
  const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
253
257
 
254
- // Use IP-based connection data from the new metrics API
258
+ // One aggregate row per IP with real throughput data
255
259
  if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
256
260
  let connIndex = 0;
257
261
  const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
258
-
262
+
259
263
  for (const [ip, count] of networkStats.connectionsByIP) {
260
- // Create a connection entry for each active IP connection
261
- for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
262
- connections.push({
263
- id: `conn-${connIndex++}`,
264
- type: 'http',
265
- source: {
266
- ip: ip,
267
- port: Math.floor(Math.random() * 50000) + 10000, // High port range
268
- },
269
- destination: {
270
- ip: publicIp,
271
- port: 443,
272
- service: 'proxy',
273
- },
274
- startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
275
- bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
276
- status: 'active',
277
- });
278
- }
264
+ const tp = networkStats.throughputByIP?.get(ip);
265
+ connections.push({
266
+ id: `ip-${connIndex++}`,
267
+ type: 'http',
268
+ source: {
269
+ ip: ip,
270
+ port: 0,
271
+ },
272
+ destination: {
273
+ ip: publicIp,
274
+ port: 443,
275
+ service: 'proxy',
276
+ },
277
+ startTime: 0,
278
+ bytesTransferred: count, // Store connection count here
279
+ status: 'active',
280
+ // Attach real throughput for the handler mapping
281
+ ...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
282
+ } as any);
279
283
  }
280
284
  } else if (connectionInfo.length > 0) {
281
285
  // Fallback to route-based connection info if no IP data available
@@ -291,6 +291,20 @@ export class StatsHandler {
291
291
  }
292
292
  }
293
293
 
294
+ // Build connectionDetails from real per-IP data
295
+ const connectionDetails: interfaces.data.IConnectionDetails[] = [];
296
+ for (const [ip, count] of stats.connectionsByIP) {
297
+ const tp = stats.throughputByIP?.get(ip);
298
+ connectionDetails.push({
299
+ remoteAddress: ip,
300
+ protocol: 'https',
301
+ state: 'connected',
302
+ startTime: 0,
303
+ bytesIn: tp?.in || 0,
304
+ bytesOut: tp?.out || 0,
305
+ });
306
+ }
307
+
294
308
  metrics.network = {
295
309
  totalBandwidth: {
296
310
  in: stats.throughputRate.bytesInPerSecond,
@@ -301,12 +315,18 @@ export class StatsHandler {
301
315
  out: stats.totalDataTransferred.bytesOut,
302
316
  },
303
317
  activeConnections: serverStats.activeConnections,
304
- connectionDetails: [],
318
+ connectionDetails,
305
319
  topEndpoints: stats.topIPs.map(ip => ({
306
320
  endpoint: ip.ip,
307
- requests: ip.count,
321
+ connections: ip.count,
308
322
  bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
309
323
  })),
324
+ topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
325
+ endpoint: ip.ip,
326
+ connections: ip.count,
327
+ bandwidth: { in: ip.bwIn, out: ip.bwOut },
328
+ })),
329
+ domainActivity: stats.domainActivity || [],
310
330
  throughputHistory: stats.throughputHistory || [],
311
331
  requestsPerSecond: stats.requestsPerSecond || 0,
312
332
  requestsTotal: stats.requestsTotal || 0,
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.12.0',
6
+ version: '13.14.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }