diff --git a/.playwright-mcp/console-2026-02-23T12-47-06-007Z.log b/.playwright-mcp/console-2026-02-23T12-47-06-007Z.log new file mode 100644 index 0000000..6181b52 --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T12-47-06-007Z.log @@ -0,0 +1,31 @@ +[ 75ms] TypeError: Cannot read properties of null (reading 'appendChild') + at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21) + at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10) + at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23) + at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9) +[ 763ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0 +[ 22315ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 22315ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 22316ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 22316ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 22321ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13 +[ 22322ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 22322ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 22322ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13 +[ 65371ms] [ERROR] method: >>createApiToken<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13 +[ 65371ms] [ERROR] Failed to create token: zs @ http://localhost:3000/bundle.js:38142 diff --git a/.playwright-mcp/console-2026-02-23T12-48-31-563Z.log b/.playwright-mcp/console-2026-02-23T12-48-31-563Z.log new file mode 100644 index 0000000..566053d --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T12-48-31-563Z.log @@ -0,0 +1,25 @@ +[ 642ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0 +[ 114916ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0 +[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 179731ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 179732ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 179737ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13 +[ 179738ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 179738ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 179738ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13 diff --git a/.playwright-mcp/console-2026-02-23T12-53-33-702Z.log b/.playwright-mcp/console-2026-02-23T12-53-33-702Z.log new file mode 100644 index 0000000..8c2ceb7 --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T12-53-33-702Z.log @@ -0,0 +1 @@ +[ 603ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0 diff --git a/.playwright-mcp/console-2026-02-23T12-55-40-311Z.log b/.playwright-mcp/console-2026-02-23T12-55-40-311Z.log new file mode 100644 index 0000000..c1912c6 --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T12-55-40-311Z.log @@ -0,0 +1,24 @@ +[ 308ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 309ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 309ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 310ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 349ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13 +[ 350ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 350ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 351ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13 +[ 500ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/apitokens:0 diff --git a/.playwright-mcp/console-2026-02-23T12-57-47-953Z.log b/.playwright-mcp/console-2026-02-23T12-57-47-953Z.log new file mode 100644 index 0000000..9945518 --- /dev/null +++ b/.playwright-mcp/console-2026-02-23T12-57-47-953Z.log @@ -0,0 +1,30 @@ +[ 427ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0 +[ 44124ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203 +[ 59106ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 59106ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 59107ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 59107ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 59116ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 59116ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 +[ 89192ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass + at N.updated (http://localhost:3000/bundle.js:1204:736) + at N._$AE (http://localhost:3000/bundle.js:1:9837) + at N.performUpdate (http://localhost:3000/bundle.js:1:9701) + at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170) + at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203 +[ 89192ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174 diff --git a/changelog.md b/changelog.md index a2bbfbb..d92cbbc 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-23 - 9.0.0 - BREAKING CHANGE(opsserver) +Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections + +- Introduce IConfigData interface with typed sections: system, smartProxy, email, dns, tls, cache, radius, remoteIngress. +- Replace ConfigHandler.getConfiguration implementation to assemble and return IConfigData (changes API response shape for getConfiguration). +- Refactor frontend: update appstate types and ops-view-config to render the new config sections, use @serve.zone/catalog IConfigField/IConfigSectionAction, add uptime formatting and remote ingress UI. +- Fix ops-view-apitokens form handling to correctly read dees-input-tags values. +- Update tests to expect new configuration fields. +- Bump dependency @serve.zone/catalog to ^2.5.0. + ## 2026-02-23 - 8.1.0 - feat(route-management) add programmatic route management API with API tokens and admin UI diff --git a/package.json b/package.json index 5ebc136..01ce09e 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartstate": "^2.0.30", "@push.rocks/smartunique": "^3.0.9", - "@serve.zone/catalog": "^2.3.0", + "@serve.zone/catalog": "^2.5.0", "@serve.zone/interfaces": "^5.3.0", "@serve.zone/remoteingress": "^4.0.0", "@tsclass/tsclass": "^9.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69231f7..8ea598c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^3.0.9 version: 3.0.9 '@serve.zone/catalog': - specifier: ^2.3.0 - version: 2.3.0(@tiptap/pm@2.27.2) + specifier: ^2.5.0 + version: 2.5.0(@tiptap/pm@2.27.2) '@serve.zone/interfaces': specifier: ^5.3.0 version: 5.3.0 @@ -1333,8 +1333,8 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@serve.zone/catalog@2.3.0': - resolution: {integrity: sha512-KCIQZXBO5A93VIsRkI/UzApNImEHzuA7P3Wx33+mDVUZ8/I5hafuCLgPzNu1q/TgQUte+q6I6e5Erezc9Hn74Q==} + '@serve.zone/catalog@2.5.0': + resolution: {integrity: sha512-bRwk7pbDxUB471wUAS7p22MTOOBCHlMWijsE43K9tDAPcxlRarhtf2Dgx0Y25s/dFXqj2JHwe6jjE84S80jFzg==} '@serve.zone/interfaces@5.3.0': resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} @@ -6785,7 +6785,7 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 - '@serve.zone/catalog@2.3.0(@tiptap/pm@2.27.2)': + '@serve.zone/catalog@2.5.0(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-catalog': 3.43.2(@tiptap/pm@2.27.2) '@design.estate/dees-domtools': 2.3.8 diff --git a/test/test.opsserver-api.ts b/test/test.opsserver-api.ts index 25cbe79..a73e528 100644 --- a/test/test.opsserver-api.ts +++ b/test/test.opsserver-api.ts @@ -55,10 +55,14 @@ tap.test('should respond to configuration request', async () => { const response = await configRequest.fire({}); expect(response).toHaveProperty('config'); + expect(response.config).toHaveProperty('system'); + expect(response.config).toHaveProperty('smartProxy'); expect(response.config).toHaveProperty('email'); expect(response.config).toHaveProperty('dns'); - expect(response.config).toHaveProperty('proxy'); - expect(response.config).toHaveProperty('security'); + expect(response.config).toHaveProperty('tls'); + expect(response.config).toHaveProperty('cache'); + expect(response.config).toHaveProperty('radius'); + expect(response.config).toHaveProperty('remoteIngress'); }); tap.test('should handle log retrieval request', async () => { diff --git a/test/test.protected-endpoint.ts b/test/test.protected-endpoint.ts index e50304d..65c23e9 100644 --- a/test/test.protected-endpoint.ts +++ b/test/test.protected-endpoint.ts @@ -106,10 +106,14 @@ tap.test('should allow read-only config access', async () => { const response = await configRequest.fire({}); expect(response).toHaveProperty('config'); + expect(response.config).toHaveProperty('system'); + expect(response.config).toHaveProperty('smartProxy'); expect(response.config).toHaveProperty('email'); expect(response.config).toHaveProperty('dns'); - expect(response.config).toHaveProperty('proxy'); - expect(response.config).toHaveProperty('security'); + expect(response.config).toHaveProperty('tls'); + expect(response.config).toHaveProperty('cache'); + expect(response.config).toHaveProperty('radius'); + expect(response.config).toHaveProperty('remoteIngress'); console.log('Configuration read successfully'); }); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 869f310..00869fe 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '8.1.0', + version: '9.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/opsserver/handlers/config.handler.ts b/ts/opsserver/handlers/config.handler.ts index a2d6c71..1e0f38a 100644 --- a/ts/opsserver/handlers/config.handler.ts +++ b/ts/opsserver/handlers/config.handler.ts @@ -1,4 +1,5 @@ import * as plugins from '../../plugins.js'; +import * as paths from '../../paths.js'; import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; @@ -17,7 +18,7 @@ export class ConfigHandler { new plugins.typedrequest.TypedHandler( 'getConfiguration', async (dataArg, toolsArg) => { - const config = await this.getConfiguration(dataArg.section); + const config = await this.getConfiguration(); return { config, section: dataArg.section, @@ -26,83 +27,164 @@ export class ConfigHandler { ) ); } - - private async getConfiguration(section?: string): Promise<{ - email: { - enabled: boolean; - ports: number[]; - maxMessageSize: number; - rateLimits: { - perMinute: number; - perHour: number; - perDay: number; - }; - domains?: string[]; - }; - dns: { - enabled: boolean; - port: number; - nameservers: string[]; - caching: boolean; - ttl: number; - }; - proxy: { - enabled: boolean; - httpPort: number; - httpsPort: number; - maxConnections: number; - }; - security: { - blockList: string[]; - rateLimit: boolean; - spamDetection: boolean; - tlsRequired: boolean; - }; - }> { + + private async getConfiguration(): Promise { const dcRouter = this.opsServerRef.dcRouterRef; - - // Get email domains if email server is configured + const opts = dcRouter.options; + const resolvedPaths = dcRouter.resolvedPaths; + + // --- System --- + const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction + ? 'custom' + : opts.storage?.fsPath + ? 'filesystem' + : 'memory'; + + const system: interfaces.requests.IConfigData['system'] = { + baseDir: resolvedPaths.dcrouterHomeDir, + dataDir: resolvedPaths.dataDir, + publicIp: opts.publicIp || null, + proxyIps: opts.proxyIps || [], + uptime: Math.floor(process.uptime()), + storageBackend, + storagePath: opts.storage?.fsPath || null, + }; + + // --- SmartProxy --- + let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null; + if (opts.smartProxyConfig?.acme) { + const acme = opts.smartProxyConfig.acme; + acmeInfo = { + enabled: acme.enabled !== false, + accountEmail: acme.accountEmail || '', + useProduction: acme.useProduction !== false, + autoRenew: acme.autoRenew !== false, + renewThresholdDays: acme.renewThresholdDays || 30, + }; + } + + let routeCount = 0; + if (dcRouter.routeConfigManager) { + try { + const merged = await dcRouter.routeConfigManager.getMergedRoutes(); + routeCount = merged.routes.length; + } catch { + routeCount = opts.smartProxyConfig?.routes?.length || 0; + } + } else if (opts.smartProxyConfig?.routes) { + routeCount = opts.smartProxyConfig.routes.length; + } + + const smartProxy: interfaces.requests.IConfigData['smartProxy'] = { + enabled: !!dcRouter.smartProxy, + routeCount, + acme: acmeInfo, + }; + + // --- Email --- let emailDomains: string[] = []; - if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) { - emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains(); - } else if (dcRouter.options.emailConfig?.domains) { - // Fallback: get domains from email config options - emailDomains = dcRouter.options.emailConfig.domains.map(d => + if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) { + emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains(); + } else if (opts.emailConfig?.domains) { + emailDomains = opts.emailConfig.domains.map((d: any) => typeof d === 'string' ? d : d.domain ); } - + + let portMapping: Record | null = null; + if (opts.emailPortConfig?.portMapping) { + portMapping = {}; + for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) { + portMapping[String(ext)] = int as number; + } + } + + const email: interfaces.requests.IConfigData['email'] = { + enabled: !!dcRouter.emailServer, + ports: opts.emailConfig?.ports || [], + portMapping, + hostname: opts.emailConfig?.hostname || null, + domains: emailDomains, + emailRouteCount: opts.emailConfig?.routes?.length || 0, + receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null, + }; + + // --- DNS --- + const dnsRecords = (opts.dnsRecords || []).map(r => ({ + name: r.name, + type: r.type, + value: r.value, + ttl: r.ttl, + })); + + const dns: interfaces.requests.IConfigData['dns'] = { + enabled: !!dcRouter.dnsServer, + port: 53, + nsDomains: opts.dnsNsDomains || [], + scopes: opts.dnsScopes || [], + recordCount: dnsRecords.length, + records: dnsRecords, + dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey, + }; + + // --- TLS --- + let tlsSource: 'acme' | 'static' | 'none' = 'none'; + if (opts.tls?.certPath && opts.tls?.keyPath) { + tlsSource = 'static'; + } else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) { + tlsSource = 'acme'; + } + + const tls: interfaces.requests.IConfigData['tls'] = { + contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null, + domain: opts.tls?.domain || null, + source: tlsSource, + certPath: opts.tls?.certPath || null, + keyPath: opts.tls?.keyPath || null, + }; + + // --- Cache --- + const cacheConfig = opts.cacheConfig; + const cache: interfaces.requests.IConfigData['cache'] = { + enabled: cacheConfig?.enabled !== false, + storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath, + dbName: cacheConfig?.dbName || 'dcrouter', + defaultTTLDays: cacheConfig?.defaultTTLDays || 30, + cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1, + ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record : {}, + }; + + // --- RADIUS --- + const radiusCfg = opts.radiusConfig; + const radius: interfaces.requests.IConfigData['radius'] = { + enabled: !!dcRouter.radiusServer, + authPort: radiusCfg?.authPort || null, + acctPort: radiusCfg?.acctPort || null, + bindAddress: radiusCfg?.bindAddress || null, + clientCount: radiusCfg?.clients?.length || 0, + vlanDefaultVlan: radiusCfg?.vlanAssignment?.defaultVlan ?? null, + vlanAllowUnknownMacs: radiusCfg?.vlanAssignment?.allowUnknownMacs ?? null, + vlanMappingCount: radiusCfg?.vlanAssignment?.mappings?.length || 0, + }; + + // --- Remote Ingress --- + const riCfg = opts.remoteIngressConfig; + const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = { + enabled: !!dcRouter.remoteIngressManager, + tunnelPort: riCfg?.tunnelPort || null, + hubDomain: riCfg?.hubDomain || null, + tlsConfigured: !!(riCfg?.tls?.certPath && riCfg?.tls?.keyPath), + }; + return { - email: { - enabled: !!dcRouter.emailServer, - ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [], - maxMessageSize: 10 * 1024 * 1024, // 10MB default - rateLimits: { - perMinute: 10, - perHour: 100, - perDay: 1000, - }, - domains: emailDomains, - }, - dns: { - enabled: !!dcRouter.dnsServer, - port: 53, - nameservers: dcRouter.options.dnsNsDomains || [], - caching: true, - ttl: 300, - }, - proxy: { - enabled: !!dcRouter.smartProxy, - httpPort: 80, - httpsPort: 443, - maxConnections: 1000, - }, - security: { - blockList: [], - rateLimit: true, - spamDetection: true, - tlsRequired: false, - }, + system, + smartProxy, + email, + dns, + tls, + cache, + radius, + remoteIngress, }; } -} \ No newline at end of file +} diff --git a/ts_interfaces/requests/config.ts b/ts_interfaces/requests/config.ts index cf959e7..accb891 100644 --- a/ts_interfaces/requests/config.ts +++ b/ts_interfaces/requests/config.ts @@ -1,6 +1,78 @@ import * as plugins from '../plugins.js'; import * as authInterfaces from '../data/auth.js'; +export interface IConfigData { + system: { + baseDir: string; + dataDir: string; + publicIp: string | null; + proxyIps: string[]; + uptime: number; + storageBackend: 'filesystem' | 'custom' | 'memory'; + storagePath: string | null; + }; + smartProxy: { + enabled: boolean; + routeCount: number; + acme: { + enabled: boolean; + accountEmail: string; + useProduction: boolean; + autoRenew: boolean; + renewThresholdDays: number; + } | null; + }; + email: { + enabled: boolean; + ports: number[]; + portMapping: Record | null; + hostname: string | null; + domains: string[]; + emailRouteCount: number; + receivedEmailsPath: string | null; + }; + dns: { + enabled: boolean; + port: number; + nsDomains: string[]; + scopes: string[]; + recordCount: number; + records: Array<{ name: string; type: string; value: string; ttl?: number }>; + dnsChallenge: boolean; + }; + tls: { + contactEmail: string | null; + domain: string | null; + source: 'acme' | 'static' | 'none'; + certPath: string | null; + keyPath: string | null; + }; + cache: { + enabled: boolean; + storagePath: string | null; + dbName: string | null; + defaultTTLDays: number; + cleanupIntervalHours: number; + ttlConfig: Record; + }; + radius: { + enabled: boolean; + authPort: number | null; + acctPort: number | null; + bindAddress: string | null; + clientCount: number; + vlanDefaultVlan: number | null; + vlanAllowUnknownMacs: boolean | null; + vlanMappingCount: number; + }; + remoteIngress: { + enabled: boolean; + tunnelPort: number | null; + hubDomain: string | null; + tlsConfigured: boolean; + }; +} + // Get Configuration (read-only) export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR< plugins.typedrequestInterfaces.ITypedRequest, @@ -12,7 +84,7 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im section?: string; }; response: { - config: any; + config: IConfigData; section?: string; }; -} \ No newline at end of file +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 869f310..00869fe 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '8.1.0', + version: '9.0.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index c7606d6..ba0ff5b 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -21,7 +21,7 @@ export interface IStatsState { } export interface IConfigState { - config: any | null; + config: interfaces.requests.IConfigData | null; isLoading: boolean; error: string | null; } diff --git a/ts_web/elements/ops-dashboard.ts b/ts_web/elements/ops-dashboard.ts index e1d85f0..26e92b4 100644 --- a/ts_web/elements/ops-dashboard.ts +++ b/ts_web/elements/ops-dashboard.ts @@ -45,6 +45,10 @@ export class OpsDashboard extends DeesElement { name: 'Overview', element: OpsViewOverview, }, + { + name: 'Configuration', + element: OpsViewConfig, + }, { name: 'Network', element: OpsViewNetwork, @@ -65,10 +69,6 @@ export class OpsDashboard extends DeesElement { name: 'ApiTokens', element: OpsViewApiTokens, }, - { - name: 'Configuration', - element: OpsViewConfig, - }, { name: 'Security', element: OpsViewSecurity, diff --git a/ts_web/elements/ops-view-apitokens.ts b/ts_web/elements/ops-view-apitokens.ts index 2cc1974..a6d10a7 100644 --- a/ts_web/elements/ops-view-apitokens.ts +++ b/ts_web/elements/ops-view-apitokens.ts @@ -225,13 +225,17 @@ export class OpsViewApiTokens extends DeesElement { name: 'Create', iconName: 'lucide:key', action: async (modalArg: any) => { - const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + const contentEl = modalArg.shadowRoot?.querySelector('.content'); + const form = contentEl?.querySelector('dees-form'); if (!form) return; const formData = await form.collectFormData(); if (!formData.name) return; - // dees-input-tags returns string[] directly - const scopes = (formData.scopes || []) + // dees-input-tags is not in dees-form's FORM_INPUT_TYPES, so collectFormData() won't + // include it. Query the tags input directly and call getValue(). + const tagsInput = form.querySelector('dees-input-tags') as any; + const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || []; + const scopes = rawScopes .filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[]; const expiresInDays = formData.expiresInDays diff --git a/ts_web/elements/ops-view-config.ts b/ts_web/elements/ops-view-config.ts index b4519f6..9040072 100644 --- a/ts_web/elements/ops-view-config.ts +++ b/ts_web/elements/ops-view-config.ts @@ -1,6 +1,7 @@ import * as plugins from '../plugins.js'; import * as shared from './shared/index.js'; import * as appstate from '../appstate.js'; +import { appRouter } from '../router.js'; import { DeesElement, @@ -12,6 +13,8 @@ import { type TemplateResult, } from '@design.estate/dees-element'; +import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog'; + @customElement('ops-view-config') export class OpsViewConfig extends DeesElement { @state() @@ -35,165 +38,19 @@ export class OpsViewConfig extends DeesElement { cssManager.defaultStyles, shared.viewHostCss, css` - .configSection { - background: ${cssManager.bdTheme('#fff', '#222')}; - border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; - border-radius: 8px; - margin-bottom: 24px; - overflow: hidden; - } - - .sectionHeader { - background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; - padding: 16px 24px; - border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; - display: flex; - justify-content: space-between; - align-items: center; - } - - .sectionTitle { - font-size: 18px; - font-weight: 600; - color: ${cssManager.bdTheme('#333', '#ccc')}; - display: flex; - align-items: center; - gap: 12px; - } - - .sectionTitle dees-icon { - font-size: 20px; - color: ${cssManager.bdTheme('#666', '#888')}; - } - - .sectionContent { - padding: 24px; - } - - .configField { - margin-bottom: 20px; - } - - .configField:last-child { - margin-bottom: 0; - } - - .fieldLabel { - font-size: 13px; - font-weight: 600; - color: ${cssManager.bdTheme('#666', '#999')}; - margin-bottom: 8px; - display: block; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .fieldValue { - font-family: 'Consolas', 'Monaco', monospace; - font-size: 14px; - color: ${cssManager.bdTheme('#333', '#ccc')}; - background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; - padding: 10px 14px; - border-radius: 6px; - border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')}; - } - - .fieldValue.empty { - color: ${cssManager.bdTheme('#999', '#666')}; - font-style: italic; - } - - .nestedFields { - margin-left: 16px; - padding-left: 16px; - border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')}; - } - - /* Status badge styles */ - .statusBadge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - border-radius: 20px; - font-size: 13px; - font-weight: 600; - } - - .statusBadge.enabled { - background: ${cssManager.bdTheme('#d4edda', '#1a3d1a')}; - color: ${cssManager.bdTheme('#155724', '#66cc66')}; - } - - .statusBadge.disabled { - background: ${cssManager.bdTheme('#f8d7da', '#3d1a1a')}; - color: ${cssManager.bdTheme('#721c24', '#cc6666')}; - } - - .statusBadge dees-icon { - font-size: 14px; - } - - /* Array/list display */ - .arrayItems { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - .arrayItem { - display: inline-flex; - align-items: center; - background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')}; - color: ${cssManager.bdTheme('#0066cc', '#66aaff')}; - padding: 4px 12px; - border-radius: 16px; - font-size: 13px; - font-family: 'Consolas', 'Monaco', monospace; - } - - .arrayCount { - font-size: 12px; - color: ${cssManager.bdTheme('#999', '#666')}; - margin-bottom: 8px; - } - - /* Numeric value formatting */ - .numericValue { - font-weight: 600; - color: ${cssManager.bdTheme('#0066cc', '#66aaff')}; - } - - .errorMessage { - background: ${cssManager.bdTheme('#fee', '#4a1f1f')}; - border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')}; - border-radius: 4px; - padding: 16px; - color: ${cssManager.bdTheme('#c00', '#ff6666')}; - margin: 16px 0; - } - .loadingMessage { text-align: center; padding: 40px; - color: ${cssManager.bdTheme('#666', '#999')}; + color: ${cssManager.bdTheme('#71717a', '#a1a1aa')}; } - .infoNote { - background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')}; - border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')}; + .errorMessage { + background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.1)')}; + border: 1px solid ${cssManager.bdTheme('#fecaca', 'rgba(239,68,68,0.3)')}; border-radius: 8px; padding: 16px; - margin-bottom: 24px; - color: ${cssManager.bdTheme('#004085', '#88ccff')}; - display: flex; - align-items: center; - gap: 12px; - } - - .infoNote dees-icon { - font-size: 20px; - flex-shrink: 0; + color: ${cssManager.bdTheme('#dc2626', '#ef4444')}; + margin: 16px 0; } `, ]; @@ -202,185 +59,266 @@ export class OpsViewConfig extends DeesElement { return html` Configuration - ${this.configState.isLoading ? html` -
- -

Loading configuration...

-
- ` : this.configState.error ? html` -
- Error loading configuration: ${this.configState.error} -
- ` : this.configState.config ? html` -
- - This view displays the current running configuration. DcRouter is configured through code or remote management. -
- - ${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)} - ${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)} - ${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)} - ${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)} - ` : html` -
No configuration loaded
- `} - `; - } - - private renderConfigSection(key: string, title: string, icon: string, config: any) { - const isEnabled = config?.enabled ?? false; - - return html` -
-
-

- - ${title} -

- ${this.renderStatusBadge(isEnabled)} -
-
- ${config ? this.renderConfigFields(config) : html` -
Not configured
- `} -
-
- `; - } - - private renderStatusBadge(enabled: boolean): TemplateResult { - return enabled - ? html`Enabled` - : html`Disabled`; - } - - private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] { - if (!config || typeof config !== 'object') { - return html`
${this.formatValue(config)}
`; - } - - return Object.entries(config).map(([key, value]) => { - const fieldName = prefix ? `${prefix}.${key}` : key; - const displayName = this.formatFieldName(key); - - // Handle boolean values with badges - if (typeof value === 'boolean') { - return html` -
- - ${this.renderStatusBadge(value)} -
- `; - } - - // Handle arrays - if (Array.isArray(value)) { - return html` -
- - ${this.renderArrayValue(value, key)} -
- `; - } - - // Handle nested objects - if (typeof value === 'object' && value !== null) { - return html` -
- -
- ${this.renderConfigFields(value, fieldName)} + ${this.configState.isLoading + ? html` +
+ +

Loading configuration...

-
- `; - } - - // Handle primitive values - return html` -
- -
${this.formatValue(value, key)}
-
- `; - }); - } - - private renderArrayValue(arr: any[], fieldKey: string): TemplateResult { - if (arr.length === 0) { - return html`
None configured
`; - } - - // Determine if we should show as pills/tags - const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number'); - - if (showAsPills) { - const itemLabel = this.getArrayItemLabel(fieldKey, arr.length); - return html` -
${arr.length} ${itemLabel}
-
- ${arr.map(item => html`${item}`)} -
- `; - } - - // For complex arrays, show as JSON - return html` -
- ${arr.length} items configured -
+ ` + : this.configState.error + ? html` +
+ Error loading configuration: ${this.configState.error} +
+ ` + : this.configState.config + ? this.renderConfig() + : html`
No configuration loaded
`} `; } - private getArrayItemLabel(fieldKey: string, count: number): string { - const labels: Record = { - ports: ['port', 'ports'], - domains: ['domain', 'domains'], - nameservers: ['nameserver', 'nameservers'], - blockList: ['IP', 'IPs'], - }; + private renderConfig(): TemplateResult { + const cfg = this.configState.config!; - const label = labels[fieldKey] || ['item', 'items']; - return count === 1 ? label[0] : label[1]; + return html` + { + if (e.detail?.view) { + appRouter.navigateToView(e.detail.view); + } + }} + > + ${this.renderSystemSection(cfg.system)} + ${this.renderSmartProxySection(cfg.smartProxy)} + ${this.renderEmailSection(cfg.email)} + ${this.renderDnsSection(cfg.dns)} + ${this.renderTlsSection(cfg.tls)} + ${this.renderCacheSection(cfg.cache)} + ${this.renderRadiusSection(cfg.radius)} + ${this.renderRemoteIngressSection(cfg.remoteIngress)} + + `; } - private formatFieldName(key: string): string { - // Convert camelCase to readable format - return key - .replace(/([A-Z])/g, ' $1') - .replace(/^./, str => str.toUpperCase()) - .trim(); + private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Base Directory', value: sys.baseDir }, + { key: 'Data Directory', value: sys.dataDir }, + { key: 'Public IP', value: sys.publicIp }, + { key: 'Proxy IPs', value: sys.proxyIps.length > 0 ? sys.proxyIps : null, type: 'pills' }, + { key: 'Uptime', value: this.formatUptime(sys.uptime) }, + { key: 'Storage Backend', value: sys.storageBackend, type: 'badge' }, + { key: 'Storage Path', value: sys.storagePath }, + ]; + + return html` + + `; } - private formatValue(value: any, fieldKey?: string): string | TemplateResult { - if (value === null || value === undefined) { - return html`Not set`; + private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Route Count', value: proxy.routeCount }, + ]; + + if (proxy.acme) { + fields.push( + { key: 'ACME Enabled', value: proxy.acme.enabled, type: 'boolean' }, + { key: 'Account Email', value: proxy.acme.accountEmail || null }, + { key: 'Use Production', value: proxy.acme.useProduction, type: 'boolean' }, + { key: 'Auto Renew', value: proxy.acme.autoRenew, type: 'boolean' }, + { key: 'Renew Threshold', value: `${proxy.acme.renewThresholdDays} days` }, + ); } - if (typeof value === 'number') { - // Format bytes - if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) { - return html`${this.formatBytes(value)}`; - } - // Format time values - if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) { - return html`${value} seconds`; - } - // Format port numbers - if (fieldKey?.toLowerCase().includes('port')) { - return html`${value}`; - } - // Format counts with separators - return html`${value.toLocaleString()}`; - } + const actions: IConfigSectionAction[] = [ + { label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } }, + ]; - return String(value); + return html` + + `; } - private formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' }, + { key: 'Hostname', value: email.hostname }, + { key: 'Domains', value: email.domains.length > 0 ? email.domains : null, type: 'pills' }, + { key: 'Email Routes', value: email.emailRouteCount }, + { key: 'Received Emails Path', value: email.receivedEmailsPath }, + ]; + + if (email.portMapping) { + const mappingStr = Object.entries(email.portMapping) + .map(([ext, int]) => `${ext} → ${int}`) + .join(', '); + fields.splice(1, 0, { key: 'Port Mapping', value: mappingStr, type: 'code' }); + } + + const actions: IConfigSectionAction[] = [ + { label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } }, + ]; + + return html` + + `; + } + + private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Port', value: dns.port }, + { key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' }, + { key: 'Scopes', value: dns.scopes.length > 0 ? dns.scopes : null, type: 'pills' }, + { key: 'Record Count', value: dns.recordCount }, + { key: 'DNS Challenge', value: dns.dnsChallenge, type: 'boolean' }, + ]; + + return html` + + `; + } + + private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Contact Email', value: tls.contactEmail }, + { key: 'Domain', value: tls.domain }, + { key: 'Source', value: tls.source, type: 'badge' }, + { key: 'Certificate Path', value: tls.certPath }, + { key: 'Key Path', value: tls.keyPath }, + ]; + + const status = tls.source === 'none' ? 'not-configured' : 'enabled'; + const actions: IConfigSectionAction[] = [ + { label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } }, + ]; + + return html` + + `; + } + + private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Storage Path', value: cache.storagePath }, + { key: 'DB Name', value: cache.dbName }, + { key: 'Default TTL', value: `${cache.defaultTTLDays} days` }, + { key: 'Cleanup Interval', value: `${cache.cleanupIntervalHours} hours` }, + ]; + + if (cache.ttlConfig && Object.keys(cache.ttlConfig).length > 0) { + for (const [key, val] of Object.entries(cache.ttlConfig)) { + fields.push({ key: `TTL: ${key}`, value: `${val} days` }); + } + } + + return html` + + `; + } + + private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Auth Port', value: radius.authPort }, + { key: 'Accounting Port', value: radius.acctPort }, + { key: 'Bind Address', value: radius.bindAddress }, + { key: 'Client Count', value: radius.clientCount }, + ]; + + if (radius.vlanDefaultVlan !== null) { + fields.push( + { key: 'Default VLAN', value: radius.vlanDefaultVlan }, + { key: 'Allow Unknown MACs', value: radius.vlanAllowUnknownMacs, type: 'boolean' }, + { key: 'VLAN Mappings', value: radius.vlanMappingCount }, + ); + } + + const status = radius.enabled ? 'enabled' : 'not-configured'; + + return html` + + `; + } + + private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult { + const fields: IConfigField[] = [ + { key: 'Tunnel Port', value: ri.tunnelPort }, + { key: 'Hub Domain', value: ri.hubDomain }, + { key: 'TLS Configured', value: ri.tlsConfigured, type: 'boolean' }, + ]; + + const actions: IConfigSectionAction[] = [ + { label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } }, + ]; + + return html` + + `; + } + + private formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + parts.push(`${mins}m`); + return parts.join(' '); } }