Compare commits

...

8 Commits

Author SHA1 Message Date
0b81c95de2 v13.10.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 20:43:57 +00:00
196e5dfc1b feat(web-ui): standardize settings views for ACME and email security panels 2026-04-12 20:43:57 +00:00
60d095cd78 v13.9.2
Some checks failed
Docker (tags) / security (push) Failing after 2m58s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 19:42:07 +00:00
2861511d20 fix(web-ui): improve form field descriptions and align certificate settings with tile components 2026-04-12 19:42:07 +00:00
b582d44502 v13.9.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 15:26:12 +00:00
36a2ebc94e fix(network-ui): enable flashing table updates for network activity, remote ingress, and VPN views 2026-04-08 15:26:12 +00:00
ed52a3188d v13.9.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 14:54:49 +00:00
93cc5c7b06 feat(dns): add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local 2026-04-08 14:54:49 +00:00
29 changed files with 453 additions and 385 deletions

View File

@@ -1,5 +1,35 @@
# Changelog
## 2026-04-12 - 13.10.0 - feat(web-ui)
standardize settings views for ACME and email security panels
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
## 2026-04-12 - 13.9.2 - fix(web-ui)
improve form field descriptions and align certificate settings with tile components
- Refines labels and adds descriptive helper text across API token, DNS, domain, route, edge, target profile, and VPN forms for clearer operator input
- Updates the DNS provider form to surface provider and credential guidance through built-in input metadata instead of custom help blocks
- Restyles the certificates ACME settings section to use tile-based layout and improves related form wording and file upload metadata
- Refreshes the Cloudflare DNS provider description and bumps UI-related dependencies
## 2026-04-08 - 13.9.1 - fix(network-ui)
enable flashing table updates for network activity, remote ingress, and VPN views
- adds stable row keys to dees-table instances so existing rows can be diffed correctly
- enables flash highlighting for changed rows and cells across network activity, top IPs, backends, remote ingress edges, and VPN clients
- updates network activity request data on every refresh so live metrics like duration and byte counts visibly refresh
## 2026-04-08 - 13.9.0 - feat(dns)
add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local
- Expose a synthetic built-in "DcRouter" provider in provider listings and block create, edit, delete, test, and external domain listing operations for it
- Rename domain and record source semantics from "manual" to "dcrouter" and "local" across backend, interfaces, and UI
- Add database migrations to convert existing DomainDoc.source and DnsRecordDoc.source values to the new naming
- Update domain creation flows and provider UI labels to reflect dcrouter-hosted authoritative domains
## 2026-04-08 - 13.8.0 - feat(acme)
add DB-backed ACME configuration management and OpsServer certificate settings UI

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.8.0",
"version": "13.10.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -27,7 +27,7 @@
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.2"
"@types/node": "^25.6.0"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.0",
@@ -35,7 +35,7 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.69.1",
"@design.estate/dees-catalog": "^3.78.0",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",
@@ -49,7 +49,7 @@
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.1.1",
"@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0",
@@ -67,7 +67,7 @@
"@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.2",
"lru-cache": "^11.3.3",
"qrcode": "^1.5.4",
"uuid": "^13.0.0"
},

113
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
specifier: ^3.69.1
version: 3.69.1(@tiptap/pm@2.27.2)
specifier: ^3.78.0
version: 3.78.0(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -66,8 +66,8 @@ importers:
specifier: ^3.0.3
version: 3.0.3
'@push.rocks/smartmigration':
specifier: 1.1.1
version: 1.1.1(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
specifier: 1.2.0
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
'@push.rocks/smartmta':
specifier: ^5.3.1
version: 5.3.1
@@ -120,8 +120,8 @@ importers:
specifier: ^1.5.6
version: 1.5.6
lru-cache:
specifier: ^11.3.2
version: 11.3.2
specifier: ^11.3.3
version: 11.3.3
qrcode:
specifier: ^1.5.4
version: 1.5.4
@@ -145,8 +145,8 @@ importers:
specifier: ^3.3.2
version: 3.3.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@tiptap/pm@2.27.2)
'@types/node':
specifier: ^25.5.2
version: 25.5.2
specifier: ^25.6.0
version: 25.6.0
packages:
@@ -353,8 +353,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.69.1':
resolution: {integrity: sha512-OSpHB/hfOrL2mkAfF50TqTKJ2hvPd7Cj1WklAmFckyjloE4fd7DRDeXdI/Bziq9152gExipX5VoofTAOr4rF5w==}
'@design.estate/dees-catalog@3.78.0':
resolution: {integrity: sha512-doc9eYGsFV47Ui7k5FuLXpt3ytC/Q+g+yX+qGU/V4fZpc5KUXpL04/FRzO0AU1wF9Xl9GMmL39CcE2vKj88QAQ==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -368,6 +368,9 @@ packages:
'@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@design.estate/dees-wcctools@3.8.4':
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==}
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -1231,8 +1234,8 @@ packages:
'@push.rocks/smartmetrics@3.0.3':
resolution: {integrity: sha512-RYY4NOla3kraZYVF9TBHgIz4/hSkqVDVNP7tLwhLK5mGBPBy8I/9NWXX6txZKQw6QihP85YD8mWUuUu2xS4D6Q==}
'@push.rocks/smartmigration@1.1.1':
resolution: {integrity: sha512-K/eLN9cNy+CLOT73rhI93vOy/vGwpV46iJpjRUyPwHsMcQcV6po2idk5+XZQzeuq2x7KpKuUPtZ6gXMtf5Y/ig==}
'@push.rocks/smartmigration@1.2.0':
resolution: {integrity: sha512-H2diE1UbZm4cXjxgxkt2YQW3aUQ3QVVU/e8Ws30hzIep0xIqL1BH6//WawA5ZBQhnAOBssZpVOuWOd3GIeBq+Q==}
peerDependencies:
'@push.rocks/smartbucket': ^4.6.0
'@push.rocks/smartdata': ^7.1.7
@@ -2054,8 +2057,8 @@ packages:
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@25.5.2':
resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -2858,8 +2861,8 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=}
ibantools@4.5.2:
resolution: {integrity: sha512-is+8TgZcKS/AMv/z9nW1zz0bhjhoyjpA1p0nc3A6GkW/InOdcQiUZpkufADzh/aO/LY/TOD/P3oPWncNRn5QMA==}
ibantools@4.5.4:
resolution: {integrity: sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
@@ -3076,16 +3079,16 @@ packages:
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lru-cache@11.3.2:
resolution: {integrity: sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==}
lru-cache@11.3.3:
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
engines: {node: 20 || >=22}
lru-cache@7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
lucide@0.577.0:
resolution: {integrity: sha512-PpC/m5eOItp/WU/GlQPFBXDOhq6HibL73KzYP37OX3LM7VmzWQF8voEj8QRWUFvy9FIKfeDQkWYoyS1D/MdWFA==}
lucide@1.8.0:
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
mailparser@3.9.6:
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
@@ -4098,8 +4101,8 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -4315,7 +4318,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260405.1
'@design.estate/dees-catalog': 3.69.1(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.78.0(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
@@ -4844,11 +4847,11 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.69.1(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.78.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
'@design.estate/dees-wcctools': 3.8.4
'@fortawesome/fontawesome-svg-core': 7.2.0
'@fortawesome/free-brands-svg-icons': 7.2.0
'@fortawesome/free-regular-svg-icons': 7.2.0
@@ -4866,9 +4869,9 @@ snapshots:
'@tsclass/tsclass': 9.5.0
echarts: 5.6.0
highlight.js: 11.11.1
ibantools: 4.5.2
ibantools: 4.5.4
lightweight-charts: 5.1.0
lucide: 0.577.0
lucide: 1.8.0
monaco-editor: 0.55.1
pdfjs-dist: 4.10.38
xterm: 5.3.0
@@ -4937,6 +4940,18 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-wcctools@3.8.4':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@push.rocks/smartdelay': 3.0.5
lit: 3.3.2
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@emnapi/core@1.9.2':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -6354,7 +6369,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmigration@1.1.1(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))':
'@push.rocks/smartmigration@1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))':
dependencies:
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartversion': 3.1.0
@@ -6404,7 +6419,7 @@ snapshots:
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2
'@tsclass/tsclass': 9.5.0
lru-cache: 11.3.2
lru-cache: 11.3.3
mailparser: 3.9.6
uuid: 13.0.0
transitivePeerDependencies:
@@ -6900,7 +6915,7 @@ snapshots:
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.69.1(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.78.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
@@ -7442,7 +7457,7 @@ snapshots:
'@types/clean-css@4.2.11':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
source-map: 0.6.1
'@types/debug@4.1.13':
@@ -7452,7 +7467,7 @@ snapshots:
'@types/fs-extra@11.0.4':
dependencies:
'@types/jsonfile': 6.1.4
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/hast@3.0.4':
dependencies:
@@ -7472,12 +7487,12 @@ snapshots:
'@types/jsonfile@6.1.4':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/linkify-it@5.0.0': {}
@@ -7498,16 +7513,16 @@ snapshots:
'@types/mute-stream@0.0.4':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
form-data: 4.0.5
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/node@16.9.1': {}
@@ -7519,13 +7534,13 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@25.5.2':
'@types/node@25.6.0':
dependencies:
undici-types: 7.18.2
undici-types: 7.19.2
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/randomatic@3.1.5': {}
@@ -7535,11 +7550,11 @@ snapshots:
'@types/tar-stream@3.1.4':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/through2@2.0.41':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/trusted-types@2.0.7': {}
@@ -7569,11 +7584,11 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.5.2
'@types/node': 25.6.0
optional: true
'@ungap/structured-clone@1.3.0': {}
@@ -8390,7 +8405,7 @@ snapshots:
dependencies:
ms: 2.1.3
ibantools@4.5.2: {}
ibantools@4.5.4: {}
iconv-lite@0.4.24:
dependencies:
@@ -8628,11 +8643,11 @@ snapshots:
lowercase-keys@3.0.0: {}
lru-cache@11.3.2: {}
lru-cache@11.3.3: {}
lru-cache@7.18.3: {}
lucide@0.577.0: {}
lucide@1.8.0: {}
mailparser@3.9.6:
dependencies:
@@ -9268,7 +9283,7 @@ snapshots:
path-scurry@2.0.2:
dependencies:
lru-cache: 11.3.2
lru-cache: 11.3.3
minipass: 7.1.3
path-to-regexp@8.4.2: {}
@@ -9942,7 +9957,7 @@ snapshots:
undici-types@6.21.0: {}
undici-types@7.18.2: {}
undici-types@7.19.2: {}
unified@11.0.5:
dependencies:

View File

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

View File

@@ -1792,7 +1792,8 @@ export class DcRouter {
logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
}
// Hand the DnsServer to DnsManager so DB-backed manual records get registered too.
// Hand the DnsServer to DnsManager so DB-backed local records on
// dcrouter-hosted domains get registered too.
if (this.dnsManager && this.dnsServer) {
await this.dnsManager.attachDnsServer(this.dnsServer);
}

View File

@@ -25,9 +25,9 @@ import type {
* Responsibilities:
* - Load Domain/DnsRecord docs from the DB on start
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
* - Register manual-domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (manual domains hit smartdns,
* provider domains hit the provider API)
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
* smartdns, provider domains hit the provider API)
* - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
*
* Provider-managed domains are NEVER served from the embedded DnsServer — the
@@ -69,12 +69,12 @@ export class DnsManager {
/**
* Wire the embedded DnsServer instance after it has been created by
* DcRouter.setupDnsWithSocketHandler(). After this, manual records loaded
* from the DB are registered with the server.
* DcRouter.setupDnsWithSocketHandler(). After this, local records on
* dcrouter-hosted domains loaded from the DB are registered with the server.
*/
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
this.dnsServer = dnsServer;
await this.applyManualDomainsToDnsServer();
await this.applyDcrouterDomainsToDnsServer();
}
// ==========================================================================
@@ -83,7 +83,8 @@ export class DnsManager {
/**
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
* seed them as `source: 'manual'` records. On subsequent boots (DB has
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
* local (`record.source: 'local'`) records. On subsequent boots (DB has
* entries), constructor config is ignored with a warning.
*/
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
@@ -117,7 +118,7 @@ export class DnsManager {
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = scope.toLowerCase();
domain.source = 'manual';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
@@ -144,7 +145,7 @@ export class DnsManager {
record.type = rec.type as TDnsRecordType;
record.value = rec.value;
record.ttl = rec.ttl ?? 300;
record.source = 'manual';
record.source = 'local';
record.createdAt = now;
record.updatedAt = now;
record.createdBy = 'seed';
@@ -174,28 +175,31 @@ export class DnsManager {
}
// ==========================================================================
// Manual-domain DnsServer wiring
// DcRouter-hosted domain DnsServer wiring
// ==========================================================================
/**
* Register all manual-domain records from the DB with the embedded DnsServer.
* Called once after attachDnsServer().
* Register all records from dcrouter-hosted domains in the DB with the
* embedded DnsServer. Called once after attachDnsServer().
*/
private async applyManualDomainsToDnsServer(): Promise<void> {
private async applyDcrouterDomainsToDnsServer(): Promise<void> {
if (!this.dnsServer) {
return;
}
const allDomains = await DomainDoc.findAll();
const manualDomains = allDomains.filter((d) => d.source === 'manual');
const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter');
let registered = 0;
for (const domain of manualDomains) {
for (const domain of dcrouterDomains) {
const records = await DnsRecordDoc.findByDomainId(domain.id);
for (const rec of records) {
this.registerRecordWithDnsServer(rec);
registered++;
}
}
logger.log('info', `DnsManager: registered ${registered} manual DNS record(s) from DB`);
logger.log(
'info',
`DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`,
);
}
/**
@@ -381,6 +385,12 @@ export class DnsManager {
credentials: TDnsProviderCredentials;
createdBy: string;
}): Promise<string> {
if (args.type === 'dcrouter') {
throw new Error(
'createProvider: cannot create a DnsProviderDoc with type "dcrouter" — ' +
'that type is reserved for the built-in pseudo-provider surfaced at read time.',
);
}
const now = Date.now();
const doc = new DnsProviderDoc();
doc.id = plugins.uuid.v4();
@@ -473,10 +483,10 @@ export class DnsManager {
}
/**
* Create a manual (authoritative) domain. dcrouter will serve DNS records
* for this domain via the embedded smartdns.DnsServer.
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
* DNS records for this domain via the embedded smartdns.DnsServer.
*/
public async createManualDomain(args: {
public async createDcrouterDomain(args: {
name: string;
description?: string;
createdBy: string;
@@ -485,7 +495,7 @@ export class DnsManager {
const doc = new DomainDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name.toLowerCase();
doc.source = 'manual';
doc.source = 'dcrouter';
doc.authoritative = true;
doc.description = args.description;
doc.createdAt = now;
@@ -571,10 +581,11 @@ export class DnsManager {
/**
* Delete a domain and all of its DNS records. For provider domains, only
* removes the local mirror — does NOT touch the provider.
* For manual domains, also unregisters records from the embedded DnsServer.
* For dcrouter-hosted domains, also unregisters records from the embedded
* DnsServer.
*
* Note: smartdns has no public unregister-by-name API in the version pinned
* here, so manual record deletes only take effect after a restart. The DB
* here, so local record deletes only take effect after a restart. The DB
* is the source of truth and the next start will not register the deleted
* record.
*/
@@ -652,7 +663,7 @@ export class DnsManager {
doc.value = args.value;
doc.ttl = args.ttl ?? 300;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.source = 'manual';
doc.source = 'local';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
@@ -678,7 +689,7 @@ export class DnsManager {
return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
}
} else {
// Manual / authoritative — register with embedded DnsServer immediately
// dcrouter-hosted / authoritative — register with embedded DnsServer immediately
this.registerRecordWithDnsServer(doc);
}
@@ -722,7 +733,7 @@ export class DnsManager {
return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
}
} else {
// Re-register the manual record so the new closure picks up the updated fields
// Re-register the local record so the new closure picks up the updated fields
this.registerRecordWithDnsServer(doc);
}
@@ -748,7 +759,7 @@ export class DnsManager {
}
}
}
// For manual records: smartdns has no unregister API in the pinned version,
// For local records: smartdns has no unregister API in the pinned version,
// so the record stays served until the next restart. The DB delete still
// takes effect — on restart, the record will not be re-registered.
@@ -807,7 +818,7 @@ export class DnsManager {
public toPublicDomain(doc: DomainDoc): {
id: string;
name: string;
source: 'manual' | 'provider';
source: 'dcrouter' | 'provider';
providerId?: string;
authoritative: boolean;
nameservers?: string[];

View File

@@ -38,6 +38,17 @@ export function createDnsProvider(
}
return new CloudflareDnsProvider(credentials.apiToken);
}
case 'dcrouter': {
// The built-in DcRouter pseudo-provider has no runtime client — dcrouter
// itself serves the records via the embedded smartdns.DnsServer. This
// case exists only to satisfy the exhaustive switch; it should never
// actually run because the handler layer rejects any CRUD that would
// result in a DnsProviderDoc with type: 'dcrouter'.
throw new Error(
`createDnsProvider: 'dcrouter' is a built-in pseudo-provider — no runtime client exists. ` +
`This call indicates a DnsProviderDoc with type: 'dcrouter' was persisted, which should never happen.`,
);
}
default: {
// If you see a TypeScript error here after extending TDnsProviderType,
// add a `case` for the new type above. The `never` enforces exhaustiveness.

View File

@@ -46,15 +46,28 @@ export class DnsProviderHandler {
}
private registerHandlers(): void {
// Get all providers
// Get all providers — prepends the built-in DcRouter pseudo-provider
// so operators see a uniform "who serves this?" list that includes the
// authoritative dcrouter alongside external accounts.
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProviders>(
'getDnsProviders',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { providers: [] };
return { providers: await dnsManager.listProviders() };
const synthetic: interfaces.data.IDnsProviderPublic = {
id: interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID,
name: 'DcRouter',
type: 'dcrouter',
status: 'ok',
createdAt: 0,
updatedAt: 0,
createdBy: 'system',
hasCredentials: false,
builtIn: true,
};
const real = dnsManager ? await dnsManager.listProviders() : [];
return { providers: [synthetic, ...real] };
},
),
);
@@ -78,6 +91,12 @@ export class DnsProviderHandler {
'createDnsProvider',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'dns-providers:write');
if (dataArg.type === 'dcrouter') {
return {
success: false,
message: 'cannot create built-in provider',
};
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) {
return { success: false, message: 'DnsManager not initialized (DB disabled?)' };
@@ -99,6 +118,9 @@ export class DnsProviderHandler {
'updateDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:write');
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return { success: false, message: 'cannot edit built-in provider' };
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
const ok = await dnsManager.updateProvider(dataArg.id, {
@@ -116,6 +138,9 @@ export class DnsProviderHandler {
'deleteDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:write');
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return { success: false, message: 'cannot delete built-in provider' };
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false);
@@ -129,6 +154,13 @@ export class DnsProviderHandler {
'testDnsProvider',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return {
ok: false,
error: 'built-in provider has no external connection to test',
testedAt: Date.now(),
};
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) {
return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() };
@@ -144,6 +176,12 @@ export class DnsProviderHandler {
'listProviderDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'dns-providers:read');
if (dataArg.providerId === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
return {
success: false,
message: 'built-in provider has no external domain listing — use "Add DcRouter Domain" instead',
};
}
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {

View File

@@ -71,7 +71,7 @@ export class DomainHandler {
),
);
// Create manual domain
// Create dcrouter-hosted domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDomain>(
'createDomain',
@@ -80,7 +80,7 @@ export class DomainHandler {
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
try {
const id = await dnsManager.createManualDomain({
const id = await dnsManager.createDcrouterDomain({
name: dataArg.name,
description: dataArg.description,
createdBy: userId,

View File

@@ -1,9 +1,28 @@
/**
* Supported DNS provider types. Initially Cloudflare; the abstraction is
* designed so additional providers (Route53, Gandi, DigitalOcean…) can be
* added by implementing the IDnsProvider class interface in ts/dns/providers/.
* Stable ID for the built-in DcRouter pseudo-provider. The Providers list
* surfaces this as the first, non-deletable row so operators see a uniform
* "who serves this?" answer for every domain. The ID is magic — it never
* exists in the DnsProviderDoc collection; handlers inject it at read time
* and reject any mutation that targets it.
*/
export type TDnsProviderType = 'cloudflare';
export const DCROUTER_BUILTIN_PROVIDER_ID = '__dcrouter__';
/**
* Supported DNS provider types.
*
* - 'cloudflare' → Cloudflare account (API token-based). Provider stays
* authoritative; dcrouter pushes record changes via API.
* - 'dcrouter' → Built-in pseudo-provider for dcrouter-hosted zones.
* dcrouter itself is the authoritative DNS server. No
* credentials, cannot be created/edited/deleted through
* the provider CRUD — the Providers view renders it from
* a handler-level synthetic row.
*
* The abstraction is designed so additional providers (Route53, Gandi,
* DigitalOcean, foreign dcrouters…) can be added by implementing the
* IDnsProvider class interface in ts/dns/providers/.
*/
export type TDnsProviderType = 'cloudflare' | 'dcrouter';
/**
* Status of the last connection test against a provider.
@@ -58,6 +77,12 @@ export interface IDnsProviderPublic {
createdBy: string;
/** Whether credentials are configured (true after creation). Never the secret itself. */
hasCredentials: boolean;
/**
* True for the built-in DcRouter pseudo-provider — read-only, cannot be
* created / edited / deleted. Injected by the handler layer, never
* persisted in the DnsProviderDoc collection.
*/
builtIn?: boolean;
}
/**
@@ -114,11 +139,18 @@ export interface IDnsProviderTypeDescriptor {
* credentials each one needs. Used by both backend and frontend.
*/
export const dnsProviderTypeDescriptors: ReadonlyArray<IDnsProviderTypeDescriptor> = [
{
type: 'dcrouter',
displayName: 'DcRouter (built-in)',
description:
'Built-in authoritative DNS. Records are served directly by dcrouter — delegate the domain\'s NS records to make this effective.',
credentialFields: [],
},
{
type: 'cloudflare',
displayName: 'Cloudflare',
description:
'Manages records via the Cloudflare API. Provider stays authoritative; dcrouter pushes record changes.',
'External DNS provider. The provider stays authoritative; dcrouter pushes record changes via its API.',
credentialFields: [
{
key: 'apiToken',

View File

@@ -6,16 +6,18 @@ export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA
/**
* Where a DNS record came from.
*
* - 'manual' → created in the dcrouter UI / API
* - 'synced' → pulled from a provider during a sync operation
* - 'local' originated in this dcrouter (created via UI / API)
* - 'synced' → pulled from an upstream provider (Cloudflare, foreign
* dcrouter, …) during a sync operation
*/
export type TDnsRecordSource = 'manual' | 'synced';
export type TDnsRecordSource = 'local' | 'synced';
/**
* A DNS record. For manual (authoritative) domains, the record is registered
* with the embedded smartdns.DnsServer. For provider-managed domains, the
* record is mirrored from / pushed to the provider API and `providerRecordId`
* holds the provider's internal record id (for updates and deletes).
* A DNS record. For dcrouter-hosted (authoritative) domains, the record is
* registered with the embedded smartdns.DnsServer. For provider-managed
* domains, the record is mirrored from / pushed to the provider API and
* `providerRecordId` holds the provider's internal record id (for updates
* and deletes).
*/
export interface IDnsRecord {
id: string;

View File

@@ -1,14 +1,15 @@
/**
* Where a domain came from / how it is managed.
*
* - 'manual' → operator added the domain manually. dcrouter is the
* authoritative DNS server for it; records are served by
* the embedded smartdns.DnsServer.
* - 'dcrouter' → dcrouter is the authoritative DNS server for this domain;
* records are served by the embedded smartdns.DnsServer.
* Operators delegate the domain's NS records to make this
* effective.
* - 'provider' → domain was imported from an external DNS provider
* (e.g. Cloudflare). The provider stays authoritative;
* dcrouter only reads/writes records via the provider API.
* (e.g. Cloudflare). The provider stays authoritative;
* dcrouter only reads/writes records via the provider API.
*/
export type TDomainSource = 'manual' | 'provider';
export type TDomainSource = 'dcrouter' | 'provider';
/**
* A domain under management by dcrouter.
@@ -20,7 +21,7 @@ export interface IDomain {
source: TDomainSource;
/** ID of the DnsProvider that owns this domain — only set when source === 'provider'. */
providerId?: string;
/** True when dcrouter is the authoritative DNS server for this domain (source === 'manual'). */
/** True when dcrouter is the authoritative DNS server for this domain (source === 'dcrouter'). */
authoritative: boolean;
/** Authoritative nameservers (display only — populated from provider for imported domains). */
nameservers?: string[];

View File

@@ -45,7 +45,7 @@ export interface IReq_GetDnsRecord extends plugins.typedrequestInterfaces.implem
/**
* Create a new DNS record.
*
* For manual domains: registers the record with the embedded DnsServer.
* For dcrouter-hosted domains: registers the record with the embedded DnsServer.
* For provider domains: pushes the record to the provider API.
*/
export interface IReq_CreateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<

View File

@@ -42,8 +42,8 @@ export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implement
}
/**
* Create a manual (authoritative) domain. dcrouter will serve DNS
* records for this domain via the embedded smartdns.DnsServer.
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
* DNS records for this domain via the embedded smartdns.DnsServer.
*/
export interface IReq_CreateDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
@@ -130,7 +130,7 @@ export interface IReq_DeleteDomain extends plugins.typedrequestInterfaces.implem
/**
* Force-resync a provider-managed domain: re-pulls all records from the
* provider API, replacing the cached DnsRecordDocs.
* No-op for manual domains.
* No-op for dcrouter-hosted domains.
*/
export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,

View File

@@ -64,6 +64,34 @@ export async function createMigrationRunner(
migrated++;
}
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
})
.step('rename-domain-source-manual-to-dcrouter')
.from('13.1.0').to('13.8.1')
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
.up(async (ctx) => {
const collection = ctx.mongo!.collection('domaindoc');
const result = await collection.updateMany(
{ source: 'manual' },
{ $set: { source: 'dcrouter' } },
);
ctx.log.log(
'info',
`rename-domain-source-manual-to-dcrouter: migrated ${result.modifiedCount} domain(s)`,
);
})
.step('rename-record-source-manual-to-local')
.from('13.8.1').to('13.8.2')
.description('Rename DnsRecordDoc.source value from "manual" to "local"')
.up(async (ctx) => {
const collection = ctx.mongo!.collection('dnsrecorddoc');
const result = await collection.updateMany(
{ source: 'manual' },
{ $set: { source: 'local' } },
);
ctx.log.log(
'info',
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
);
});
return migration;

View File

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

View File

@@ -1793,7 +1793,7 @@ export async function fetchProviderDomains(
return await request.fire({ identity: context.identity, providerId });
}
export const createManualDomainAction = domainsStatePart.createAction<{
export const createDcrouterDomainAction = domainsStatePart.createAction<{
name: string;
description?: string;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {

View File

@@ -222,7 +222,7 @@ export class OpsViewApiTokens extends DeesElement {
.suggestions=${allScopes}
.required=${true}
></dees-input-tags>
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
</dees-form>
`,
menuOptions: [

View File

@@ -44,12 +44,15 @@ export class DnsProviderForm extends DeesElement {
accessor providerName: string = '';
/**
* Currently selected provider type. Initialized to the first descriptor;
* caller can override before mounting (e.g. for edit dialogs).
* Currently selected provider type. Initialized to the first user-creatable
* descriptor; caller can override before mounting (e.g. for edit dialogs).
* The built-in 'dcrouter' pseudo-provider is excluded from the picker —
* operators cannot create another one.
*/
@state()
accessor selectedType: interfaces.data.TDnsProviderType =
interfaces.data.dnsProviderTypeDescriptors[0]?.type ?? 'cloudflare';
interfaces.data.dnsProviderTypeDescriptors.find((d) => d.type !== 'dcrouter')?.type ??
'cloudflare';
/** When true, hide the type picker — used in edit dialogs. */
@property({ type: Boolean })
@@ -77,22 +80,6 @@ export class DnsProviderForm extends DeesElement {
margin-bottom: 12px;
}
.helpText {
font-size: 12px;
opacity: 0.7;
margin-top: -6px;
margin-bottom: 8px;
}
.typeDescription {
font-size: 12px;
opacity: 0.8;
margin: 4px 0 16px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
border-radius: 6px;
}
.credentialsHint {
font-size: 12px;
opacity: 0.7;
@@ -102,7 +89,12 @@ export class DnsProviderForm extends DeesElement {
];
public render(): TemplateResult {
const descriptors = interfaces.data.dnsProviderTypeDescriptors;
// Exclude the built-in 'dcrouter' pseudo-provider from the type picker —
// operators cannot create another one, it's surfaced at read time by the
// backend handler instead.
const descriptors = interfaces.data.dnsProviderTypeDescriptors.filter(
(d) => d.type !== 'dcrouter',
);
const descriptor = interfaces.data.getDnsProviderTypeDescriptor(this.selectedType);
return html`
@@ -122,6 +114,7 @@ export class DnsProviderForm extends DeesElement {
<dees-input-text
.key=${'__type_display'}
.label=${'Type'}
.infoText=${descriptor?.description || ''}
.value=${descriptor?.displayName ?? this.selectedType}
.disabled=${true}
></dees-input-text>
@@ -132,6 +125,7 @@ export class DnsProviderForm extends DeesElement {
<dees-input-dropdown
.key=${'__type'}
.label=${'Provider type'}
.infoText=${descriptor?.description || ''}
.options=${descriptors.map((d) => ({ option: d.displayName, key: d.type }))}
.selectedOption=${descriptor
? { option: descriptor.displayName, key: descriptor.type }
@@ -150,7 +144,6 @@ export class DnsProviderForm extends DeesElement {
`}
${descriptor
? html`
<div class="typeDescription">${descriptor.description}</div>
${this.credentialsHint
? html`<div class="credentialsHint">${this.credentialsHint}</div>`
: ''}
@@ -160,9 +153,9 @@ export class DnsProviderForm extends DeesElement {
<dees-input-text
.key=${f.key}
.label=${f.label}
.description=${f.helpText || ''}
.required=${f.required && !this.lockType}
></dees-input-text>
${f.helpText ? html`<div class="helpText">${f.helpText}</div>` : ''}
</div>
`,
)}

View File

@@ -54,62 +54,6 @@ export class OpsViewCertificates extends DeesElement {
gap: 24px;
}
.acmeCard {
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 8px;
}
.acmeCard.acmeCardEmpty {
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border-color: ${cssManager.bdTheme('#fde68a', '#78350f')};
}
.acmeCardHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.acmeCardTitle {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px 24px;
}
.acmeField {
display: flex;
flex-direction: column;
gap: 2px;
}
.acmeLabel {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.acmeValue {
font-size: 13px;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeEmptyHint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
}
.statusBadge {
display: inline-flex;
align-items: center;
@@ -226,73 +170,38 @@ export class OpsViewCertificates extends DeesElement {
<dees-heading level="3">Certificates</dees-heading>
<div class="certificatesContainer">
${this.renderAcmeSettingsCard()}
${this.renderStatsTiles(summary)}
${this.renderAcmeSettingsTile()}
${this.renderCertificateTable()}
</div>
`;
}
private renderAcmeSettingsCard(): TemplateResult {
private renderAcmeSettingsTile(): TemplateResult {
const config = this.acmeState.config;
if (!config) {
return html`
<div class="acmeCard acmeCardEmpty">
<div class="acmeCardHeader">
<span class="acmeCardTitle">ACME Settings</span>
<dees-button
eventName="edit-acme"
@click=${() => this.showEditAcmeDialog()}
.type=${'highlighted'}
>Configure</dees-button>
</div>
<p class="acmeEmptyHint">
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
under <strong>Domains &gt; Providers</strong>.
</p>
</div>
<dees-settings
.heading=${'ACME Settings'}
.description=${'No ACME configuration yet. Click Configure to set up automated TLS certificate issuance via Let\'s Encrypt. You\'ll also need at least one DNS provider under Domains > Providers.'}
.actions=${[{ name: 'Configure', action: () => this.showEditAcmeDialog() }]}
></dees-settings>
`;
}
return html`
<div class="acmeCard">
<div class="acmeCardHeader">
<span class="acmeCardTitle">ACME Settings</span>
<dees-button eventName="edit-acme" @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
</div>
<div class="acmeGrid">
<div class="acmeField">
<span class="acmeLabel">Account email</span>
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Status</span>
<span class="acmeValue">
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
${config.enabled ? 'enabled' : 'disabled'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Mode</span>
<span class="acmeValue">
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
${config.useProduction ? 'production' : 'staging'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Auto-renew</span>
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Renewal threshold</span>
<span class="acmeValue">${config.renewThresholdDays} days</span>
</div>
</div>
</div>
<dees-settings
.heading=${'ACME Settings'}
.settingsFields=${[
{ key: 'email', label: 'Account email', value: config.accountEmail || '(not set)' },
{ key: 'status', label: 'Status', value: config.enabled ? 'enabled' : 'disabled' },
{ key: 'mode', label: 'Mode', value: config.useProduction ? 'production' : 'staging' },
{ key: 'autoRenew', label: 'Auto-renew', value: config.autoRenew ? 'on' : 'off' },
{ key: 'threshold', label: 'Renewal threshold', value: `${config.renewThresholdDays} days` },
]}
.actions=${[{ name: 'Edit', action: () => this.showEditAcmeDialog() }]}
></dees-settings>
`;
}
@@ -317,7 +226,8 @@ export class OpsViewCertificates extends DeesElement {
></dees-input-checkbox>
<dees-input-checkbox
.key=${'useProduction'}
.label=${"Use Let's Encrypt production (uncheck for staging)"}
.label=${"Use Let's Encrypt production"}
.description=${'Uncheck to use the staging environment'}
.value=${current?.useProduction ?? true}
></dees-input-checkbox>
<dees-input-checkbox
@@ -327,7 +237,8 @@ export class OpsViewCertificates extends DeesElement {
></dees-input-checkbox>
<dees-input-text
.key=${'renewThresholdDays'}
.label=${'Renewal threshold (days)'}
.label=${'Renewal threshold'}
.description=${'Number of days before expiry to trigger renewal'}
.value=${String(current?.renewThresholdDays ?? 30)}
></dees-input-text>
</dees-form>
@@ -456,11 +367,12 @@ export class OpsViewCertificates extends DeesElement {
content: html`
<dees-form>
<dees-input-fileupload
key="certJsonFile"
label="Certificate JSON (.tsclass.cert.json)"
accept=".json"
.key=${'certJsonFile'}
.label=${'Certificate JSON'}
.description=${'Upload a .tsclass.cert.json file'}
.accept=${'.json'}
.multiple=${false}
required
.required=${true}
></dees-input-fileupload>
</dees-form>
`,

View File

@@ -80,7 +80,7 @@ export class OpsViewDns extends DeesElement {
font-weight: 500;
}
.sourceBadge.manual {
.sourceBadge.local {
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
}
@@ -101,8 +101,8 @@ export class OpsViewDns extends DeesElement {
<dees-heading level="3">DNS Records</dees-heading>
<div class="dnsContainer">
<div class="domainPicker">
<span>Domain:</span>
<dees-input-dropdown
.label=${'Domain'}
.options=${domains.map((d) => ({ option: d.name, key: d.id }))}
.selectedOption=${selectedId
? { option: domains.find((d) => d.id === selectedId)?.name || '', key: selectedId }
@@ -184,7 +184,7 @@ export class OpsViewDns extends DeesElement {
private domainHint(domainId: string): string {
const domain = this.domainsState.domains.find((d) => d.id === domainId);
if (!domain) return '';
if (domain.source === 'manual') {
if (domain.source === 'dcrouter') {
return 'Records are served by dcrouter (authoritative).';
}
return 'Records are stored at the provider — changes here are pushed via the provider API.';
@@ -196,7 +196,7 @@ export class OpsViewDns extends DeesElement {
heading: 'Add DNS Record',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .required=${true}></dees-input-text>
<dees-input-text .key=${'name'} .label=${'Name'} .description=${'Fully qualified domain name'} .required=${true}></dees-input-text>
<dees-input-dropdown
.key=${'type'}
.label=${'Type'}
@@ -205,10 +205,11 @@ export class OpsViewDns extends DeesElement {
></dees-input-dropdown>
<dees-input-text
.key=${'value'}
.label=${'Value (for MX use "10 mail.example.com")'}
.label=${'Value'}
.description=${'For MX records use priority format, e.g. "10 mail.example.com"'}
.required=${true}
></dees-input-text>
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${'300'}></dees-input-text>
<dees-input-text .key=${'ttl'} .label=${'TTL'} .description=${'Time to live in seconds'} .value=${'300'}></dees-input-text>
</dees-form>
`,
menuOptions: [
@@ -242,9 +243,9 @@ export class OpsViewDns extends DeesElement {
heading: `Edit ${rec.type} ${rec.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .value=${rec.name}></dees-input-text>
<dees-input-text .key=${'name'} .label=${'Name'} .description=${'Fully qualified domain name'} .value=${rec.name}></dees-input-text>
<dees-input-text .key=${'value'} .label=${'Value'} .value=${rec.value}></dees-input-text>
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${String(rec.ttl)}></dees-input-text>
<dees-input-text .key=${'ttl'} .label=${'TTL'} .description=${'Time to live in seconds'} .value=${String(rec.ttl)}></dees-input-text>
</dees-form>
`,
menuOptions: [

View File

@@ -55,7 +55,7 @@ export class OpsViewDomains extends DeesElement {
font-weight: 500;
}
.sourceBadge.manual {
.sourceBadge.dcrouter {
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
}
@@ -76,7 +76,7 @@ export class OpsViewDomains extends DeesElement {
<div class="domainsContainer">
<dees-table
.heading1=${'Domains'}
.heading2=${'Domains under management — manual (authoritative) or imported from a provider'}
.heading2=${'Domains under management — served by dcrouter (authoritative) or imported from a provider'}
.data=${domains}
.showColumnFilters=${true}
.displayFunction=${(d: interfaces.data.IDomain) => ({
@@ -90,11 +90,11 @@ export class OpsViewDomains extends DeesElement {
})}
.dataActions=${[
{
name: 'Add Manual Domain',
name: 'Add DcRouter Domain',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateManualDialog();
await this.showCreateDcrouterDialog();
},
},
{
@@ -168,21 +168,21 @@ export class OpsViewDomains extends DeesElement {
d: interfaces.data.IDomain,
providersById: Map<string, interfaces.data.IDnsProviderPublic>,
): TemplateResult {
if (d.source === 'manual') {
return html`<span class="sourceBadge manual">Manual</span>`;
if (d.source === 'dcrouter') {
return html`<span class="sourceBadge dcrouter">DcRouter</span>`;
}
const provider = d.providerId ? providersById.get(d.providerId) : undefined;
return html`<span class="sourceBadge provider">${provider?.name || 'Provider'}</span>`;
}
private async showCreateManualDialog() {
private async showCreateDcrouterDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Add Manual Domain',
heading: 'Add DcRouter Domain',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'FQDN (e.g. example.com)'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description (optional)'}></dees-input-text>
<dees-input-text .key=${'name'} .label=${'FQDN'} .description=${'e.g. example.com'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
</dees-form>
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
dcrouter will become the authoritative DNS server for this domain. You'll need to
@@ -199,7 +199,7 @@ export class OpsViewDomains extends DeesElement {
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
await appstate.domainsStatePart.dispatchAction(appstate.createManualDomainAction, {
await appstate.domainsStatePart.dispatchAction(appstate.createDcrouterDomainAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
});
@@ -235,7 +235,8 @@ export class OpsViewDomains extends DeesElement {
></dees-input-dropdown>
<dees-input-text
.key=${'domainNames'}
.label=${'Comma-separated FQDNs to import (e.g. example.com, foo.com)'}
.label=${'Domain Names'}
.description=${'Comma-separated FQDNs, e.g. example.com, foo.com'}
.required=${true}
></dees-input-text>
</dees-form>

View File

@@ -71,6 +71,11 @@ export class OpsViewProviders extends DeesElement {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
}
.statusBadge.builtin {
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
}
`,
];
@@ -82,15 +87,21 @@ export class OpsViewProviders extends DeesElement {
<div class="providersContainer">
<dees-table
.heading1=${'Providers'}
.heading2=${'External DNS provider accounts'}
.heading2=${'Built-in dcrouter + external DNS provider accounts'}
.data=${providers}
.showColumnFilters=${true}
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
Name: p.name,
Type: this.providerTypeLabel(p.type),
Status: this.renderStatusBadge(p.status),
'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never',
Error: p.lastError || '-',
Status: p.builtIn
? html`<span class="statusBadge builtin">built-in</span>`
: this.renderStatusBadge(p.status),
'Last Tested': p.builtIn
? '—'
: p.lastTestedAt
? new Date(p.lastTestedAt).toLocaleString()
: 'never',
Error: p.builtIn ? '—' : p.lastError || '-',
})}
.dataActions=${[
{
@@ -116,6 +127,7 @@ export class OpsViewProviders extends DeesElement {
name: 'Test Connection',
iconName: 'lucide:plug',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn,
actionFunc: async (actionData: any) => {
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
await this.testProvider(provider);
@@ -125,6 +137,7 @@ export class OpsViewProviders extends DeesElement {
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn,
actionFunc: async (actionData: any) => {
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
await this.showEditDialog(provider);
@@ -134,6 +147,7 @@ export class OpsViewProviders extends DeesElement {
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn,
actionFunc: async (actionData: any) => {
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
await this.deleteProvider(provider);

View File

@@ -37,25 +37,10 @@ export class OpsViewEmailSecurity extends DeesElement {
cssManager.defaultStyles,
viewHostCss,
css`
h2 {
margin: 32px 0 16px 0;
font-size: 24px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
dees-statsgrid {
margin-bottom: 32px;
}
.securityCard {
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
padding: 24px;
position: relative;
overflow: hidden;
}
.actionButton {
margin-top: 16px;
.securityContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
@@ -113,48 +98,44 @@ export class OpsViewEmailSecurity extends DeesElement {
return html`
<dees-heading level="3">Email Security</dees-heading>
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<div class="securityContainer">
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Email Security Configuration</h2>
<div class="securityCard">
<dees-form>
<dees-input-checkbox
.key=${'enableSPF'}
.label=${'Enable SPF checking'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDKIM'}
.label=${'Enable DKIM validation'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDMARC'}
.label=${'Enable DMARC policy enforcement'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableSpamFilter'}
.label=${'Enable spam filtering'}
.value=${true}
></dees-input-checkbox>
</dees-form>
<dees-button
class="actionButton"
type="highlighted"
@click=${() => this.saveEmailSecuritySettings()}
>
Save Settings
</dees-button>
<dees-settings
.heading=${'Security Configuration'}
.settingsFields=${[
{ key: 'spf', label: 'SPF checking', value: 'enabled' },
{ key: 'dkim', label: 'DKIM validation', value: 'enabled' },
{ key: 'dmarc', label: 'DMARC policy', value: 'enabled' },
{ key: 'spam', label: 'Spam filtering', value: 'enabled' },
]}
.actions=${[{ name: 'Edit', action: () => this.showEditSecurityDialog() }]}
></dees-settings>
</div>
`;
}
private async saveEmailSecuritySettings() {
// Config is read-only from the UI for now
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
private async showEditSecurityDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Edit Security Configuration',
content: html`
<dees-form>
<dees-input-checkbox .key=${'enableSPF'} .label=${'SPF checking'} .value=${true}></dees-input-checkbox>
<dees-input-checkbox .key=${'enableDKIM'} .label=${'DKIM validation'} .value=${true}></dees-input-checkbox>
<dees-input-checkbox .key=${'enableDMARC'} .label=${'DMARC policy enforcement'} .value=${true}></dees-input-checkbox>
<dees-input-checkbox .key=${'enableSpamFilter'} .label=${'Spam filtering'} .value=${true}></dees-input-checkbox>
</dees-form>
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
These settings are read-only for now. Update the dcrouter configuration to change them.
</p>
`,
menuOptions: [
{ name: 'Close', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}

View File

@@ -323,6 +323,8 @@ export class OpsViewNetworkActivity extends DeesElement {
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.displayFunction=${(req: INetworkRequest) => ({
Time: new Date(req.timestamp).toLocaleTimeString(),
Protocol: html`<span class="protocolBadge ${req.protocol}">${req.protocol.toUpperCase()}</span>`,
@@ -595,6 +597,8 @@ export class OpsViewNetworkActivity extends DeesElement {
return html`
<dees-table
.data=${this.networkState.topIPs}
.rowKey=${'ip'}
.highlightUpdates=${'flash'}
.displayFunction=${(ipData: { ip: string; count: number }) => {
const bw = bandwidthByIP.get(ipData.ip);
return {
@@ -624,6 +628,8 @@ export class OpsViewNetworkActivity extends DeesElement {
return html`
<dees-table
.data=${backends}
.rowKey=${'backend'}
.highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IBackendInfo) => {
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
const protocolClass = item.protocol.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -724,37 +730,24 @@ export class OpsViewNetworkActivity extends DeesElement {
this.requestsPerSecHistory.shift();
}
// Only update if connections changed significantly
const newConnectionCount = this.networkState.connections.length;
const oldConnectionCount = this.networkRequests.length;
// Check if we need to update the network requests array
const shouldUpdate = newConnectionCount !== oldConnectionCount ||
newConnectionCount === 0 ||
(newConnectionCount > 0 && this.networkRequests.length === 0);
if (shouldUpdate) {
// Convert connection data to network requests format
if (newConnectionCount > 0) {
this.networkRequests = this.networkState.connections.map((conn, index) => ({
id: conn.id,
timestamp: conn.startTime,
method: 'GET', // Default method for proxy connections
url: '/',
hostname: conn.remoteAddress,
port: conn.protocol === 'https' ? 443 : 80,
protocol: conn.protocol === 'https' || conn.protocol === 'http' ? conn.protocol : 'tcp',
statusCode: conn.state === 'connected' ? 200 : undefined,
duration: Date.now() - conn.startTime,
bytesIn: conn.bytesReceived,
bytesOut: conn.bytesSent,
remoteIp: conn.remoteAddress,
route: 'proxy',
}));
} else {
this.networkRequests = [];
}
}
// Reassign unconditionally so dees-table's flash diff can compare per-cell
// values against the previous snapshot. Row identity is preserved via
// rowKey='id', so DOM nodes are reused across ticks.
this.networkRequests = this.networkState.connections.map((conn) => ({
id: conn.id,
timestamp: conn.startTime,
method: 'GET', // Default method for proxy connections
url: '/',
hostname: conn.remoteAddress,
port: conn.protocol === 'https' ? 443 : 80,
protocol: conn.protocol === 'https' || conn.protocol === 'http' ? conn.protocol : 'tcp',
statusCode: conn.state === 'connected' ? 200 : undefined,
duration: Date.now() - conn.startTime,
bytesIn: conn.bytesReceived,
bytesOut: conn.bytesSent,
remoteIp: conn.remoteAddress,
route: 'proxy',
}));
// Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {

View File

@@ -220,6 +220,8 @@ export class OpsViewRemoteIngress extends DeesElement {
.heading1=${'Edge Nodes'}
.heading2=${'Manage remote ingress edge registrations'}
.data=${this.riState.edges}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.showColumnFilters=${true}
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
name: edge.name,
@@ -241,9 +243,9 @@ export class OpsViewRemoteIngress extends DeesElement {
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Additional Manual Ports (comma-separated, optional)'}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
</dees-form>
`,
menuOptions: [
@@ -318,9 +320,9 @@ export class OpsViewRemoteIngress extends DeesElement {
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports (comma-separated)'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated)'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
</dees-form>
`,
menuOptions: [

View File

@@ -473,19 +473,19 @@ export class OpsViewRoutes extends DeesElement {
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .value=${route.name || ''} .required=${true}></dees-input-text>
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
<div class="tlsCustomCertGroup" style="display: ${needsCert && isCustom ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'} .value=${currentCustomKey}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text>
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'} .value=${currentCustomKey}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'} .value=${currentCustomCert}></dees-input-text>
</div>
</div>
</dees-form>
@@ -607,19 +607,19 @@ export class OpsViewRoutes extends DeesElement {
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=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'}></dees-input-text>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
<div class="tlsCustomCertGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text>
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'}></dees-input-text>
</div>
</div>
</dees-form>

View File

@@ -176,7 +176,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</dees-form>
`,
@@ -235,7 +235,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</dees-form>
`,

View File

@@ -305,6 +305,8 @@ export class OpsViewVpn extends DeesElement {
.heading1=${'VPN Clients'}
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
.data=${clients}
.rowKey=${'clientId'}
.highlightUpdates=${'flash'}
.showColumnFilters=${true}
.displayFunction=${(client: interfaces.data.IVpnClient) => {
const conn = this.getConnectedInfo(client);
@@ -369,8 +371,8 @@ export class OpsViewVpn extends DeesElement {
</div>
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${false}></dees-input-checkbox>
<div class="aclGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'}></dees-input-text>
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'}></dees-input-text>
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
</div>
</dees-form>
`,
@@ -679,8 +681,8 @@ export class OpsViewVpn extends DeesElement {
</div>
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${currentAllowAcls}></dees-input-checkbox>
<div class="aclGroup" style="display: ${currentAllowAcls ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'} .value=${currentAllowList}></dees-input-text>
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'} .value=${currentBlockList}></dees-input-text>
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List'} .description=${'Comma-separated IPs or CIDRs'} .value=${currentAllowList}></dees-input-text>
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'} .value=${currentBlockList}></dees-input-text>
</div>
</dees-form>
`,