Compare commits

..

8 Commits

Author SHA1 Message Date
2bd5e5c7c5 v9.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 21:34:50 +00:00
4d6ac81c59 BREAKING CHANGE(opsserver): Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections 2026-02-23 21:34:50 +00:00
2ebe0de92d v8.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-23 12:40:26 +00:00
f5028ffb60 feat(route-management): add programmatic route management API with API tokens and admin UI 2026-02-23 12:40:26 +00:00
90016d1217 v8.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-22 00:45:01 +00:00
48d3d1218f BREAKING CHANGE(email-ops): migrate email operations to catalog-compatible email model and simplify UI/router 2026-02-22 00:45:01 +00:00
4759c4f011 v7.4.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-21 23:36:10 +00:00
0fbd8d1cdd fix(logging): add adaptive rate-limited DNS query logging, flush pending DNS logs on shutdown, and enhance email delivery logging 2026-02-21 23:36:10 +00:00
48 changed files with 3201 additions and 1993 deletions

View File

@@ -0,0 +1,7 @@
[ 74ms] 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)
[ 587ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 697ms] [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/routes:0

View File

@@ -0,0 +1,12 @@
[ 669ms] [WARNING] Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information. @ http://localhost:3000/chunk-3L5NJTXF.js:13541
[ 729ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
[ 27973ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 27973ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 29975ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 29975ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 33977ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 33978ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 41980ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 41980ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
[ 51983ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
[ 51983ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141

View File

@@ -0,0 +1,6 @@
[ 55ms] 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)
[ 791ms] [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

View File

@@ -0,0 +1,50 @@
[ 272ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
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)
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
[ 272ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 274ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Pause-circle
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)
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
[ 274ms] [WARNING] Lucide icon 'Pause-circle' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 275ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
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
[ 275ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
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)
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
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
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 297ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 377ms] [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/routes:0
[ 78064ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 78237ms] [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/routes:0
[ 127969ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 127969ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 129695ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 129695ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 133309ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
[ 133309ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
[ 141762ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
[ 141910ms] [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/routes:0

View File

@@ -0,0 +1,23 @@
[ 437ms] [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
[ 38948ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
[ 52895ms] [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
[ 52896ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 52896ms] [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
[ 52897ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
[ 99401ms] [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
[ 99401ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,5 +1,45 @@
# Changelog # 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
- Introduce RouteConfigManager to persist and manage programmatic routes and hardcoded-route overrides
- Add ApiTokenManager to create, validate, list, toggle and revoke API tokens (stored hashed)
- New OpsServer TypedRequest handlers: RouteManagementHandler (getMergedRoutes, create/update/delete/toggle routes, set/remove overrides) and ApiTokenHandler (create/list/revoke/toggle tokens)
- DcRouter integration: initialize routeConfigManager and apiTokenManager, expose getConstructorRoutes and re-apply programmatic routes after SmartProxy restarts
- Front-end additions: new 'Routes' and 'ApiTokens' views and UI components (ops-view-routes, ops-view-apitokens), router and appstate actions to fetch/manage routes and tokens
- New TS interfaces and request types for route-management and API tokens, plus storage schemas for persisted routes, overrides and tokens
- Bump dependency @serve.zone/catalog to ^2.3.0
## 2026-02-22 - 8.0.0 - BREAKING CHANGE(email-ops)
migrate email operations to catalog-compatible email model and simplify UI/router
- Add @serve.zone/catalog dependency and import (szCatalog) in web plugins
- Replace queue-based typedrequest methods with catalog APIs: getQueuedEmails / getSentEmails / getFailedEmails => getAllEmails and getEmailDetail (request/response shapes changed)
- Update TypeScript interfaces: IEmailQueueItem/IBounceRecord/ISecurityIncident etc. replaced by IEmail, IEmailDetail, ISmtpLogEntry, IConnectionInfo, IAuthenticationResults (breaking type changes)
- Frontend state and actions consolidated: emailOps state now holds emails array; multiple fetch actions removed and replaced by fetchAllEmailsAction and getEmailDetail usage
- UI components updated: ops-view-emails switched to list/detail view and now requests email detail via new API; router no longer exposes email folder routes and email-folder navigation removed
- Ops server handler refactored to return catalog-style emails and email detail; added status mapping and size formatting helpers
## 2026-02-21 - 7.4.3 - fix(logging)
add adaptive rate-limited DNS query logging, flush pending DNS logs on shutdown, and enhance email delivery logging
- Introduce adaptive DNS logging: allow up to 2 individual DNS query logs per second, then aggregate further queries and emit a batched summary (dnsLogWindow, dnsBatchCount, dnsBatchTimer) with a 5s flush.
- Flush pending DNS batch on stop() and log final DNS batch count during shutdown.
- Enhance email observability by logging deliveryStart, deliverySuccess, deliveryFailed and bounceProcessed events alongside existing MetricsManager tracking.
- Dependency bump: @design.estate/dees-catalog updated from ^3.43.1 to ^3.43.2.
- Non-breaking change; intended as a patch release.
## 2026-02-21 - 7.4.2 - fix(monitoring,remoteingress,web) ## 2026-02-21 - 7.4.2 - fix(monitoring,remoteingress,web)
Prune old metrics buckets periodically, clear metrics caches on shutdown, simplify edge disconnect handling, and optimize network view data updates Prune old metrics buckets periodically, clear metrics caches on shutdown, simplify edge disconnect handling, and optimize network view data updates

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "7.4.2", "version": "9.0.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -32,7 +32,7 @@
"@api.global/typedserver": "^8.3.0", "@api.global/typedserver": "^8.3.0",
"@api.global/typedsocket": "^4.1.0", "@api.global/typedsocket": "^4.1.0",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.43.1", "@design.estate/dees-catalog": "^3.43.2",
"@design.estate/dees-element": "^2.1.6", "@design.estate/dees-element": "^2.1.6",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
@@ -55,6 +55,7 @@
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.0.30", "@push.rocks/smartstate": "^2.0.30",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@serve.zone/catalog": "^2.5.0",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.0.0", "@serve.zone/remoteingress": "^4.0.0",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.3.0",

31
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
'@design.estate/dees-catalog': '@design.estate/dees-catalog':
specifier: ^3.43.1 specifier: ^3.43.2
version: 3.43.1(@tiptap/pm@2.27.2) version: 3.43.2(@tiptap/pm@2.27.2)
'@design.estate/dees-element': '@design.estate/dees-element':
specifier: ^2.1.6 specifier: ^2.1.6
version: 2.1.6 version: 2.1.6
@@ -92,6 +92,9 @@ importers:
'@push.rocks/smartunique': '@push.rocks/smartunique':
specifier: ^3.0.9 specifier: ^3.0.9
version: 3.0.9 version: 3.0.9
'@serve.zone/catalog':
specifier: ^2.5.0
version: 2.5.0(@tiptap/pm@2.27.2)
'@serve.zone/interfaces': '@serve.zone/interfaces':
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
@@ -351,8 +354,8 @@ packages:
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.43.1': '@design.estate/dees-catalog@3.43.2':
resolution: {integrity: sha512-WAWOV8dIgdKfAbS4Ciek8oDVIWC0OSPODhpQdLlsGBXERcFaBPaYxcpywmrjXB/TFeoAQPxBxhS7jb9/p2Rprg==} resolution: {integrity: sha512-7pU+K+B70SxqR4DrBkpc/xvQGLDkxAV2jH7Qyh0TYgkCoxxjsxCuTMKM9JguA38wm6bEgBJVTvyg5S3wCwxm4Q==}
'@design.estate/dees-comms@1.0.30': '@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -1330,6 +1333,9 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@serve.zone/catalog@2.5.0':
resolution: {integrity: sha512-bRwk7pbDxUB471wUAS7p22MTOOBCHlMWijsE43K9tDAPcxlRarhtf2Dgx0Y25s/dFXqj2JHwe6jjE84S80jFzg==}
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.3.0':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
@@ -4334,7 +4340,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1) '@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
'@cloudflare/workers-types': 4.20260210.0 '@cloudflare/workers-types': 4.20260210.0
'@design.estate/dees-catalog': 3.43.1(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.43.2(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -4932,7 +4938,7 @@ snapshots:
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.43.1(@tiptap/pm@2.27.2)': '@design.estate/dees-catalog@3.43.2(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-domtools': 2.3.8 '@design.estate/dees-domtools': 2.3.8
'@design.estate/dees-element': 2.1.6 '@design.estate/dees-element': 2.1.6
@@ -6779,6 +6785,19 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
selderee: 0.11.0 selderee: 0.11.0
'@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
'@design.estate/dees-element': 2.1.6
'@design.estate/dees-wcctools': 3.8.0
transitivePeerDependencies:
- '@nuxt/kit'
- '@tiptap/pm'
- react
- supports-color
- vue
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.3.0':
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19

View File

@@ -55,10 +55,14 @@ tap.test('should respond to configuration request', async () => {
const response = await configRequest.fire({}); const response = await configRequest.fire({});
expect(response).toHaveProperty('config'); expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy');
expect(response.config).toHaveProperty('email'); expect(response.config).toHaveProperty('email');
expect(response.config).toHaveProperty('dns'); expect(response.config).toHaveProperty('dns');
expect(response.config).toHaveProperty('proxy'); expect(response.config).toHaveProperty('tls');
expect(response.config).toHaveProperty('security'); expect(response.config).toHaveProperty('cache');
expect(response.config).toHaveProperty('radius');
expect(response.config).toHaveProperty('remoteIngress');
}); });
tap.test('should handle log retrieval request', async () => { tap.test('should handle log retrieval request', async () => {

View File

@@ -106,10 +106,14 @@ tap.test('should allow read-only config access', async () => {
const response = await configRequest.fire({}); const response = await configRequest.fire({});
expect(response).toHaveProperty('config'); expect(response).toHaveProperty('config');
expect(response.config).toHaveProperty('system');
expect(response.config).toHaveProperty('smartProxy');
expect(response.config).toHaveProperty('email'); expect(response.config).toHaveProperty('email');
expect(response.config).toHaveProperty('dns'); expect(response.config).toHaveProperty('dns');
expect(response.config).toHaveProperty('proxy'); expect(response.config).toHaveProperty('tls');
expect(response.config).toHaveProperty('security'); expect(response.config).toHaveProperty('cache');
expect(response.config).toHaveProperty('radius');
expect(response.config).toHaveProperty('remoteIngress');
console.log('Configuration read successfully'); console.log('Configuration read successfully');
}); });

View File

@@ -1,21 +1,32 @@
import { DcRouter } from '../ts/index.js'; import { DcRouter } from '../ts/index.js';
const devRouter = new DcRouter({ const devRouter = new DcRouter({
// Configure services as needed for development // SmartProxy routes for development/demo
// OpsServer always starts on port 3000 smartProxyConfig: {
routes: [
// Example: Add SmartProxy routes {
// smartProxyConfig: { name: 'web-traffic',
// routes: [...] match: { ports: [18080], domains: ['example.com', '*.example.com'] },
// }, action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
},
// Example: Add email configuration {
// emailConfig: { name: 'api-gateway',
// ports: [2525], match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
// hostname: 'localhost', action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
// domains: [], },
// routes: [] {
// }, name: 'tls-passthrough',
match: { ports: [18443], domains: ['secure.example.com'] },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 4443 }],
tls: { mode: 'passthrough' },
},
},
],
},
// Disable cache/mongo for dev
cacheConfig: { enabled: false },
}); });
console.log('Starting DcRouter in development mode...'); console.log('Starting DcRouter in development mode...');

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '7.4.2', version: '9.0.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js'; import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
export interface IDcRouterOptions { export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -212,6 +213,15 @@ export class DcRouter {
public remoteIngressManager?: RemoteIngressManager; public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager; public tunnelManager?: TunnelManager;
// Programmatic config API
public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager;
// DNS query logging rate limiter state
private dnsLogWindow: number[] = [];
private dnsBatchCount: number = 0;
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
// Certificate status tracking from SmartProxy events (keyed by domain) // Certificate status tracking from SmartProxy events (keyed by domain)
public certificateStatusMap = new Map<string, { public certificateStatusMap = new Map<string, {
status: 'valid' | 'failed'; status: 'valid' | 'failed';
@@ -228,6 +238,9 @@ export class DcRouter {
// TypedRouter for API endpoints // TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access // Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/'); private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -271,6 +284,16 @@ export class DcRouter {
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes // Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
await this.setupSmartProxy(); await this.setupSmartProxy();
// Initialize programmatic config API managers
this.routeConfigManager = new RouteConfigManager(
this.storageManager,
() => this.getConstructorRoutes(),
() => this.smartProxy,
);
this.apiTokenManager = new ApiTokenManager(this.storageManager);
await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize();
// Set up unified email handling if configured // Set up unified email handling if configured
if (this.options.emailConfig) { if (this.options.emailConfig) {
await this.setupUnifiedEmailHandling(); await this.setupUnifiedEmailHandling();
@@ -438,6 +461,9 @@ export class DcRouter {
challengeHandlers.push(dns01Handler); challengeHandlers.push(dns01Handler);
} }
// Cache constructor routes for RouteConfigManager
this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it // If we have routes or need a basic SmartProxy instance, create it
if (routes.length > 0 || this.options.smartProxyConfig) { if (routes.length > 0 || this.options.smartProxyConfig) {
logger.log('info', 'Setting up SmartProxy with combined configuration'); logger.log('info', 'Setting up SmartProxy with combined configuration');
@@ -852,9 +878,28 @@ export class DcRouter {
return names; return names;
} }
/**
* Get the routes derived from constructor config (smartProxy + email + DNS).
* Used by RouteConfigManager as the "hardcoded" base.
*/
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
return this.constructorRoutes;
}
public async stop() { public async stop() {
logger.log('info', 'Stopping DcRouter services...'); logger.log('info', 'Stopping DcRouter services...');
// Flush pending DNS batch log
if (this.dnsBatchTimer) {
clearTimeout(this.dnsBatchTimer);
if (this.dnsBatchCount > 0) {
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited, final flush)`, { zone: 'dns' });
}
this.dnsBatchTimer = null;
this.dnsBatchCount = 0;
this.dnsLogWindow = [];
}
await this.opsServer.stop(); await this.opsServer.stop();
try { try {
@@ -913,6 +958,8 @@ export class DcRouter {
this.smartAcme = undefined; this.smartAcme = undefined;
this.certProvisionScheduler = undefined; this.certProvisionScheduler = undefined;
this.remoteIngressManager = undefined; this.remoteIngressManager = undefined;
this.routeConfigManager = undefined;
this.apiTokenManager = undefined;
this.certificateStatusMap.clear(); this.certificateStatusMap.clear();
logger.log('info', 'All DcRouter services stopped'); logger.log('info', 'All DcRouter services stopped');
@@ -944,6 +991,11 @@ export class DcRouter {
// Start new SmartProxy with updated configuration (will include email routes if configured) // Start new SmartProxy with updated configuration (will include email routes if configured)
await this.setupSmartProxy(); await this.setupSmartProxy();
// Re-apply programmatic routes and overrides after SmartProxy restart
if (this.routeConfigManager) {
await this.routeConfigManager.initialize();
}
logger.log('info', 'SmartProxy configuration updated'); logger.log('info', 'SmartProxy configuration updated');
} }
@@ -1002,21 +1054,25 @@ export class DcRouter {
// Start the server // Start the server
await this.emailServer.start(); await this.emailServer.start();
// Wire delivery events to MetricsManager for time-series tracking // Wire delivery events to MetricsManager and logger
if (this.metricsManager && this.emailServer.deliverySystem) { if (this.metricsManager && this.emailServer.deliverySystem) {
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => { this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
this.metricsManager.trackEmailReceived(item?.from); this.metricsManager.trackEmailReceived(item?.from);
logger.log('info', `Email delivery started: ${item?.from}${item?.to}`, { zone: 'email' });
}); });
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => { this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
this.metricsManager.trackEmailSent(item?.to); this.metricsManager.trackEmailSent(item?.to);
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
}); });
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => { this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
this.metricsManager.trackEmailFailed(item?.to, error?.message); this.metricsManager.trackEmailFailed(item?.to, error?.message);
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
}); });
} }
if (this.metricsManager && this.emailServer) { if (this.metricsManager && this.emailServer) {
this.emailServer.on('bounceProcessed', () => { this.emailServer.on('bounceProcessed', () => {
this.metricsManager.trackEmailBounced(); this.metricsManager.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' });
}); });
} }
@@ -1203,9 +1259,18 @@ export class DcRouter {
await this.dnsServer.start(); await this.dnsServer.start();
logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`); logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`);
// Wire DNS query events to MetricsManager for time-series tracking // Wire DNS query events to MetricsManager and logger with adaptive rate limiting
if (this.metricsManager && this.dnsServer) { if (this.metricsManager && this.dnsServer) {
const flushDnsBatch = () => {
if (this.dnsBatchCount > 0) {
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited)`, { zone: 'dns' });
this.dnsBatchCount = 0;
}
this.dnsBatchTimer = null;
};
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => { this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
// Metrics tracking
for (const question of event.questions) { for (const question of event.questions) {
this.metricsManager.trackDnsQuery( this.metricsManager.trackDnsQuery(
question.type, question.type,
@@ -1215,6 +1280,21 @@ export class DcRouter {
event.answered, event.answered,
); );
} }
// Adaptive logging: individual logs up to 2/sec, then batch
const now = Date.now();
this.dnsLogWindow = this.dnsLogWindow.filter(t => now - t < 1000);
if (this.dnsLogWindow.length < 2) {
this.dnsLogWindow.push(now);
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
} else {
this.dnsBatchCount++;
if (!this.dnsBatchTimer) {
this.dnsBatchTimer = setTimeout(flushDnsBatch, 5000);
}
}
}); });
} }

View File

@@ -0,0 +1,155 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import type {
IStoredApiToken,
IApiTokenInfo,
TApiTokenScope,
} from '../../ts_interfaces/data/route-management.js';
const TOKENS_PREFIX = '/config-api/tokens/';
const TOKEN_PREFIX_STR = 'dcr_';
export class ApiTokenManager {
private tokens = new Map<string, IStoredApiToken>();
constructor(private storageManager: StorageManager) {}
public async initialize(): Promise<void> {
await this.loadTokens();
if (this.tokens.size > 0) {
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
}
}
// =========================================================================
// Token lifecycle
// =========================================================================
/**
* Create a new API token. Returns the raw token value (shown once).
*/
public async createToken(
name: string,
scopes: TApiTokenScope[],
expiresInDays: number | null,
createdBy: string,
): Promise<{ id: string; rawToken: string }> {
const id = plugins.uuid.v4();
const randomBytes = plugins.crypto.randomBytes(32);
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
const now = Date.now();
const stored: IStoredApiToken = {
id,
name,
tokenHash,
scopes,
createdAt: now,
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
lastUsedAt: null,
createdBy,
enabled: true,
};
this.tokens.set(id, stored);
await this.persistToken(stored);
logger.log('info', `API token '${name}' created (id: ${id})`);
return { id, rawToken };
}
/**
* Validate a raw token string. Returns the stored token if valid, null otherwise.
* Also updates lastUsedAt.
*/
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
for (const stored of this.tokens.values()) {
if (stored.tokenHash === hash) {
if (!stored.enabled) return null;
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
// Update lastUsedAt (fire and forget)
stored.lastUsedAt = Date.now();
this.persistToken(stored).catch(() => {});
return stored;
}
}
return null;
}
/**
* Check if a token has a specific scope.
*/
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
return token.scopes.includes(scope);
}
/**
* List all tokens (safe info only, no hashes).
*/
public listTokens(): IApiTokenInfo[] {
const result: IApiTokenInfo[] = [];
for (const stored of this.tokens.values()) {
result.push({
id: stored.id,
name: stored.name,
scopes: stored.scopes,
createdAt: stored.createdAt,
expiresAt: stored.expiresAt,
lastUsedAt: stored.lastUsedAt,
enabled: stored.enabled,
});
}
return result;
}
/**
* Revoke (delete) a token.
*/
public async revokeToken(id: string): Promise<boolean> {
if (!this.tokens.has(id)) return false;
const token = this.tokens.get(id)!;
this.tokens.delete(id);
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
return true;
}
/**
* Enable or disable a token.
*/
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
const stored = this.tokens.get(id);
if (!stored) return false;
stored.enabled = enabled;
await this.persistToken(stored);
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
return true;
}
// =========================================================================
// Private
// =========================================================================
private async loadTokens(): Promise<void> {
const keys = await this.storageManager.list(TOKENS_PREFIX);
for (const key of keys) {
if (!key.endsWith('.json')) continue;
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
if (stored?.id) {
this.tokens.set(stored.id, stored);
}
}
}
private async persistToken(stored: IStoredApiToken): Promise<void> {
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
}
}

View File

@@ -0,0 +1,271 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import type { StorageManager } from '../storage/index.js';
import type {
IStoredRoute,
IRouteOverride,
IMergedRoute,
IRouteWarning,
} from '../../ts_interfaces/data/route-management.js';
const ROUTES_PREFIX = '/config-api/routes/';
const OVERRIDES_PREFIX = '/config-api/overrides/';
export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>();
private overrides = new Map<string, IRouteOverride>();
private warnings: IRouteWarning[] = [];
constructor(
private storageManager: StorageManager,
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
) {}
/**
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
*/
public async initialize(): Promise<void> {
await this.loadStoredRoutes();
await this.loadOverrides();
this.computeWarnings();
this.logWarnings();
await this.applyRoutes();
}
// =========================================================================
// Merged view
// =========================================================================
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
const merged: IMergedRoute[] = [];
// Hardcoded routes
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
merged.push({
route,
source: 'hardcoded',
enabled: override ? override.enabled : true,
overridden: !!override,
});
}
// Programmatic routes
for (const stored of this.storedRoutes.values()) {
merged.push({
route: stored.route,
source: 'programmatic',
enabled: stored.enabled,
overridden: false,
storedRouteId: stored.id,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
});
}
return { routes: merged, warnings: [...this.warnings] };
}
// =========================================================================
// Programmatic route CRUD
// =========================================================================
public async createRoute(
route: plugins.smartproxy.IRouteConfig,
createdBy: string,
enabled = true,
): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
// Ensure route has a name
if (!route.name) {
route.name = `programmatic-${id.slice(0, 8)}`;
}
const stored: IStoredRoute = {
id,
route,
enabled,
createdAt: now,
updatedAt: now,
createdBy,
};
this.storedRoutes.set(id, stored);
await this.persistRoute(stored);
await this.applyRoutes();
return id;
}
public async updateRoute(
id: string,
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
): Promise<boolean> {
const stored = this.storedRoutes.get(id);
if (!stored) return false;
if (patch.route) {
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
}
if (patch.enabled !== undefined) {
stored.enabled = patch.enabled;
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
await this.applyRoutes();
return true;
}
public async deleteRoute(id: string): Promise<boolean> {
if (!this.storedRoutes.has(id)) return false;
this.storedRoutes.delete(id);
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
await this.applyRoutes();
return true;
}
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
return this.updateRoute(id, { enabled });
}
// =========================================================================
// Hardcoded route overrides
// =========================================================================
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
const override: IRouteOverride = {
routeName,
enabled,
updatedAt: Date.now(),
updatedBy,
};
this.overrides.set(routeName, override);
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
this.computeWarnings();
await this.applyRoutes();
}
public async removeOverride(routeName: string): Promise<boolean> {
if (!this.overrides.has(routeName)) return false;
this.overrides.delete(routeName);
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
this.computeWarnings();
await this.applyRoutes();
return true;
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadStoredRoutes(): Promise<void> {
const keys = await this.storageManager.list(ROUTES_PREFIX);
for (const key of keys) {
if (!key.endsWith('.json')) continue;
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
if (stored?.id) {
this.storedRoutes.set(stored.id, stored);
}
}
if (this.storedRoutes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
}
}
private async loadOverrides(): Promise<void> {
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
for (const key of keys) {
if (!key.endsWith('.json')) continue;
const override = await this.storageManager.getJSON<IRouteOverride>(key);
if (override?.routeName) {
this.overrides.set(override.routeName, override);
}
}
if (this.overrides.size > 0) {
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
}
}
private async persistRoute(stored: IStoredRoute): Promise<void> {
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
}
// =========================================================================
// Private: warnings
// =========================================================================
private computeWarnings(): void {
this.warnings = [];
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
// Check overrides
for (const [routeName, override] of this.overrides) {
if (!hardcodedNames.has(routeName)) {
this.warnings.push({
type: 'orphaned-override',
routeName,
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
});
} else if (!override.enabled) {
this.warnings.push({
type: 'disabled-hardcoded',
routeName,
message: `Route '${routeName}' is disabled via API override`,
});
}
}
// Check disabled programmatic routes
for (const stored of this.storedRoutes.values()) {
if (!stored.enabled) {
const name = stored.route.name || stored.id;
this.warnings.push({
type: 'disabled-programmatic',
routeName: name,
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
});
}
}
}
private logWarnings(): void {
for (const w of this.warnings) {
logger.log('warn', w.message);
}
}
// =========================================================================
// Private: apply merged routes to SmartProxy
// =========================================================================
private async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add enabled hardcoded routes (respecting overrides)
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
if (override && !override.enabled) {
continue; // Skip disabled hardcoded route
}
enabledRoutes.push(route);
}
// Add enabled programmatic routes
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
enabledRoutes.push(stored.route);
}
}
await smartProxy.updateRoutes(enabledRoutes);
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
}
}

View File

@@ -1,2 +1,4 @@
// Export validation tools only // Export validation tools only
export * from './validator.js'; export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';

View File

@@ -20,6 +20,8 @@ export class OpsServer {
private emailOpsHandler: handlers.EmailOpsHandler; private emailOpsHandler: handlers.EmailOpsHandler;
private certificateHandler: handlers.CertificateHandler; private certificateHandler: handlers.CertificateHandler;
private remoteIngressHandler: handlers.RemoteIngressHandler; private remoteIngressHandler: handlers.RemoteIngressHandler;
private routeManagementHandler: handlers.RouteManagementHandler;
private apiTokenHandler: handlers.ApiTokenHandler;
constructor(dcRouterRefArg: DcRouter) { constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg; this.dcRouterRef = dcRouterRefArg;
@@ -61,6 +63,8 @@ export class OpsServer {
this.emailOpsHandler = new handlers.EmailOpsHandler(this); this.emailOpsHandler = new handlers.EmailOpsHandler(this);
this.certificateHandler = new handlers.CertificateHandler(this); this.certificateHandler = new handlers.CertificateHandler(this);
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this); this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized'); console.log('✅ OpsServer TypedRequest handlers initialized');
} }

View File

@@ -0,0 +1,96 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class ApiTokenHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Token management requires admin JWT only (tokens cannot manage tokens).
*/
private async requireAdmin(identity?: interfaces.data.IIdentity): Promise<string> {
if (!identity?.jwt) {
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ identity });
if (!isAdmin) {
throw new plugins.typedrequest.TypedResponseError('admin access required');
}
return identity.userId;
}
private registerHandlers(): void {
// Create API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
'createApiToken',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await manager.createToken(
dataArg.name,
dataArg.scopes,
dataArg.expiresInDays ?? null,
userId,
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
),
);
// List API tokens
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
'listApiTokens',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { tokens: [] };
}
return { tokens: manager.listTokens() };
},
),
);
// Revoke API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
'revokeApiToken',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const ok = await manager.revokeToken(dataArg.id);
return { success: ok, message: ok ? undefined : 'Token not found' };
},
),
);
// Toggle API token
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
'toggleApiToken',
async (dataArg) => {
await this.requireAdmin(dataArg.identity);
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!manager) {
return { success: false, message: 'Token management not initialized' };
}
const ok = await manager.toggleToken(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Token not found' };
},
),
);
}
}

View File

@@ -1,4 +1,5 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
@@ -17,7 +18,7 @@ export class ConfigHandler {
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
'getConfiguration', 'getConfiguration',
async (dataArg, toolsArg) => { async (dataArg, toolsArg) => {
const config = await this.getConfiguration(dataArg.section); const config = await this.getConfiguration();
return { return {
config, config,
section: dataArg.section, section: dataArg.section,
@@ -27,82 +28,163 @@ export class ConfigHandler {
); );
} }
private async getConfiguration(section?: string): Promise<{ private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
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;
};
}> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const opts = dcRouter.options;
const resolvedPaths = dcRouter.resolvedPaths;
// Get email domains if email server is configured // --- 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[] = []; let emailDomains: string[] = [];
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) { if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains(); emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
} else if (dcRouter.options.emailConfig?.domains) { } else if (opts.emailConfig?.domains) {
// Fallback: get domains from email config options emailDomains = opts.emailConfig.domains.map((d: any) =>
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
typeof d === 'string' ? d : d.domain typeof d === 'string' ? d : d.domain
); );
} }
return { let portMapping: Record<string, number> | null = null;
email: { 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, enabled: !!dcRouter.emailServer,
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [], ports: opts.emailConfig?.ports || [],
maxMessageSize: 10 * 1024 * 1024, // 10MB default portMapping,
rateLimits: { hostname: opts.emailConfig?.hostname || null,
perMinute: 10,
perHour: 100,
perDay: 1000,
},
domains: emailDomains, domains: emailDomains,
}, emailRouteCount: opts.emailConfig?.routes?.length || 0,
dns: { 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, enabled: !!dcRouter.dnsServer,
port: 53, port: 53,
nameservers: dcRouter.options.dnsNsDomains || [], nsDomains: opts.dnsNsDomains || [],
caching: true, scopes: opts.dnsScopes || [],
ttl: 300, recordCount: dnsRecords.length,
}, records: dnsRecords,
proxy: { dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
enabled: !!dcRouter.smartProxy, };
httpPort: 80,
httpsPort: 443, // --- TLS ---
maxConnections: 1000, let tlsSource: 'acme' | 'static' | 'none' = 'none';
}, if (opts.tls?.certPath && opts.tls?.keyPath) {
security: { tlsSource = 'static';
blockList: [], } else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
rateLimit: true, tlsSource = 'acme';
spamDetection: true, }
tlsRequired: false,
}, 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<string, number> : {},
};
// --- 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 {
system,
smartProxy,
email,
dns,
tls,
cache,
radius,
remoteIngress,
}; };
} }
} }

View File

@@ -1,7 +1,6 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
import { SecurityLogger } from '../../security/index.js';
export class EmailOpsHandler { export class EmailOpsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -13,68 +12,24 @@ export class EmailOpsHandler {
} }
private registerHandlers(): void { private registerHandlers(): void {
// Get Queued Emails Handler // Get All Emails Handler
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
'getQueuedEmails', 'getAllEmails',
async (dataArg) => { async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer; const emails = this.getAllQueueEmails();
if (!emailServer?.deliveryQueue) { return { emails };
return { items: [], total: 0 };
}
const queue = emailServer.deliveryQueue;
const stats = queue.getStats();
// Get all queue items and filter by status if provided
const items = this.getQueueItems(
dataArg.status,
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: stats.queueSize,
};
} }
) )
); );
// Get Sent Emails Handler // Get Email Detail Handler
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
'getSentEmails', 'getEmailDetail',
async (dataArg) => { async (dataArg) => {
const items = this.getQueueItems( const email = this.getEmailDetail(dataArg.emailId);
'delivered', return { email };
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length, // Note: total would ideally come from a counter
};
}
)
);
// Get Failed Emails Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
'getFailedEmails',
async (dataArg) => {
const items = this.getQueueItems(
'failed',
dataArg.limit || 50,
dataArg.offset || 0
);
return {
items,
total: items.length,
};
} }
) )
); );
@@ -101,17 +56,12 @@ export class EmailOpsHandler {
} }
try { try {
// Re-enqueue the failed email by creating a new queue entry
// with the same data but reset attempt count
const newQueueId = await queue.enqueue( const newQueueId = await queue.enqueue(
item.processingResult, item.processingResult,
item.processingMode, item.processingMode,
item.route item.route
); );
// Optionally remove the old failed entry
await queue.removeItem(dataArg.emailId); await queue.removeItem(dataArg.emailId);
return { success: true, newQueueId }; return { success: true, newQueueId };
} catch (error) { } catch (error) {
return { return {
@@ -122,197 +72,199 @@ export class EmailOpsHandler {
} }
) )
); );
// Get Security Incidents Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
'getSecurityIncidents',
async (dataArg) => {
const securityLogger = SecurityLogger.getInstance();
const filter: {
level?: any;
type?: any;
} = {};
if (dataArg.level) {
filter.level = dataArg.level;
}
if (dataArg.type) {
filter.type = dataArg.type;
}
const incidents = securityLogger.getRecentEvents(
dataArg.limit || 100,
Object.keys(filter).length > 0 ? filter : undefined
);
return {
incidents: incidents.map(event => ({
timestamp: event.timestamp,
level: event.level as interfaces.requests.TSecurityLogLevel,
type: event.type as interfaces.requests.TSecurityEventType,
message: event.message,
details: event.details,
ipAddress: event.ipAddress,
userId: event.userId,
sessionId: event.sessionId,
emailId: event.emailId,
domain: event.domain,
action: event.action,
result: event.result,
success: event.success,
})),
total: incidents.length,
};
}
)
);
// Get Bounce Records Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
'getBounceRecords',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { records: [], suppressionList: [], total: 0 };
}
// Use smartmta's public API for bounce/suppression data
const suppressionList = emailServer.getSuppressionList();
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
// Create bounce records from the available data
const records: interfaces.requests.IBounceRecord[] = [];
for (const email of hardBouncedAddresses) {
const bounceInfo = emailServer.getBounceHistory(email);
if (bounceInfo) {
records.push({
id: `bounce-${email}`,
recipient: email,
sender: '',
domain: email.split('@')[1] || '',
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
timestamp: (bounceInfo as any).lastBounce,
processed: true,
});
}
}
// Apply limit and offset
const limit = dataArg.limit || 50;
const offset = dataArg.offset || 0;
const paginatedRecords = records.slice(offset, offset + limit);
return {
records: paginatedRecords,
suppressionList,
total: records.length,
};
}
)
);
// Remove from Suppression List Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
'removeFromSuppressionList',
async (dataArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return { success: false, error: 'Email server not available' };
}
try {
emailServer.removeFromSuppressionList(dataArg.email);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
};
}
}
)
);
} }
/** /**
* Helper method to get queue items with filtering and pagination * Get all queue items mapped to catalog IEmail format
*/ */
private getQueueItems( private getAllQueueEmails(): interfaces.requests.IEmail[] {
status?: interfaces.requests.TEmailQueueStatus,
limit: number = 50,
offset: number = 0
): interfaces.requests.IEmailQueueItem[] {
const emailServer = this.opsServerRef.dcRouterRef.emailServer; const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) { if (!emailServer?.deliveryQueue) {
return []; return [];
} }
const queue = emailServer.deliveryQueue; const queue = emailServer.deliveryQueue;
const items: interfaces.requests.IEmailQueueItem[] = [];
// Access the internal queue map via reflection
// This is necessary because the queue doesn't expose iteration methods
const queueMap = (queue as any).queue as Map<string, any>; const queueMap = (queue as any).queue as Map<string, any>;
if (!queueMap) { if (!queueMap) {
return []; return [];
} }
// Filter and convert items const emails: interfaces.requests.IEmail[] = [];
for (const [id, item] of queueMap.entries()) { for (const [id, item] of queueMap.entries()) {
// Apply status filter if provided emails.push(this.mapQueueItemToEmail(item));
if (status && item.status !== status) {
continue;
}
// Extract email details from processingResult if available
const processingResult = item.processingResult;
let from = '';
let to: string[] = [];
let subject = '';
if (processingResult) {
// Check if it's an Email object or raw email data
if (processingResult.email) {
from = processingResult.email.from || '';
to = processingResult.email.to || [];
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = processingResult.to || [];
subject = processingResult.subject || '';
}
}
items.push({
id: item.id,
processingMode: item.processingMode,
status: item.status,
attempts: item.attempts,
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
lastError: item.lastError,
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
from,
to,
subject,
});
} }
// Sort by createdAt descending (newest first) // Sort by createdAt descending (newest first)
items.sort((a, b) => b.createdAt - a.createdAt); emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination return emails;
return items.slice(offset, offset + limit); }
/**
* Get a single email detail by ID
*/
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer?.deliveryQueue) {
return null;
}
const queue = emailServer.deliveryQueue;
const item = queue.getItem(emailId);
if (!item) {
return null;
}
return this.mapQueueItemToEmailDetail(item);
}
/**
* Map a queue item to catalog IEmail format
*/
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
const processingResult = item.processingResult;
let from = '';
let to = '';
let subject = '';
let messageId = '';
let size = '0 B';
if (processingResult) {
if (processingResult.email) {
from = processingResult.email.from || '';
to = (processingResult.email.to || [])[0] || '';
subject = processingResult.email.subject || '';
} else if (processingResult.from) {
from = processingResult.from;
to = (processingResult.to || [])[0] || '';
subject = processingResult.subject || '';
}
// Try to get messageId
if (typeof processingResult.getMessageId === 'function') {
try {
messageId = processingResult.getMessageId() || '';
} catch {
messageId = '';
}
}
// Compute approximate size
const textLen = processingResult.text?.length || 0;
const htmlLen = processingResult.html?.length || 0;
let attachSize = 0;
if (typeof processingResult.getAttachmentsSize === 'function') {
try {
attachSize = processingResult.getAttachmentsSize() || 0;
} catch {
attachSize = 0;
}
}
size = this.formatSize(textLen + htmlLen + attachSize);
}
// Map queue status to catalog TEmailStatus
const status = this.mapStatus(item.status);
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
return {
id: item.id,
direction: 'outbound' as interfaces.requests.TEmailDirection,
status,
from,
to,
subject,
timestamp: new Date(createdAt).toISOString(),
messageId,
size,
};
}
/**
* Map a queue item to catalog IEmailDetail format
*/
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
const base = this.mapQueueItemToEmail(item);
const processingResult = item.processingResult;
let toList: string[] = [];
let cc: string[] = [];
let headers: Record<string, string> = {};
let body = '';
if (processingResult) {
if (processingResult.email) {
toList = processingResult.email.to || [];
cc = processingResult.email.cc || [];
} else {
toList = processingResult.to || [];
cc = processingResult.cc || [];
}
headers = processingResult.headers || {};
body = processingResult.html || processingResult.text || '';
}
return {
...base,
toList,
cc,
smtpLog: [],
connectionInfo: {
sourceIp: '',
sourceHostname: '',
destinationIp: '',
destinationPort: 0,
tlsVersion: '',
tlsCipher: '',
authenticated: false,
authMethod: '',
authUser: '',
},
authenticationResults: {
spf: 'none',
spfDomain: '',
dkim: 'none',
dkimDomain: '',
dmarc: 'none',
dmarcPolicy: '',
},
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
headers,
body,
};
}
/**
* Map queue status to catalog TEmailStatus
*/
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
switch (queueStatus) {
case 'pending':
case 'processing':
return 'pending';
case 'delivered':
return 'delivered';
case 'failed':
return 'bounced';
case 'deferred':
return 'deferred';
default:
return 'pending';
}
}
/**
* Format byte size to human-readable string
*/
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
} }

View File

@@ -7,3 +7,5 @@ export * from './radius.handler.js';
export * from './email-ops.handler.js'; export * from './email-ops.handler.js';
export * from './certificate.handler.js'; export * from './certificate.handler.js';
export * from './remoteingress.handler.js'; export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
export * from './api-token.handler.js';

View File

@@ -0,0 +1,163 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class RouteManagementHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
/**
* Validate auth: JWT identity OR API token with required scope.
* Returns a userId string on success, throws on failure.
*/
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
// Try JWT identity first
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
// Try API token
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get merged routes
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMergedRoutes>(
'getMergedRoutes',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:read');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { routes: [], warnings: [] };
}
return manager.getMergedRoutes();
},
),
);
// Create route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
'createRoute',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true);
return { success: true, storedRouteId: id };
},
),
);
// Update route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRoute>(
'updateRoute',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.updateRoute(dataArg.id, {
route: dataArg.route as any,
enabled: dataArg.enabled,
});
return { success: ok, message: ok ? undefined : 'Route not found' };
},
),
);
// Delete route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRoute>(
'deleteRoute',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.deleteRoute(dataArg.id);
return { success: ok, message: ok ? undefined : 'Route not found' };
},
),
);
// Set override on a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
return { success: true };
},
),
);
// Remove override from a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.removeOverride(dataArg.routeName);
return { success: ok, message: ok ? undefined : 'Override not found' };
},
),
);
// Toggle programmatic route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
return { success: ok, message: ok ? undefined : 'Route not found' };
},
),
);
}
}

View File

@@ -1,3 +1,4 @@
export * from './auth.js'; export * from './auth.js';
export * from './stats.js'; export * from './stats.js';
export * from './remoteingress.js'; export * from './remoteingress.js';
export * from './route-management.js';

View File

@@ -0,0 +1,83 @@
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Route Management Data Types
// ============================================================================
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage';
/**
* A merged route combining hardcoded and programmatic sources.
*/
export interface IMergedRoute {
route: IRouteConfig;
source: 'hardcoded' | 'programmatic';
enabled: boolean;
overridden: boolean;
storedRouteId?: string;
createdAt?: number;
updatedAt?: number;
}
/**
* A warning generated during route merge/startup.
*/
export interface IRouteWarning {
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
routeName: string;
message: string;
}
/**
* Public info about an API token (never includes the hash).
*/
export interface IApiTokenInfo {
id: string;
name: string;
scopes: TApiTokenScope[];
createdAt: number;
expiresAt: number | null;
lastUsedAt: number | null;
enabled: boolean;
}
// ============================================================================
// Storage Schemas (persisted via StorageManager)
// ============================================================================
/**
* A programmatic route stored in /config-api/routes/{id}.json
*/
export interface IStoredRoute {
id: string;
route: IRouteConfig;
enabled: boolean;
createdAt: number;
updatedAt: number;
createdBy: string;
}
/**
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
*/
export interface IRouteOverride {
routeName: string;
enabled: boolean;
updatedAt: number;
updatedBy: string;
}
/**
* A stored API token, stored in /config-api/tokens/{id}.json
*/
export interface IStoredApiToken {
id: string;
name: string;
tokenHash: string;
scopes: TApiTokenScope[];
createdAt: number;
expiresAt: number | null;
lastUsedAt: number | null;
createdBy: string;
enabled: boolean;
}

View File

@@ -0,0 +1,83 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js';
// ============================================================================
// API Token Management Endpoints
// ============================================================================
/**
* Create a new API token. Returns the raw token value once (never shown again).
* Admin JWT only — tokens cannot create tokens.
*/
export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateApiToken
> {
method: 'createApiToken';
request: {
identity?: authInterfaces.IIdentity;
name: string;
scopes: TApiTokenScope[];
expiresInDays?: number | null;
};
response: {
success: boolean;
tokenId?: string;
tokenValue?: string;
message?: string;
};
}
/**
* List all API tokens (without hashes).
*/
export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ListApiTokens
> {
method: 'listApiTokens';
request: {
identity?: authInterfaces.IIdentity;
};
response: {
tokens: IApiTokenInfo[];
};
}
/**
* Revoke (delete) an API token.
*/
export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RevokeApiToken
> {
method: 'revokeApiToken';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Enable or disable an API token.
*/
export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ToggleApiToken
> {
method: 'toggleApiToken';
request: {
identity?: authInterfaces.IIdentity;
id: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}

View File

@@ -1,6 +1,78 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.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<string, number> | 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<string, number>;
};
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) // Get Configuration (read-only)
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -12,7 +84,7 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
section?: string; section?: string;
}; };
response: { response: {
config: any; config: IConfigData;
section?: string; section?: string;
}; };
} }

View File

@@ -2,162 +2,93 @@ import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js'; import * as authInterfaces from '../data/auth.js';
// ============================================================================ // ============================================================================
// Email Queue Item Interface (matches backend IQueueItem) // Catalog-compatible email types (matches @serve.zone/catalog IEmail/IEmailDetail)
// ============================================================================ // ============================================================================
export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred'; export type TEmailStatus = 'delivered' | 'bounced' | 'rejected' | 'deferred' | 'pending';
export type TEmailDirection = 'inbound' | 'outbound';
export interface IEmailQueueItem { export interface IEmail {
id: string; id: string;
processingMode: 'forward' | 'mta' | 'process'; direction: TEmailDirection;
status: TEmailQueueStatus; status: TEmailStatus;
attempts: number; from: string;
nextAttempt: number; // timestamp to: string;
lastError?: string; subject: string;
createdAt: number; // timestamp timestamp: string;
updatedAt: number; // timestamp messageId: string;
deliveredAt?: number; // timestamp size: string;
// Email details extracted from processingResult }
from?: string;
to?: string[]; export interface ISmtpLogEntry {
subject?: string; timestamp: string;
direction: 'client' | 'server';
command: string;
responseCode?: number;
}
export interface IConnectionInfo {
sourceIp: string;
sourceHostname: string;
destinationIp: string;
destinationPort: number;
tlsVersion: string;
tlsCipher: string;
authenticated: boolean;
authMethod: string;
authUser: string;
}
export interface IAuthenticationResults {
spf: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none';
spfDomain: string;
dkim: 'pass' | 'fail' | 'none';
dkimDomain: string;
dmarc: 'pass' | 'fail' | 'none';
dmarcPolicy: string;
}
export interface IEmailDetail extends IEmail {
toList: string[];
cc?: string[];
smtpLog: ISmtpLogEntry[];
connectionInfo: IConnectionInfo;
authenticationResults: IAuthenticationResults;
rejectionReason?: string;
bounceMessage?: string;
headers: Record<string, string>;
body: string;
} }
// ============================================================================ // ============================================================================
// Bounce Record Interface (matches backend BounceRecord) // Get All Emails Request
// ============================================================================ // ============================================================================
export type TBounceType = export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implementsTR<
| 'invalid_recipient'
| 'domain_not_found'
| 'mailbox_full'
| 'mailbox_inactive'
| 'blocked'
| 'spam_related'
| 'policy_related'
| 'server_unavailable'
| 'temporary_failure'
| 'quota_exceeded'
| 'network_error'
| 'timeout'
| 'auto_response'
| 'challenge_response'
| 'unknown';
export type TBounceCategory = 'hard' | 'soft' | 'auto_response' | 'unknown';
export interface IBounceRecord {
id: string;
originalEmailId?: string;
recipient: string;
sender: string;
domain: string;
subject?: string;
bounceType: TBounceType;
bounceCategory: TBounceCategory;
timestamp: number;
smtpResponse?: string;
diagnosticCode?: string;
statusCode?: string;
processed: boolean;
retryCount?: number;
nextRetryTime?: number;
}
// ============================================================================
// Security Incident Interface (matches backend ISecurityEvent)
// ============================================================================
export type TSecurityLogLevel = 'info' | 'warn' | 'error' | 'critical';
export type TSecurityEventType =
| 'authentication'
| 'access_control'
| 'email_validation'
| 'email_processing'
| 'email_forwarding'
| 'email_delivery'
| 'dkim'
| 'spf'
| 'dmarc'
| 'rate_limit'
| 'rate_limiting'
| 'spam'
| 'malware'
| 'connection'
| 'data_exposure'
| 'configuration'
| 'ip_reputation'
| 'rejected_connection';
export interface ISecurityIncident {
timestamp: number;
level: TSecurityLogLevel;
type: TSecurityEventType;
message: string;
details?: any;
ipAddress?: string;
userId?: string;
sessionId?: string;
emailId?: string;
domain?: string;
action?: string;
result?: string;
success?: boolean;
}
// ============================================================================
// Get Queued Emails Request
// ============================================================================
export interface IReq_GetQueuedEmails extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetQueuedEmails IReq_GetAllEmails
> { > {
method: 'getQueuedEmails'; method: 'getAllEmails';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
status?: TEmailQueueStatus;
limit?: number;
offset?: number;
}; };
response: { response: {
items: IEmailQueueItem[]; emails: IEmail[];
total: number;
}; };
} }
// ============================================================================ // ============================================================================
// Get Sent Emails Request // Get Email Detail Request
// ============================================================================ // ============================================================================
export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSentEmails IReq_GetEmailDetail
> { > {
method: 'getSentEmails'; method: 'getEmailDetail';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
limit?: number; emailId: string;
offset?: number;
}; };
response: { response: {
items: IEmailQueueItem[]; email: IEmailDetail | null;
total: number;
};
}
// ============================================================================
// Get Failed Emails Request
// ============================================================================
export interface IReq_GetFailedEmails extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetFailedEmails
> {
method: 'getFailedEmails';
request: {
identity?: authInterfaces.IIdentity;
limit?: number;
offset?: number;
};
response: {
items: IEmailQueueItem[];
total: number;
}; };
} }
@@ -179,61 +110,3 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
error?: string; error?: string;
}; };
} }
// ============================================================================
// Get Security Incidents Request
// ============================================================================
export interface IReq_GetSecurityIncidents extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityIncidents
> {
method: 'getSecurityIncidents';
request: {
identity?: authInterfaces.IIdentity;
type?: TSecurityEventType;
level?: TSecurityLogLevel;
limit?: number;
};
response: {
incidents: ISecurityIncident[];
total: number;
};
}
// ============================================================================
// Get Bounce Records Request
// ============================================================================
export interface IReq_GetBounceRecords extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetBounceRecords
> {
method: 'getBounceRecords';
request: {
identity?: authInterfaces.IIdentity;
limit?: number;
offset?: number;
};
response: {
records: IBounceRecord[];
suppressionList: string[];
total: number;
};
}
// ============================================================================
// Remove from Suppression List Request
// ============================================================================
export interface IReq_RemoveFromSuppressionList extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RemoveFromSuppressionList
> {
method: 'removeFromSuppressionList';
request: {
identity?: authInterfaces.IIdentity;
email: string;
};
response: {
success: boolean;
error?: string;
};
}

View File

@@ -7,3 +7,5 @@ export * from './radius.js';
export * from './email-ops.js'; export * from './email-ops.js';
export * from './certificate.js'; export * from './certificate.js';
export * from './remoteingress.js'; export * from './remoteingress.js';
export * from './route-management.js';
export * from './api-tokens.js';

View File

@@ -0,0 +1,146 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Route Management Endpoints
// ============================================================================
/**
* Get all merged routes (hardcoded + programmatic) with warnings.
*/
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetMergedRoutes
> {
method: 'getMergedRoutes';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
routes: IMergedRoute[];
warnings: IRouteWarning[];
};
}
/**
* Create a new programmatic route.
*/
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateRoute
> {
method: 'createRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
route: IRouteConfig;
enabled?: boolean;
};
response: {
success: boolean;
storedRouteId?: string;
message?: string;
};
}
/**
* Update a programmatic route.
*/
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateRoute
> {
method: 'updateRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
route?: Partial<IRouteConfig>;
enabled?: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Delete a programmatic route.
*/
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteRoute
> {
method: 'deleteRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Set an override on a hardcoded route (disable/enable by name).
*/
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SetRouteOverride
> {
method: 'setRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove an override from a hardcoded route (restore default behavior).
*/
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RemoveRouteOverride
> {
method: 'removeRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Toggle a programmatic route on/off by id.
*/
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ToggleRoute
> {
method: 'toggleRoute';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '7.4.2', version: '9.0.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -21,7 +21,7 @@ export interface IStatsState {
} }
export interface IConfigState { export interface IConfigState {
config: any | null; config: interfaces.requests.IConfigData | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@@ -67,14 +67,7 @@ export interface ICertificateState {
} }
export interface IEmailOpsState { export interface IEmailOpsState {
currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security'; emails: interfaces.requests.IEmail[];
queuedEmails: interfaces.requests.IEmailQueueItem[];
sentEmails: interfaces.requests.IEmailQueueItem[];
failedEmails: interfaces.requests.IEmailQueueItem[];
securityIncidents: interfaces.requests.ISecurityIncident[];
bounceRecords: interfaces.requests.IBounceRecord[];
suppressionList: string[];
selectedEmailId: string | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
lastUpdated: number; lastUpdated: number;
@@ -116,7 +109,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
// Determine initial view from URL path // Determine initial view from URL path
const getInitialView = (): string => { const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/'; const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress']; const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'];
const segments = path.split('/').filter(Boolean); const segments = path.split('/').filter(Boolean);
const view = segments[0]; const view = segments[0];
return validViews.includes(view) ? view : 'overview'; return validViews.includes(view) ? view : 'overview';
@@ -165,14 +158,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>( export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
'emailOps', 'emailOps',
{ {
currentView: 'queued', emails: [],
queuedEmails: [],
sentEmails: [],
failedEmails: [],
securityIncidents: [],
bounceRecords: [],
suppressionList: [],
selectedEmailId: null,
isLoading: false, isLoading: false,
error: null, error: null,
lastUpdated: 0, lastUpdated: 0,
@@ -220,6 +206,32 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
'soft' 'soft'
); );
// ============================================================================
// Route Management State
// ============================================================================
export interface IRouteManagementState {
mergedRoutes: interfaces.data.IMergedRoute[];
warnings: interfaces.data.IRouteWarning[];
apiTokens: interfaces.data.IApiTokenInfo[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const routeManagementStatePart = await appState.getStatePart<IRouteManagementState>(
'routeManagement',
{
mergedRoutes: [],
warnings: [],
apiTokens: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft'
);
// Actions for state management // Actions for state management
interface IActionContext { interface IActionContext {
identity: interfaces.data.IIdentity | null; identity: interfaces.data.IIdentity | null;
@@ -406,6 +418,20 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
}, 100); }, 100);
} }
// If switching to routes view, ensure we fetch route data
if (viewName === 'routes' && currentState.activeView !== 'routes') {
setTimeout(() => {
routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
}, 100);
}
// If switching to apitokens view, ensure we fetch token data
if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') {
setTimeout(() => {
routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
}, 100);
}
// If switching to remoteingress view, ensure we fetch edge data // If switching to remoteingress view, ensure we fetch edge data
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') { if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
setTimeout(() => { setTimeout(() => {
@@ -492,35 +518,22 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
// Email Operations Actions // Email Operations Actions
// ============================================================================ // ============================================================================
// Set Email Ops View Action // Fetch All Emails Action
export const setEmailOpsViewAction = emailOpsStatePart.createAction<IEmailOpsState['currentView']>( export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
async (statePartArg, view) => {
return {
...statePartArg.getState(),
currentView: view,
};
}
);
// Fetch Queued Emails Action
export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
const context = getActionContext(); const context = getActionContext();
const currentState = statePartArg.getState(); const currentState = statePartArg.getState();
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetQueuedEmails interfaces.requests.IReq_GetAllEmails
>('/typedrequest', 'getQueuedEmails'); >('/typedrequest', 'getAllEmails');
const response = await request.fire({ const response = await request.fire({
identity: context.identity, identity: context.identity,
status: 'pending',
limit: 100,
}); });
return { return {
...currentState, emails: response.emails,
queuedEmails: response.items,
isLoading: false, isLoading: false,
error: null, error: null,
lastUpdated: Date.now(), lastUpdated: Date.now(),
@@ -529,197 +542,11 @@ export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (sta
return { return {
...currentState, ...currentState,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch queued emails', error: error instanceof Error ? error.message : 'Failed to fetch emails',
}; };
} }
}); });
// Fetch Sent Emails Action
export const fetchSentEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSentEmails
>('/typedrequest', 'getSentEmails');
const response = await request.fire({
identity: context.identity,
limit: 100,
});
return {
...currentState,
sentEmails: response.items,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch sent emails',
};
}
});
// Fetch Failed Emails Action
export const fetchFailedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetFailedEmails
>('/typedrequest', 'getFailedEmails');
const response = await request.fire({
identity: context.identity,
limit: 100,
});
return {
...currentState,
failedEmails: response.items,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch failed emails',
};
}
});
// Fetch Security Incidents Action
export const fetchSecurityIncidentsAction = emailOpsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecurityIncidents
>('/typedrequest', 'getSecurityIncidents');
const response = await request.fire({
identity: context.identity,
limit: 100,
});
return {
...currentState,
securityIncidents: response.incidents,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch security incidents',
};
}
});
// Fetch Bounce Records Action
export const fetchBounceRecordsAction = emailOpsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBounceRecords
>('/typedrequest', 'getBounceRecords');
const response = await request.fire({
identity: context.identity,
limit: 100,
});
return {
...currentState,
bounceRecords: response.records,
suppressionList: response.suppressionList,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch bounce records',
};
}
});
// Resend Failed Email Action
export const resendEmailAction = emailOpsStatePart.createAction<string>(async (statePartArg, emailId) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ResendEmail
>('/typedrequest', 'resendEmail');
const response = await request.fire({
identity: context.identity,
emailId,
});
if (response.success) {
// Refresh failed emails list
await emailOpsStatePart.dispatchAction(fetchFailedEmailsAction, null);
await emailOpsStatePart.dispatchAction(fetchQueuedEmailsAction, null);
}
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to resend email',
};
}
});
// Remove from Suppression List Action
export const removeFromSuppressionListAction = emailOpsStatePart.createAction<string>(
async (statePartArg, email) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RemoveFromSuppressionList
>('/typedrequest', 'removeFromSuppressionList');
const response = await request.fire({
identity: context.identity,
email,
});
if (response.success) {
// Refresh bounce records
await emailOpsStatePart.dispatchAction(fetchBounceRecordsAction, null);
}
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to remove from suppression list',
};
}
}
);
// ============================================================================ // ============================================================================
// Certificate Actions // Certificate Actions
// ============================================================================ // ============================================================================
@@ -1075,6 +902,273 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
} }
}); });
// ============================================================================
// Route Management Actions
// ============================================================================
export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetMergedRoutes
>('/typedrequest', 'getMergedRoutes');
const response = await request.fire({
identity: context.identity,
});
return {
...currentState,
mergedRoutes: response.routes,
warnings: response.warnings,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch routes',
};
}
});
export const createRouteAction = routeManagementStatePart.createAction<{
route: any;
enabled?: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateRoute
>('/typedrequest', 'createRoute');
await request.fire({
identity: context.identity,
route: dataArg.route,
enabled: dataArg.enabled,
});
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to create route',
};
}
});
export const deleteRouteAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeId) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteRoute
>('/typedrequest', 'deleteRoute');
await request.fire({
identity: context.identity,
id: routeId,
});
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete route',
};
}
}
);
export const toggleRouteAction = routeManagementStatePart.createAction<{
id: string;
enabled: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ToggleRoute
>('/typedrequest', 'toggleRoute');
await request.fire({
identity: context.identity,
id: dataArg.id,
enabled: dataArg.enabled,
});
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle route',
};
}
});
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
routeName: string;
enabled: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SetRouteOverride
>('/typedrequest', 'setRouteOverride');
await request.fire({
identity: context.identity,
routeName: dataArg.routeName,
enabled: dataArg.enabled,
});
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to set override',
};
}
});
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
async (statePartArg, routeName) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RemoveRouteOverride
>('/typedrequest', 'removeRouteOverride');
await request.fire({
identity: context.identity,
routeName,
});
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to remove override',
};
}
}
);
// ============================================================================
// API Token Actions
// ============================================================================
export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListApiTokens
>('/typedrequest', 'listApiTokens');
const response = await request.fire({
identity: context.identity,
});
return {
...currentState,
apiTokens: response.tokens,
};
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch tokens',
};
}
});
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateApiToken
>('/typedrequest', 'createApiToken');
return request.fire({
identity: context.identity,
name,
scopes,
expiresInDays,
});
}
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
async (statePartArg, tokenId) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RevokeApiToken
>('/typedrequest', 'revokeApiToken');
await request.fire({
identity: context.identity,
id: tokenId,
});
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to revoke token',
};
}
}
);
export const toggleApiTokenAction = routeManagementStatePart.createAction<{
id: string;
enabled: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ToggleApiToken
>('/typedrequest', 'toggleApiToken');
await request.fire({
identity: context.identity,
id: dataArg.id,
enabled: dataArg.enabled,
});
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to toggle token',
};
}
});
// ============================================================================ // ============================================================================
// TypedSocket Client for Real-time Log Streaming // TypedSocket Client for Real-time Log Streaming
// ============================================================================ // ============================================================================

View File

@@ -4,6 +4,8 @@ export * from './ops-view-network.js';
export * from './ops-view-emails.js'; export * from './ops-view-emails.js';
export * from './ops-view-logs.js'; export * from './ops-view-logs.js';
export * from './ops-view-config.js'; export * from './ops-view-config.js';
export * from './ops-view-routes.js';
export * from './ops-view-apitokens.js';
export * from './ops-view-security.js'; export * from './ops-view-security.js';
export * from './ops-view-certificates.js'; export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js'; export * from './ops-view-remoteingress.js';

View File

@@ -18,6 +18,8 @@ import { OpsViewNetwork } from './ops-view-network.js';
import { OpsViewEmails } from './ops-view-emails.js'; import { OpsViewEmails } from './ops-view-emails.js';
import { OpsViewLogs } from './ops-view-logs.js'; import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewConfig } from './ops-view-config.js'; import { OpsViewConfig } from './ops-view-config.js';
import { OpsViewRoutes } from './ops-view-routes.js';
import { OpsViewApiTokens } from './ops-view-apitokens.js';
import { OpsViewSecurity } from './ops-view-security.js'; import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js'; import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js'; import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
@@ -43,6 +45,10 @@ export class OpsDashboard extends DeesElement {
name: 'Overview', name: 'Overview',
element: OpsViewOverview, element: OpsViewOverview,
}, },
{
name: 'Configuration',
element: OpsViewConfig,
},
{ {
name: 'Network', name: 'Network',
element: OpsViewNetwork, element: OpsViewNetwork,
@@ -56,8 +62,12 @@ export class OpsDashboard extends DeesElement {
element: OpsViewLogs, element: OpsViewLogs,
}, },
{ {
name: 'Configuration', name: 'Routes',
element: OpsViewConfig, element: OpsViewRoutes,
},
{
name: 'ApiTokens',
element: OpsViewApiTokens,
}, },
{ {
name: 'Security', name: 'Security',

View File

@@ -0,0 +1,285 @@
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
type TApiTokenScope = interfaces.data.TApiTokenScope;
@customElement('ops-view-apitokens')
export class OpsViewApiTokens extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
apiTokens: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
constructor() {
super();
const sub = appstate.routeManagementStatePart
.select((s) => s)
.subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(sub);
// Re-fetch tokens when user logs in (fixes race condition where
// the view is created before authentication completes)
const loginSub = appstate.loginStatePart
.select((s) => s.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.apiTokensContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.scopePill {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')};
color: ${cssManager.bdTheme('#0369a1', '#0af')};
margin-right: 4px;
margin-bottom: 2px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.statusBadge.active {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.expired {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
`,
];
public render(): TemplateResult {
const { apiTokens } = this.routeState;
return html`
<ops-sectionheading>API Tokens</ops-sectionheading>
<div class="apiTokensContainer">
<dees-table
.heading1=${'API Tokens'}
.heading2=${'Manage programmatic access tokens'}
.data=${apiTokens}
.dataName=${'token'}
.searchable=${true}
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
name: token.name,
scopes: this.renderScopePills(token.scopes),
status: this.renderStatusBadge(token),
created: new Date(token.createdAt).toLocaleDateString(),
expires: token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : 'Never',
lastUsed: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : 'Never',
})}
.dataActions=${[
{
name: 'Create Token',
iconName: 'lucide:plus',
type: ['header'],
actionFunc: async () => {
await this.showCreateTokenDialog();
},
},
{
name: 'Enable',
iconName: 'lucide:play',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleApiTokenAction,
{ id: token.id, enabled: true },
);
},
},
{
name: 'Disable',
iconName: 'lucide:pause',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleApiTokenAction,
{ id: token.id, enabled: false },
);
},
},
{
name: 'Revoke',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const token = actionData.item as interfaces.data.IApiTokenInfo;
await appstate.routeManagementStatePart.dispatchAction(
appstate.revokeApiTokenAction,
token.id,
);
},
},
]}
></dees-table>
</div>
`;
}
private renderScopePills(scopes: TApiTokenScope[]): TemplateResult {
return html`<div style="display: flex; flex-wrap: wrap; gap: 2px;">${scopes.map(
(s) => html`<span class="scopePill">${s}</span>`,
)}</div>`;
}
private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult {
if (!token.enabled) {
return html`<span class="statusBadge disabled">Disabled</span>`;
}
if (token.expiresAt && token.expiresAt < Date.now()) {
return html`<span class="statusBadge expired">Expired</span>`;
}
return html`<span class="statusBadge active">Active</span>`;
}
private async showCreateTokenDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
const allScopes: TApiTokenScope[] = [
'routes:read',
'routes:write',
'config:read',
'tokens:read',
'tokens:manage',
];
await DeesModal.createAndShow({
heading: 'Create API Token',
content: html`
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
The token value will be shown once after creation. Copy it immediately.
</div>
<dees-form>
<dees-input-text .key=${'name'} .label=${'Token Name'} .required=${true}></dees-input-text>
<dees-input-tags
.key=${'scopes'}
.label=${'Token Scopes'}
.value=${['routes:read', 'routes:write']}
.suggestions=${allScopes}
.required=${true}
></dees-input-tags>
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:key',
action: async (modalArg: any) => {
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 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
? parseInt(formData.expiresInDays, 10)
: null;
await modalArg.destroy();
try {
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays);
if (response.success && response.tokenValue) {
// Refresh the list first so it's ready when user dismisses the modal
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
// Show the token value in a new modal
await DeesModal.createAndShow({
heading: 'Token Created',
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Copy this token now. It will not be shown again.</p>
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
</div>
</div>
`,
menuOptions: [
{
name: 'Done',
iconName: 'lucide:check',
action: async (m: any) => await m.destroy(),
},
],
});
}
} catch (error) {
console.error('Failed to create token:', error);
}
},
},
],
});
}
async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
}
}

View File

@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import { import {
DeesElement, DeesElement,
@@ -12,6 +13,8 @@ import {
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog';
@customElement('ops-view-config') @customElement('ops-view-config')
export class OpsViewConfig extends DeesElement { export class OpsViewConfig extends DeesElement {
@state() @state()
@@ -35,165 +38,19 @@ export class OpsViewConfig extends DeesElement {
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css` 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 { .loadingMessage {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
color: ${cssManager.bdTheme('#666', '#999')}; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
} }
.infoNote { .errorMessage {
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')}; background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.1)')};
border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')}; border: 1px solid ${cssManager.bdTheme('#fecaca', 'rgba(239,68,68,0.3)')};
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
margin-bottom: 24px; color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
color: ${cssManager.bdTheme('#004085', '#88ccff')}; margin: 16px 0;
display: flex;
align-items: center;
gap: 12px;
}
.infoNote dees-icon {
font-size: 20px;
flex-shrink: 0;
} }
`, `,
]; ];
@@ -202,185 +59,266 @@ export class OpsViewConfig extends DeesElement {
return html` return html`
<ops-sectionheading>Configuration</ops-sectionheading> <ops-sectionheading>Configuration</ops-sectionheading>
${this.configState.isLoading ? html` ${this.configState.isLoading
? html`
<div class="loadingMessage"> <div class="loadingMessage">
<dees-spinner></dees-spinner> <dees-spinner></dees-spinner>
<p>Loading configuration...</p> <p>Loading configuration...</p>
</div> </div>
` : this.configState.error ? html` `
: this.configState.error
? html`
<div class="errorMessage"> <div class="errorMessage">
Error loading configuration: ${this.configState.error} Error loading configuration: ${this.configState.error}
</div> </div>
` : this.configState.config ? html` `
<div class="infoNote"> : this.configState.config
<dees-icon icon="lucide:info"></dees-icon> ? this.renderConfig()
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span> : html`<div class="errorMessage">No configuration loaded</div>`}
</div>
${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`
<div class="errorMessage">No configuration loaded</div>
`}
`; `;
} }
private renderConfigSection(key: string, title: string, icon: string, config: any) { private renderConfig(): TemplateResult {
const isEnabled = config?.enabled ?? false; const cfg = this.configState.config!;
return html` return html`
<div class="configSection"> <sz-config-overview
<div class="sectionHeader"> infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
<h3 class="sectionTitle"> @navigate=${(e: CustomEvent) => {
<dees-icon icon="${icon}"></dees-icon> if (e.detail?.view) {
${title} appRouter.navigateToView(e.detail.view);
</h3> }
${this.renderStatusBadge(isEnabled)} }}
</div> >
<div class="sectionContent"> ${this.renderSystemSection(cfg.system)}
${config ? this.renderConfigFields(config) : html` ${this.renderSmartProxySection(cfg.smartProxy)}
<div class="fieldValue empty">Not configured</div> ${this.renderEmailSection(cfg.email)}
`} ${this.renderDnsSection(cfg.dns)}
</div> ${this.renderTlsSection(cfg.tls)}
</div> ${this.renderCacheSection(cfg.cache)}
${this.renderRadiusSection(cfg.radius)}
${this.renderRemoteIngressSection(cfg.remoteIngress)}
</sz-config-overview>
`; `;
} }
private renderStatusBadge(enabled: boolean): TemplateResult { private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
return enabled const fields: IConfigField[] = [
? html`<span class="statusBadge enabled"><dees-icon icon="lucide:check"></dees-icon>Enabled</span>` { key: 'Base Directory', value: sys.baseDir },
: html`<span class="statusBadge disabled"><dees-icon icon="lucide:x"></dees-icon>Disabled</span>`; { 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 },
];
private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
if (!config || typeof config !== 'object') {
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
}
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` return html`
<div class="configField"> <sz-config-section
<label class="fieldLabel">${displayName}</label> title="System"
${this.renderStatusBadge(value)} subtitle="Base paths and infrastructure"
</div> icon="lucide:server"
status="enabled"
.fields=${fields}
></sz-config-section>
`; `;
} }
// Handle arrays private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult {
if (Array.isArray(value)) { 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` },
);
}
const actions: IConfigSectionAction[] = [
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
];
return html` return html`
<div class="configField"> <sz-config-section
<label class="fieldLabel">${displayName}</label> title="SmartProxy"
${this.renderArrayValue(value, key)} subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
</div> icon="lucide:network"
.status=${proxy.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`; `;
} }
// Handle nested objects private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult {
if (typeof value === 'object' && value !== null) { 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` return html`
<div class="configField"> <sz-config-section
<label class="fieldLabel">${displayName}</label> title="Email Server"
<div class="nestedFields"> subtitle="SMTP email handling with smartmta"
${this.renderConfigFields(value, fieldName)} icon="lucide:mail"
</div> .status=${email.enabled ? 'enabled' : 'disabled'}
</div> .fields=${fields}
.actions=${actions}
></sz-config-section>
`; `;
} }
// Handle primitive values 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` return html`
<div class="configField"> <sz-config-section
<label class="fieldLabel">${displayName}</label> title="DNS Server"
<div class="fieldValue">${this.formatValue(value, key)}</div> subtitle="Authoritative DNS with smartdns"
</div> icon="lucide:globe"
.status=${dns.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
></sz-config-section>
`; `;
});
} }
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult { private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult {
if (arr.length === 0) { const fields: IConfigField[] = [
return html`<div class="fieldValue empty">None configured</div>`; { 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 },
];
// Determine if we should show as pills/tags const status = tls.source === 'none' ? 'not-configured' : 'enabled';
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number'); const actions: IConfigSectionAction[] = [
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
];
if (showAsPills) {
const itemLabel = this.getArrayItemLabel(fieldKey, arr.length);
return html` return html`
<div class="arrayCount">${arr.length} ${itemLabel}</div> <sz-config-section
<div class="arrayItems"> title="TLS / Certificates"
${arr.map(item => html`<span class="arrayItem">${item}</span>`)} subtitle="Certificate management and ACME"
</div> icon="lucide:shield-check"
.status=${status as any}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`; `;
} }
// For complex arrays, show as JSON 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` return html`
<div class="fieldValue"> <sz-config-section
${arr.length} items configured title="Cache Database"
</div> subtitle="Persistent caching with smartdata"
icon="lucide:database"
.status=${cache.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
></sz-config-section>
`; `;
} }
private getArrayItemLabel(fieldKey: string, count: number): string { private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult {
const labels: Record<string, [string, string]> = { const fields: IConfigField[] = [
ports: ['port', 'ports'], { key: 'Auth Port', value: radius.authPort },
domains: ['domain', 'domains'], { key: 'Accounting Port', value: radius.acctPort },
nameservers: ['nameserver', 'nameservers'], { key: 'Bind Address', value: radius.bindAddress },
blockList: ['IP', 'IPs'], { key: 'Client Count', value: radius.clientCount },
}; ];
const label = labels[fieldKey] || ['item', 'items']; if (radius.vlanDefaultVlan !== null) {
return count === 1 ? label[0] : label[1]; fields.push(
{ key: 'Default VLAN', value: radius.vlanDefaultVlan },
{ key: 'Allow Unknown MACs', value: radius.vlanAllowUnknownMacs, type: 'boolean' },
{ key: 'VLAN Mappings', value: radius.vlanMappingCount },
);
} }
private formatFieldName(key: string): string { const status = radius.enabled ? 'enabled' : 'not-configured';
// Convert camelCase to readable format
return key return html`
.replace(/([A-Z])/g, ' $1') <sz-config-section
.replace(/^./, str => str.toUpperCase()) title="RADIUS Server"
.trim(); subtitle="Network authentication and VLAN assignment"
icon="lucide:wifi"
.status=${status as any}
.fields=${fields}
></sz-config-section>
`;
} }
private formatValue(value: any, fieldKey?: string): string | TemplateResult { private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult {
if (value === null || value === undefined) { const fields: IConfigField[] = [
return html`<span class="empty">Not set</span>`; { 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`
<sz-config-section
title="Remote Ingress"
subtitle="Edge tunnel nodes"
icon="lucide:cloud"
.status=${ri.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
} }
if (typeof value === 'number') { private formatUptime(seconds: number): string {
// Format bytes const days = Math.floor(seconds / 86400);
if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) { const hours = Math.floor((seconds % 86400) / 3600);
return html`<span class="numericValue">${this.formatBytes(value)}</span>`; const mins = Math.floor((seconds % 3600) / 60);
}
// Format time values
if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) {
return html`<span class="numericValue">${value} seconds</span>`;
}
// Format port numbers
if (fieldKey?.toLowerCase().includes('port')) {
return html`<span class="numericValue">${value}</span>`;
}
// Format counts with separators
return html`<span class="numericValue">${value.toLocaleString()}</span>`;
}
return String(value); const parts: string[] = [];
} if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
private formatBytes(bytes: number): string { parts.push(`${mins}m`);
if (bytes === 0) return '0 B'; return parts.join(' ');
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];
} }
} }

View File

@@ -1,8 +1,8 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element'; import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../dist_ts_interfaces/index.js';
import { appRouter } from '../router.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -10,67 +10,30 @@ declare global {
} }
} }
type TEmailFolder = 'queued' | 'sent' | 'failed' | 'received' | 'security';
@customElement('ops-view-emails') @customElement('ops-view-emails')
export class OpsViewEmails extends DeesElement { export class OpsViewEmails extends DeesElement {
@state() @state()
accessor selectedFolder: TEmailFolder = 'queued'; accessor emails: interfaces.requests.IEmail[] = [];
@state() @state()
accessor queuedEmails: interfaces.requests.IEmailQueueItem[] = []; accessor selectedEmail: interfaces.requests.IEmailDetail | null = null;
@state() @state()
accessor sentEmails: interfaces.requests.IEmailQueueItem[] = []; accessor currentView: 'list' | 'detail' = 'list';
@state()
accessor failedEmails: interfaces.requests.IEmailQueueItem[] = [];
@state()
accessor securityIncidents: interfaces.requests.ISecurityIncident[] = [];
@state()
accessor selectedEmail: interfaces.requests.IEmailQueueItem | null = null;
@state()
accessor selectedIncident: interfaces.requests.ISecurityIncident | null = null;
@state()
accessor showCompose = false;
@state() @state()
accessor isLoading = false; accessor isLoading = false;
@state()
accessor searchTerm = '';
@state()
accessor emailDomains: string[] = [];
private stateSubscription: any; private stateSubscription: any;
constructor() {
super();
this.loadData();
this.loadEmailDomains();
}
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
// Subscribe to state changes
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => { this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
this.queuedEmails = state.queuedEmails; this.emails = state.emails;
this.sentEmails = state.sentEmails;
this.failedEmails = state.failedEmails;
this.securityIncidents = state.securityIncidents;
this.isLoading = state.isLoading; this.isLoading = state.isLoading;
// Sync folder from state (e.g., when URL changes)
if (state.currentView !== this.selectedFolder) {
this.selectedFolder = state.currentView as TEmailFolder;
this.loadFolderData(state.currentView as TEmailFolder);
}
}); });
// Initial fetch
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchAllEmailsAction, null);
} }
async disconnectedCallback() { async disconnectedCallback() {
@@ -89,730 +52,58 @@ export class OpsViewEmails extends DeesElement {
height: 100%; height: 100%;
} }
.emailLayout { .viewContainer {
display: flex;
gap: 16px;
height: 100%; height: 100%;
min-height: 600px;
}
.sidebar {
flex-shrink: 0;
width: 280px;
}
.mainArea {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.emailToolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.searchBox {
flex: 1;
min-width: 200px;
max-width: 400px;
}
.emailList {
flex: 1;
overflow: hidden;
}
.emailPreview {
flex: 1;
display: flex;
flex-direction: column;
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
overflow: hidden;
}
.emailHeader {
padding: 24px;
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.emailSubject {
font-size: 24px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.emailMeta {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.emailMetaRow {
display: flex;
gap: 8px;
}
.emailMetaLabel {
font-weight: 600;
min-width: 80px;
}
.emailBody {
flex: 1;
padding: 24px;
overflow-y: auto;
font-size: 15px;
line-height: 1.6;
}
.emailActions {
display: flex;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: ${cssManager.bdTheme('#999', '#666')};
}
.emptyIcon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.emptyText {
font-size: 18px;
}
.status-pending {
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
}
.status-processing {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.status-delivered {
color: ${cssManager.bdTheme('#10b981', '#34d399')};
}
.status-failed {
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.status-deferred {
color: ${cssManager.bdTheme('#f97316', '#fb923c')};
}
.severity-info {
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.severity-warn {
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
}
.severity-error {
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.severity-critical {
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
font-weight: bold;
}
.incidentDetails {
padding: 24px;
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
}
.incidentHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.incidentTitle {
font-size: 20px;
font-weight: 600;
}
.incidentMeta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.incidentField {
padding: 12px;
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
border-radius: 6px;
}
.incidentFieldLabel {
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-bottom: 4px;
}
.incidentFieldValue {
font-size: 14px;
word-break: break-all;
} }
`, `,
]; ];
public render() { public render() {
if (this.selectedEmail) {
return this.renderEmailDetail();
}
if (this.selectedIncident) {
return this.renderIncidentDetail();
}
return html` return html`
<ops-sectionheading>Email Operations</ops-sectionheading> <ops-sectionheading>Email Operations</ops-sectionheading>
<div class="viewContainer">
<!-- Toolbar --> ${this.currentView === 'detail' && this.selectedEmail
<div class="emailToolbar" style="margin-bottom: 16px;"> ? html`
<dees-button @click=${() => this.openComposeModal()} type="highlighted"> <sz-mta-detail-view
<dees-icon icon="lucide:penLine" slot="iconSlot"></dees-icon> .email=${this.selectedEmail}
Compose @back=${this.handleBack}
</dees-button> ></sz-mta-detail-view>
`
<dees-input-text : html`
class="searchBox" <sz-mta-list-view
placeholder="Search..." .emails=${this.emails}
.value=${this.searchTerm} @email-click=${this.handleEmailClick}
@input=${(e: Event) => this.searchTerm = (e.target as any).value} ></sz-mta-list-view>
> `
<dees-icon icon="lucide:search" slot="iconSlot"></dees-icon>
</dees-input-text>
<dees-button @click=${() => this.refreshData()}>
${this.isLoading ? html`<dees-spinner slot="iconSlot" size="small"></dees-spinner>` : html`<dees-icon slot="iconSlot" icon="lucide:refreshCw"></dees-icon>`}
Refresh
</dees-button>
<div style="margin-left: auto; display: flex; gap: 8px;">
<dees-button-group>
<dees-button
@click=${() => this.selectFolder('queued')}
.type=${this.selectedFolder === 'queued' ? 'highlighted' : 'normal'}
>
Queued ${this.queuedEmails.length > 0 ? `(${this.queuedEmails.length})` : ''}
</dees-button>
<dees-button
@click=${() => this.selectFolder('sent')}
.type=${this.selectedFolder === 'sent' ? 'highlighted' : 'normal'}
>
Sent
</dees-button>
<dees-button
@click=${() => this.selectFolder('failed')}
.type=${this.selectedFolder === 'failed' ? 'highlighted' : 'normal'}
>
Failed ${this.failedEmails.length > 0 ? `(${this.failedEmails.length})` : ''}
</dees-button>
<dees-button
@click=${() => this.selectFolder('security')}
.type=${this.selectedFolder === 'security' ? 'highlighted' : 'normal'}
>
Security ${this.securityIncidents.length > 0 ? `(${this.securityIncidents.length})` : ''}
</dees-button>
</dees-button-group>
</div>
</div>
${this.renderContent()}
`;
} }
private renderContent() {
switch (this.selectedFolder) {
case 'queued':
return this.renderEmailTable(this.queuedEmails, 'Queued Emails', 'Emails waiting to be delivered');
case 'sent':
return this.renderEmailTable(this.sentEmails, 'Sent Emails', 'Successfully delivered emails');
case 'failed':
return this.renderEmailTable(this.failedEmails, 'Failed Emails', 'Emails that failed to deliver', true);
case 'security':
return this.renderSecurityIncidents();
default:
return this.renderEmptyState('Select a folder');
}
}
private renderEmailTable(
emails: interfaces.requests.IEmailQueueItem[],
heading1: string,
heading2: string,
showResend = false
) {
const filteredEmails = this.filterEmails(emails);
if (filteredEmails.length === 0) {
return this.renderEmptyState(`No emails in ${this.selectedFolder}`);
}
const actions = [
{
name: 'View Details',
iconName: 'lucide:eye',
type: ['doubleClick', 'inRow'] as any,
actionFunc: async (actionData: any) => {
this.selectedEmail = actionData.item;
}
}
];
if (showResend) {
actions.push({
name: 'Resend',
iconName: 'lucide:send',
type: ['inRow'] as any,
actionFunc: async (actionData: any) => {
await this.resendEmail(actionData.item.id);
}
});
}
return html`
<dees-table
.data=${filteredEmails}
.displayFunction=${(email: interfaces.requests.IEmailQueueItem) => ({
'Status': html`<span class="status-${email.status}">${email.status}</span>`,
'From': email.from || 'N/A',
'To': email.to?.join(', ') || 'N/A',
'Subject': email.subject || 'No subject',
'Attempts': email.attempts,
'Created': this.formatDate(email.createdAt),
})}
.dataActions=${actions}
.selectionMode=${'single'}
heading1=${heading1}
heading2=${`${filteredEmails.length} emails - ${heading2}`}
></dees-table>
`;
}
private renderSecurityIncidents() {
const incidents = this.securityIncidents;
if (incidents.length === 0) {
return this.renderEmptyState('No security incidents');
}
return html`
<dees-table
.data=${incidents}
.displayFunction=${(incident: interfaces.requests.ISecurityIncident) => ({
'Severity': html`<span class="severity-${incident.level}">${incident.level.toUpperCase()}</span>`,
'Type': incident.type,
'Message': incident.message,
'IP': incident.ipAddress || 'N/A',
'Domain': incident.domain || 'N/A',
'Time': this.formatDate(incident.timestamp),
})}
.dataActions=${[
{
name: 'View Details',
iconName: 'lucide:eye',
type: ['doubleClick', 'inRow'],
actionFunc: async (actionData: any) => {
this.selectedIncident = actionData.item;
}
}
]}
.selectionMode=${'single'}
heading1="Security Incidents"
heading2=${`${incidents.length} incidents`}
></dees-table>
`;
}
private renderEmailDetail() {
if (!this.selectedEmail) return '';
return html`
<ops-sectionheading>Email Details</ops-sectionheading>
<div class="emailLayout">
<div class="sidebar">
<dees-windowbox>
<dees-button @click=${() => this.selectedEmail = null} type="secondary" style="width: 100%;">
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
Back to List
</dees-button>
</dees-windowbox>
</div>
<div class="mainArea">
<div class="emailPreview">
<div class="emailHeader">
<div class="emailSubject">${this.selectedEmail.subject || 'No subject'}</div>
<div class="emailMeta">
<div class="emailMetaRow">
<span class="emailMetaLabel">Status:</span>
<span class="status-${this.selectedEmail.status}">${this.selectedEmail.status}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">From:</span>
<span>${this.selectedEmail.from || 'N/A'}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">To:</span>
<span>${this.selectedEmail.to?.join(', ') || 'N/A'}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">Mode:</span>
<span>${this.selectedEmail.processingMode}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">Attempts:</span>
<span>${this.selectedEmail.attempts}</span>
</div>
<div class="emailMetaRow">
<span class="emailMetaLabel">Created:</span>
<span>${new Date(this.selectedEmail.createdAt).toLocaleString()}</span>
</div>
${this.selectedEmail.deliveredAt ? html`
<div class="emailMetaRow">
<span class="emailMetaLabel">Delivered:</span>
<span>${new Date(this.selectedEmail.deliveredAt).toLocaleString()}</span>
</div>
` : ''}
${this.selectedEmail.lastError ? html`
<div class="emailMetaRow">
<span class="emailMetaLabel">Last Error:</span>
<span style="color: #ef4444;">${this.selectedEmail.lastError}</span>
</div>
` : ''}
</div>
</div>
<div class="emailActions">
${this.selectedEmail.status === 'failed' ? html`
<dees-button @click=${() => this.resendEmail(this.selectedEmail!.id)} type="highlighted">
<dees-icon icon="lucide:send" slot="iconSlot"></dees-icon>
Resend
</dees-button>
` : ''}
<dees-button @click=${() => this.selectedEmail = null}>
<dees-icon icon="lucide:x" slot="iconSlot"></dees-icon>
Close
</dees-button>
</div>
</div>
</div>
</div> </div>
`; `;
} }
private renderIncidentDetail() { private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
if (!this.selectedIncident) return ''; const emailSummary = e.detail;
const incident = this.selectedIncident;
return html`
<ops-sectionheading>Security Incident Details</ops-sectionheading>
<div style="margin-bottom: 16px;">
<dees-button @click=${() => this.selectedIncident = null} type="secondary">
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
Back to List
</dees-button>
</div>
<div class="incidentDetails">
<div class="incidentHeader">
<div>
<div class="incidentTitle">${incident.message}</div>
<div style="margin-top: 8px; color: #666;">
${new Date(incident.timestamp).toLocaleString()}
</div>
</div>
<span class="severity-${incident.level}" style="font-size: 16px; padding: 4px 12px; background: rgba(0,0,0,0.1); border-radius: 4px;">
${incident.level.toUpperCase()}
</span>
</div>
<div class="incidentMeta">
<div class="incidentField">
<div class="incidentFieldLabel">Type</div>
<div class="incidentFieldValue">${incident.type}</div>
</div>
${incident.ipAddress ? html`
<div class="incidentField">
<div class="incidentFieldLabel">IP Address</div>
<div class="incidentFieldValue">${incident.ipAddress}</div>
</div>
` : ''}
${incident.domain ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Domain</div>
<div class="incidentFieldValue">${incident.domain}</div>
</div>
` : ''}
${incident.emailId ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Email ID</div>
<div class="incidentFieldValue">${incident.emailId}</div>
</div>
` : ''}
${incident.userId ? html`
<div class="incidentField">
<div class="incidentFieldLabel">User ID</div>
<div class="incidentFieldValue">${incident.userId}</div>
</div>
` : ''}
${incident.action ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Action</div>
<div class="incidentFieldValue">${incident.action}</div>
</div>
` : ''}
${incident.result ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Result</div>
<div class="incidentFieldValue">${incident.result}</div>
</div>
` : ''}
${incident.success !== undefined ? html`
<div class="incidentField">
<div class="incidentFieldLabel">Success</div>
<div class="incidentFieldValue">${incident.success ? 'Yes' : 'No'}</div>
</div>
` : ''}
</div>
${incident.details ? html`
<div style="margin-top: 24px;">
<div class="incidentFieldLabel" style="margin-bottom: 8px;">Details</div>
<pre style="background: #1a1a1a; color: #e5e5e5; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px;">
${JSON.stringify(incident.details, null, 2)}
</pre>
</div>
` : ''}
</div>
`;
}
private renderEmptyState(message: string) {
return html`
<div class="emptyState">
<dees-icon class="emptyIcon" icon="lucide:inbox"></dees-icon>
<div class="emptyText">${message}</div>
</div>
`;
}
private async openComposeModal() {
const { DeesModal } = await import('@design.estate/dees-catalog');
// Ensure domains are loaded before opening modal
if (this.emailDomains.length === 0) {
await this.loadEmailDomains();
}
await DeesModal.createAndShow({
heading: 'New Email',
width: 'large',
content: html`
<div>
<dees-form @formData=${async (e: CustomEvent) => {
await this.sendEmail(e.detail);
const modals = document.querySelectorAll('dees-modal');
modals.forEach(m => (m as any).destroy?.());
}}>
<div style="display: flex; gap: 8px; align-items: flex-end;">
<dees-input-text
key="fromUsername"
label="From"
placeholder="username"
.value=${'admin'}
required
style="flex: 1;"
></dees-input-text>
<span style="padding-bottom: 12px; font-size: 18px; color: #666;">@</span>
<dees-input-dropdown
key="fromDomain"
label=" "
.options=${this.emailDomains.length > 0
? this.emailDomains.map(domain => ({ key: domain, value: domain }))
: [{ key: 'dcrouter.local', value: 'dcrouter.local' }]}
.selectedKey=${this.emailDomains[0] || 'dcrouter.local'}
required
style="flex: 1;"
></dees-input-dropdown>
</div>
<dees-input-tags
key="to"
label="To"
placeholder="Enter recipient email addresses..."
required
></dees-input-tags>
<dees-input-tags
key="cc"
label="CC"
placeholder="Enter CC recipients..."
></dees-input-tags>
<dees-input-text
key="subject"
label="Subject"
placeholder="Enter email subject..."
required
></dees-input-text>
<dees-input-wysiwyg
key="body"
label="Message"
outputFormat="html"
></dees-input-wysiwyg>
</dees-form>
</div>
`,
menuOptions: [
{
name: 'Send',
iconName: 'lucide:send',
action: async (modalArg) => {
const form = modalArg.shadowRoot?.querySelector('dees-form') as any;
form?.submit();
}
},
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg) => await modalArg.destroy()
}
]
});
}
private filterEmails(emails: interfaces.requests.IEmailQueueItem[]): interfaces.requests.IEmailQueueItem[] {
if (!this.searchTerm) {
return emails;
}
const search = this.searchTerm.toLowerCase();
return emails.filter(e =>
(e.subject?.toLowerCase().includes(search)) ||
(e.from?.toLowerCase().includes(search)) ||
(e.to?.some(t => t.toLowerCase().includes(search)))
);
}
private selectFolder(folder: TEmailFolder) {
// Use router for navigation to update URL
appRouter.navigateToEmailFolder(folder);
// Clear selections
this.selectedEmail = null;
this.selectedIncident = null;
}
private formatDate(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = diff / (1000 * 60 * 60);
if (hours < 24) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (hours < 168) { // 7 days
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
}
private async loadData() {
this.isLoading = true;
await this.loadFolderData(this.selectedFolder);
this.isLoading = false;
}
private async loadFolderData(folder: TEmailFolder) {
switch (folder) {
case 'queued':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchQueuedEmailsAction, null);
break;
case 'sent':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSentEmailsAction, null);
break;
case 'failed':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchFailedEmailsAction, null);
break;
case 'security':
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSecurityIncidentsAction, null);
break;
}
}
private async loadEmailDomains() {
try { try {
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null); const context = appstate.loginStatePart.getState();
const config = appstate.configStatePart.getState().config; const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetEmailDetail
>('/typedrequest', 'getEmailDetail');
if (config?.email?.domains && Array.isArray(config.email.domains) && config.email.domains.length > 0) { const response = await request.fire({
this.emailDomains = config.email.domains; identity: context.identity,
} else { emailId: emailSummary.id,
this.emailDomains = ['dcrouter.local']; });
if (response.email) {
this.selectedEmail = response.email;
this.currentView = 'detail';
} }
} catch (error) { } catch (error) {
console.error('Failed to load email domains:', error); console.error('Failed to fetch email detail:', error);
this.emailDomains = ['dcrouter.local'];
} }
} }
private async refreshData() { private handleBack() {
this.isLoading = true;
await this.loadFolderData(this.selectedFolder);
this.isLoading = false;
}
private async sendEmail(formData: any) {
try {
console.log('Sending email:', formData);
// TODO: Implement actual email sending via API
// For now, just log the data
const fromEmail = `${formData.fromUsername || 'admin'}@${formData.fromDomain || this.emailDomains[0] || 'dcrouter.local'}`;
console.log('From:', fromEmail);
console.log('To:', formData.to);
console.log('Subject:', formData.subject);
} catch (error: any) {
console.error('Failed to send email', error);
}
}
private async resendEmail(emailId: string) {
try {
await appstate.emailOpsStatePart.dispatchAction(appstate.resendEmailAction, emailId);
this.selectedEmail = null; this.selectedEmail = null;
} catch (error) { this.currentView = 'list';
console.error('Failed to resend email:', error);
}
} }
} }

View File

@@ -1,4 +1,3 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js'; import * as shared from './shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../appstate.js';
@@ -20,15 +19,6 @@ export class OpsViewLogs extends DeesElement {
filters: {}, filters: {},
}; };
@state()
accessor filterLevel: string | undefined;
@state()
accessor filterCategory: string | undefined;
@state()
accessor filterLimit: number = 100;
private lastPushedCount = 0; private lastPushedCount = 0;
constructor() { constructor() {
@@ -44,63 +34,13 @@ export class OpsViewLogs extends DeesElement {
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
shared.viewHostCss, shared.viewHostCss,
css` css``,
.controls {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.filterGroup {
display: flex;
align-items: center;
gap: 8px;
}
`,
]; ];
public render() { public render() {
return html` return html`
<ops-sectionheading>Logs</ops-sectionheading> <ops-sectionheading>Logs</ops-sectionheading>
<div class="controls">
<div class="filterGroup">
<dees-button
@click=${() => this.fetchLogs()}
>
Refresh Logs
</dees-button>
</div>
<div class="filterGroup">
<label>Level:</label>
<dees-input-dropdown
.options=${['all', 'debug', 'info', 'warn', 'error']}
.selectedOption=${'all'}
@selectedOption=${(e: any) => this.updateFilter('level', e.detail)}
></dees-input-dropdown>
</div>
<div class="filterGroup">
<label>Category:</label>
<dees-input-dropdown
.options=${['all', 'smtp', 'dns', 'security', 'system', 'email']}
.selectedOption=${'all'}
@selectedOption=${(e: any) => this.updateFilter('category', e.detail)}
></dees-input-dropdown>
</div>
<div class="filterGroup">
<label>Limit:</label>
<dees-input-dropdown
.options=${['50', '100', '200', '500']}
.selectedOption=${'100'}
@selectedOption=${(e: any) => this.updateFilter('limit', e.detail)}
></dees-input-dropdown>
</div>
</div>
<dees-chart-log <dees-chart-log
.label=${'Application Logs'} .label=${'Application Logs'}
.autoScroll=${true} .autoScroll=${true}
@@ -115,7 +55,7 @@ export class OpsViewLogs extends DeesElement {
this.lastPushedCount = 0; this.lastPushedCount = 0;
// Only fetch if state is empty (streaming will handle new entries) // Only fetch if state is empty (streaming will handle new entries)
if (this.logState.recentLogs.length === 0) { if (this.logState.recentLogs.length === 0) {
this.fetchLogs(); await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
} }
} }
@@ -166,29 +106,4 @@ export class OpsViewLogs extends DeesElement {
})); }));
} }
private async fetchLogs() {
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, {
limit: this.filterLimit,
level: this.filterLevel as 'debug' | 'info' | 'warn' | 'error' | undefined,
category: this.filterCategory as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined,
});
}
private updateFilter(type: string, value: string) {
const resolved = value === 'all' ? undefined : value;
switch (type) {
case 'level':
this.filterLevel = resolved;
break;
case 'category':
this.filterCategory = resolved;
break;
case 'limit':
this.filterLimit = resolved ? parseInt(resolved, 10) : 100;
break;
}
this.fetchLogs();
}
} }

View File

@@ -0,0 +1,389 @@
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
import {
DeesElement,
css,
cssManager,
customElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('ops-view-routes')
export class OpsViewRoutes extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
apiTokens: [],
isLoading: false,
error: null,
lastUpdated: 0,
};
constructor() {
super();
const sub = appstate.routeManagementStatePart
.select((s) => s)
.subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(sub);
// Re-fetch routes when user logs in (fixes race condition where
// the view is created before authentication completes)
const loginSub = appstate.loginStatePart
.select((s) => s.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
});
this.rxSubscriptions.push(loginSub);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.routesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.warnings-bar {
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
border-radius: 8px;
padding: 12px 16px;
}
.warning-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 13px;
color: ${cssManager.bdTheme('#b45309', '#fa0')};
}
.warning-icon {
flex-shrink: 0;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: ${cssManager.bdTheme('#6b7280', '#666')};
}
.empty-state p {
margin: 8px 0;
}
`,
];
public render(): TemplateResult {
const { mergedRoutes, warnings } = this.routeState;
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
const statsTiles: IStatsTile[] = [
{
id: 'totalRoutes',
title: 'Total Routes',
type: 'number',
value: mergedRoutes.length,
icon: 'lucide:route',
description: 'All configured routes',
color: '#3b82f6',
},
{
id: 'hardcoded',
title: 'Hardcoded',
type: 'number',
value: hardcodedCount,
icon: 'lucide:lock',
description: 'Routes from constructor config',
color: '#8b5cf6',
},
{
id: 'programmatic',
title: 'Programmatic',
type: 'number',
value: programmaticCount,
icon: 'lucide:code',
description: 'Routes added via API',
color: '#0ea5e9',
},
{
id: 'disabled',
title: 'Disabled',
type: 'number',
value: disabledCount,
icon: 'lucide:pauseCircle',
description: 'Currently disabled routes',
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
},
];
// Map merged routes to sz-route-list-view format
const szRoutes = mergedRoutes.map((mr) => {
const tags = [...(mr.route.tags || [])];
tags.push(mr.source);
if (!mr.enabled) tags.push('disabled');
if (mr.overridden) tags.push('overridden');
return {
...mr.route,
enabled: mr.enabled,
tags,
id: mr.storedRouteId || mr.route.name || undefined,
};
});
return html`
<ops-sectionheading>Route Management</ops-sectionheading>
<div class="routesContainer">
<dees-statsgrid
.tiles=${statsTiles}
.gridActions=${[
{
name: 'Add Route',
iconName: 'lucide:plus',
action: () => this.showCreateRouteDialog(),
},
{
name: 'Refresh',
iconName: 'lucide:refreshCw',
action: () => this.refreshData(),
},
]}
></dees-statsgrid>
${warnings.length > 0
? html`
<div class="warnings-bar">
${warnings.map(
(w) => html`
<div class="warning-item">
<span class="warning-icon">&#9888;</span>
<span>${w.message}</span>
</div>
`,
)}
</div>
`
: ''}
${szRoutes.length > 0
? html`
<sz-route-list-view
.routes=${szRoutes}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
></sz-route-list-view>
`
: html`
<div class="empty-state">
<p>No routes configured</p>
<p>Add a programmatic route or check your constructor configuration.</p>
</div>
`}
</div>
`;
}
private async handleRouteClick(e: CustomEvent) {
const clickedRoute = e.detail;
if (!clickedRoute) return;
// Find the corresponding merged route
const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name,
);
if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog');
if (merged.source === 'hardcoded') {
const menuOptions = merged.enabled
? [
{
name: 'Disable Route',
iconName: 'lucide:pause',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: false },
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
]
: [
{
name: 'Enable Route',
iconName: 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.setRouteOverrideAction,
{ routeName: merged.route.name!, enabled: true },
);
await modalArg.destroy();
},
},
{
name: 'Remove Override',
iconName: 'lucide:undo',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.removeRouteOverrideAction,
merged.route.name!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
];
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
</div>
`,
menuOptions,
});
} else {
// Programmatic route
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
<div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
</div>
`,
menuOptions: [
{
name: merged.enabled ? 'Disable' : 'Enable',
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleRouteAction,
{ id: merged.storedRouteId!, enabled: !merged.enabled },
);
await modalArg.destroy();
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction,
merged.storedRouteId!,
);
await modalArg.destroy();
},
},
{
name: 'Close',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
}
}
private async showCreateRouteDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Add Programmatic Route',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, optional)'}></dees-input-text>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .value=${'localhost'} .required=${true}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .required=${true}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
if (!formData.name || !formData.ports) return;
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
const domains = formData.domains
? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean)
: undefined;
const route: any = {
name: formData.name,
match: {
ports,
...(domains && domains.length > 0 ? { domains } : {}),
},
action: {
type: 'forward',
targets: [
{
host: formData.targetHost || 'localhost',
port: parseInt(formData.targetPort, 10),
},
],
},
};
await appstate.routeManagementStatePart.dispatchAction(
appstate.createRouteAction,
{ route },
);
await modalArg.destroy();
},
},
],
});
}
private refreshData() {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
async firstUpdated() {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
}

View File

@@ -2,12 +2,16 @@
import * as deesElement from '@design.estate/dees-element'; import * as deesElement from '@design.estate/dees-element';
import * as deesCatalog from '@design.estate/dees-catalog'; import * as deesCatalog from '@design.estate/dees-catalog';
// @serve.zone scope
import * as szCatalog from '@serve.zone/catalog';
// TypedSocket for real-time push communication // TypedSocket for real-time push communication
import * as typedsocket from '@api.global/typedsocket'; import * as typedsocket from '@api.global/typedsocket';
export { export {
deesElement, deesElement,
deesCatalog, deesCatalog,
szCatalog,
typedsocket, typedsocket,
} }

View File

@@ -3,11 +3,9 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const; export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
export type TValidView = typeof validViews[number]; export type TValidView = typeof validViews[number];
export type TValidEmailFolder = typeof validEmailFolders[number];
class AppRouter { class AppRouter {
private router: InstanceType<typeof SmartRouter>; private router: InstanceType<typeof SmartRouter>;
@@ -27,32 +25,11 @@ class AppRouter {
} }
private setupRoutes(): void { private setupRoutes(): void {
// Main views
for (const view of validViews) { for (const view of validViews) {
if (view === 'emails') {
// Email root - default to queued
this.router.on('/emails', async () => {
this.updateViewState('emails');
this.updateEmailFolder('queued');
});
// Email with folder parameter
this.router.on('/emails/:folder', async (routeInfo) => {
const folder = routeInfo.params.folder as string;
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
this.updateViewState('emails');
this.updateEmailFolder(folder as TValidEmailFolder);
} else {
// Invalid folder, redirect to queued
this.navigateTo('/emails/queued');
}
});
} else {
this.router.on(`/${view}`, async () => { this.router.on(`/${view}`, async () => {
this.updateViewState(view); this.updateViewState(view);
}); });
} }
}
// Root redirect // Root redirect
this.router.on('/', async () => { this.router.on('/', async () => {
@@ -61,60 +38,32 @@ class AppRouter {
} }
private setupStateSync(): void { private setupStateSync(): void {
// Sync URL when state changes programmatically (not from router)
appstate.uiStatePart.state.subscribe((uiState) => { appstate.uiStatePart.state.subscribe((uiState) => {
if (this.suppressStateUpdate) return; if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const expectedPath = this.getExpectedPath(uiState.activeView); const expectedPath = `/${uiState.activeView}`;
// Only update URL if it doesn't match current state if (currentPath !== expectedPath) {
if (!currentPath.startsWith(expectedPath)) {
this.suppressStateUpdate = true; this.suppressStateUpdate = true;
if (uiState.activeView === 'emails') { this.router.pushUrl(expectedPath);
const emailState = appstate.emailOpsStatePart.getState();
this.router.pushUrl(`/emails/${emailState.currentView}`);
} else {
this.router.pushUrl(`/${uiState.activeView}`);
}
this.suppressStateUpdate = false; this.suppressStateUpdate = false;
} }
}); });
} }
private getExpectedPath(view: string): string {
if (view === 'emails') {
return '/emails';
}
return `/${view}`;
}
private handleInitialRoute(): void { private handleInitialRoute(): void {
const path = window.location.pathname; const path = window.location.pathname;
if (!path || path === '/') { if (!path || path === '/') {
// Redirect root to overview
this.router.pushUrl('/overview'); this.router.pushUrl('/overview');
} else { } else {
// Parse current path and update state
const segments = path.split('/').filter(Boolean); const segments = path.split('/').filter(Boolean);
const view = segments[0]; const view = segments[0];
if (validViews.includes(view as TValidView)) { if (validViews.includes(view as TValidView)) {
this.updateViewState(view as TValidView); this.updateViewState(view as TValidView);
if (view === 'emails' && segments[1]) {
const folder = segments[1];
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
this.updateEmailFolder(folder as TValidEmailFolder);
} else { } else {
this.updateEmailFolder('queued');
}
} else if (view === 'emails') {
this.updateEmailFolder('queued');
}
} else {
// Invalid view, redirect to overview
this.router.pushUrl('/overview'); this.router.pushUrl('/overview');
} }
} }
@@ -132,18 +81,6 @@ class AppRouter {
this.suppressStateUpdate = false; this.suppressStateUpdate = false;
} }
private updateEmailFolder(folder: TValidEmailFolder): void {
this.suppressStateUpdate = true;
const currentState = appstate.emailOpsStatePart.getState();
if (currentState.currentView !== folder) {
appstate.emailOpsStatePart.setState({
...currentState,
currentView: folder as appstate.IEmailOpsState['currentView'],
});
}
this.suppressStateUpdate = false;
}
public navigateTo(path: string): void { public navigateTo(path: string): void {
this.router.pushUrl(path); this.router.pushUrl(path);
} }
@@ -156,22 +93,10 @@ class AppRouter {
} }
} }
public navigateToEmailFolder(folder: string): void {
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
this.navigateTo(`/emails/${folder}`);
} else {
this.navigateTo('/emails/queued');
}
}
public getCurrentView(): string { public getCurrentView(): string {
return appstate.uiStatePart.getState().activeView; return appstate.uiStatePart.getState().activeView;
} }
public getCurrentEmailFolder(): string {
return appstate.emailOpsStatePart.getState().currentView;
}
public destroy(): void { public destroy(): void {
this.router.destroy(); this.router.destroy();
this.initialized = false; this.initialized = false;