@serve.zone/dcrouter 13.27.1 → 13.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist_serve/bundle.js +913 -799
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/classes.dcrouter.d.ts +2 -1
  4. package/dist_ts/classes.dcrouter.js +11 -6
  5. package/dist_ts/config/classes.gateway-client-manager.d.ts +22 -0
  6. package/dist_ts/config/classes.gateway-client-manager.js +101 -0
  7. package/dist_ts/config/index.d.ts +1 -0
  8. package/dist_ts/config/index.js +2 -1
  9. package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +18 -0
  10. package/dist_ts/db/documents/classes.gateway-client.doc.js +133 -0
  11. package/dist_ts/db/documents/index.d.ts +1 -0
  12. package/dist_ts/db/documents/index.js +2 -1
  13. package/dist_ts/opsserver/handlers/certificate.handler.js +5 -1
  14. package/dist_ts/opsserver/handlers/workhoster.handler.d.ts +4 -0
  15. package/dist_ts/opsserver/handlers/workhoster.handler.js +146 -16
  16. package/dist_ts_apiclient/classes.workhoster.d.ts +1 -0
  17. package/dist_ts_apiclient/classes.workhoster.js +5 -1
  18. package/dist_ts_interfaces/data/workhoster.d.ts +28 -3
  19. package/dist_ts_interfaces/requests/workhoster.d.ts +83 -1
  20. package/dist_ts_web/00_commitinfo_data.js +1 -1
  21. package/dist_ts_web/appstate.d.ts +29 -0
  22. package/dist_ts_web/appstate.js +79 -1
  23. package/dist_ts_web/elements/access/ops-view-apitokens.js +2 -1
  24. package/dist_ts_web/elements/access/ops-view-gatewayclients.d.ts +15 -0
  25. package/dist_ts_web/elements/access/ops-view-gatewayclients.js +293 -0
  26. package/dist_ts_web/elements/domains/ops-view-certificates.d.ts +6 -0
  27. package/dist_ts_web/elements/domains/ops-view-certificates.js +155 -13
  28. package/dist_ts_web/elements/network/ops-view-routes.js +2 -1
  29. package/dist_ts_web/elements/ops-dashboard.js +3 -1
  30. package/dist_ts_web/router.js +3 -3
  31. package/package.json +1 -1
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts/classes.dcrouter.ts +10 -5
  34. package/ts/config/classes.gateway-client-manager.ts +117 -0
  35. package/ts/config/index.ts +2 -1
  36. package/ts/db/documents/classes.gateway-client.doc.ts +54 -0
  37. package/ts/db/documents/index.ts +1 -0
  38. package/ts/opsserver/handlers/certificate.handler.ts +5 -0
  39. package/ts/opsserver/handlers/workhoster.handler.ts +191 -17
  40. package/ts_apiclient/classes.workhoster.ts +8 -0
  41. package/ts_web/00_commitinfo_data.ts +1 -1
  42. package/ts_web/appstate.ts +111 -0
  43. package/ts_web/elements/access/ops-view-apitokens.ts +1 -0
  44. package/ts_web/elements/access/ops-view-gatewayclients.ts +250 -0
  45. package/ts_web/elements/domains/ops-view-certificates.ts +166 -11
  46. package/ts_web/elements/network/ops-view-routes.ts +1 -0
  47. package/ts_web/elements/ops-dashboard.ts +2 -0
  48. package/ts_web/router.ts +2 -2
@@ -285,6 +285,7 @@ export interface IRouteManagementState {
285
285
  mergedRoutes: interfaces.data.IMergedRoute[];
286
286
  warnings: interfaces.data.IRouteWarning[];
287
287
  apiTokens: interfaces.data.IApiTokenInfo[];
288
+ gatewayClients: interfaces.data.IGatewayClient[];
288
289
  isLoading: boolean;
289
290
  error: string | null;
290
291
  lastUpdated: number;
@@ -296,6 +297,7 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
296
297
  mergedRoutes: [],
297
298
  warnings: [],
298
299
  apiTokens: [],
300
+ gatewayClients: [],
299
301
  isLoading: false,
300
302
  error: null,
301
303
  lastUpdated: 0,
@@ -2477,6 +2479,115 @@ export const fetchApiTokensAction = routeManagementStatePart.createAction(async
2477
2479
  }
2478
2480
  });
2479
2481
 
2482
+ export const fetchGatewayClientsAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
2483
+ const context = getActionContext();
2484
+ const currentState = statePartArg.getState()!;
2485
+ if (!context.identity) return currentState;
2486
+
2487
+ try {
2488
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2489
+ interfaces.requests.IReq_ListGatewayClients
2490
+ >('/typedrequest', 'listGatewayClients');
2491
+ const response = await request.fire({ identity: context.identity });
2492
+ return {
2493
+ ...currentState,
2494
+ gatewayClients: response.gatewayClients,
2495
+ error: null,
2496
+ lastUpdated: Date.now(),
2497
+ };
2498
+ } catch (error) {
2499
+ return {
2500
+ ...currentState,
2501
+ error: error instanceof Error ? error.message : 'Failed to fetch gateway clients',
2502
+ };
2503
+ }
2504
+ });
2505
+
2506
+ export async function createGatewayClient(data: {
2507
+ id?: string;
2508
+ type: interfaces.data.IGatewayClient['type'];
2509
+ name: string;
2510
+ description?: string;
2511
+ hostnamePatterns?: string[];
2512
+ allowedRouteTargets?: interfaces.data.IGatewayClient['allowedRouteTargets'];
2513
+ }) {
2514
+ const context = getActionContext();
2515
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2516
+ interfaces.requests.IReq_CreateGatewayClient
2517
+ >('/typedrequest', 'createGatewayClient');
2518
+ return request.fire({
2519
+ identity: context.identity!,
2520
+ capabilities: {
2521
+ readDomains: true,
2522
+ readDnsRecords: true,
2523
+ syncRoutes: true,
2524
+ syncDnsRecords: false,
2525
+ requestCertificates: false,
2526
+ },
2527
+ ...data,
2528
+ });
2529
+ }
2530
+
2531
+ export const updateGatewayClientAction = routeManagementStatePart.createAction<{
2532
+ id: string;
2533
+ name?: string;
2534
+ description?: string;
2535
+ hostnamePatterns?: string[];
2536
+ allowedRouteTargets?: interfaces.data.IGatewayClient['allowedRouteTargets'];
2537
+ enabled?: boolean;
2538
+ }>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
2539
+ const context = getActionContext();
2540
+ const currentState = statePartArg.getState()!;
2541
+ try {
2542
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2543
+ interfaces.requests.IReq_UpdateGatewayClient
2544
+ >('/typedrequest', 'updateGatewayClient');
2545
+ await request.fire({ identity: context.identity!, ...dataArg });
2546
+ return await actionContext!.dispatch(fetchGatewayClientsAction, null);
2547
+ } catch (error) {
2548
+ return {
2549
+ ...currentState,
2550
+ error: error instanceof Error ? error.message : 'Failed to update gateway client',
2551
+ };
2552
+ }
2553
+ });
2554
+
2555
+ export const deleteGatewayClientAction = routeManagementStatePart.createAction<string>(
2556
+ async (statePartArg, gatewayClientId, actionContext): Promise<IRouteManagementState> => {
2557
+ const context = getActionContext();
2558
+ const currentState = statePartArg.getState()!;
2559
+ try {
2560
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2561
+ interfaces.requests.IReq_DeleteGatewayClient
2562
+ >('/typedrequest', 'deleteGatewayClient');
2563
+ await request.fire({ identity: context.identity!, id: gatewayClientId });
2564
+ return await actionContext!.dispatch(fetchGatewayClientsAction, null);
2565
+ } catch (error) {
2566
+ return {
2567
+ ...currentState,
2568
+ error: error instanceof Error ? error.message : 'Failed to delete gateway client',
2569
+ };
2570
+ }
2571
+ },
2572
+ );
2573
+
2574
+ export async function createGatewayClientToken(
2575
+ gatewayClientId: string,
2576
+ name?: string,
2577
+ expiresInDays?: number | null,
2578
+ ) {
2579
+ const context = getActionContext();
2580
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2581
+ interfaces.requests.IReq_CreateGatewayClientToken
2582
+ >('/typedrequest', 'createGatewayClientToken');
2583
+ return request.fire({
2584
+ identity: context.identity!,
2585
+ gatewayClientId,
2586
+ name,
2587
+ expiresInDays,
2588
+ });
2589
+ }
2590
+
2480
2591
  // Users (read-only list)
2481
2592
  export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
2482
2593
  const context = getActionContext();
@@ -20,6 +20,7 @@ export class OpsViewApiTokens extends DeesElement {
20
20
  mergedRoutes: [],
21
21
  warnings: [],
22
22
  apiTokens: [],
23
+ gatewayClients: [],
23
24
  isLoading: false,
24
25
  error: null,
25
26
  lastUpdated: 0,
@@ -0,0 +1,250 @@
1
+ import * as appstate from '../../appstate.js';
2
+ import * as interfaces from '../../../dist_ts_interfaces/index.js';
3
+ import { viewHostCss } from '../shared/css.js';
4
+
5
+ import {
6
+ DeesElement,
7
+ css,
8
+ cssManager,
9
+ customElement,
10
+ html,
11
+ state,
12
+ type TemplateResult,
13
+ } from '@design.estate/dees-element';
14
+
15
+ @customElement('ops-view-gatewayclients')
16
+ export class OpsViewGatewayClients extends DeesElement {
17
+ @state() accessor routeState: appstate.IRouteManagementState = {
18
+ mergedRoutes: [],
19
+ warnings: [],
20
+ apiTokens: [],
21
+ gatewayClients: [],
22
+ isLoading: false,
23
+ error: null,
24
+ lastUpdated: 0,
25
+ };
26
+
27
+ constructor() {
28
+ super();
29
+ const sub = appstate.routeManagementStatePart
30
+ .select((s) => s)
31
+ .subscribe((routeState) => {
32
+ this.routeState = routeState;
33
+ });
34
+ this.rxSubscriptions.push(sub);
35
+
36
+ const loginSub = appstate.loginStatePart
37
+ .select((s) => s.isLoggedIn)
38
+ .subscribe((isLoggedIn) => {
39
+ if (isLoggedIn) {
40
+ appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
41
+ }
42
+ });
43
+ this.rxSubscriptions.push(loginSub);
44
+ }
45
+
46
+ public static styles = [
47
+ cssManager.defaultStyles,
48
+ viewHostCss,
49
+ css`
50
+ .pill {
51
+ display: inline-flex;
52
+ padding: 2px 6px;
53
+ border-radius: 3px;
54
+ font-size: 11px;
55
+ background: ${cssManager.bdTheme('rgba(37, 99, 235, 0.1)', 'rgba(96, 165, 250, 0.14)')};
56
+ color: ${cssManager.bdTheme('#1d4ed8', '#93c5fd')};
57
+ margin-right: 4px;
58
+ margin-bottom: 2px;
59
+ }
60
+ `,
61
+ ];
62
+
63
+ public render(): TemplateResult {
64
+ return html`
65
+ <dees-heading level="3">Gateway Clients</dees-heading>
66
+ <dees-table
67
+ .heading1=${'Gateway Clients'}
68
+ .heading2=${'Create durable clients and token credentials for Onebox, Cloudly, or custom integrations'}
69
+ .data=${this.routeState.gatewayClients}
70
+ .dataName=${'gateway client'}
71
+ .searchable=${true}
72
+ .showColumnFilters=${true}
73
+ .displayFunction=${(client: interfaces.data.IGatewayClient) => ({
74
+ name: client.name,
75
+ id: client.id,
76
+ type: client.type,
77
+ hostnames: this.renderPills(client.hostnamePatterns),
78
+ targets: this.renderTargets(client.allowedRouteTargets),
79
+ tokens: client.tokenCount || 0,
80
+ status: client.enabled ? 'Active' : 'Disabled',
81
+ })}
82
+ .dataActions=${[
83
+ {
84
+ name: 'Create Client',
85
+ iconName: 'lucide:plus',
86
+ type: ['header'],
87
+ actionFunc: async () => await this.showCreateClientDialog(),
88
+ },
89
+ {
90
+ name: 'Create Token',
91
+ iconName: 'lucide:keyRound',
92
+ type: ['inRow', 'contextmenu'] as any,
93
+ actionFunc: async (actionData: any) => await this.showCreateTokenDialog(actionData.item),
94
+ },
95
+ {
96
+ name: 'Enable',
97
+ iconName: 'lucide:play',
98
+ type: ['inRow', 'contextmenu'] as any,
99
+ actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
100
+ actionFunc: async (actionData: any) => {
101
+ await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
102
+ id: actionData.item.id,
103
+ enabled: true,
104
+ });
105
+ },
106
+ },
107
+ {
108
+ name: 'Disable',
109
+ iconName: 'lucide:pause',
110
+ type: ['inRow', 'contextmenu'] as any,
111
+ actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
112
+ actionFunc: async (actionData: any) => {
113
+ await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
114
+ id: actionData.item.id,
115
+ enabled: false,
116
+ });
117
+ },
118
+ },
119
+ {
120
+ name: 'Delete',
121
+ iconName: 'lucide:trash2',
122
+ type: ['inRow', 'contextmenu'] as any,
123
+ actionFunc: async (actionData: any) => {
124
+ await appstate.routeManagementStatePart.dispatchAction(appstate.deleteGatewayClientAction, actionData.item.id);
125
+ },
126
+ },
127
+ ]}
128
+ ></dees-table>
129
+ `;
130
+ }
131
+
132
+ private renderPills(values: string[]): TemplateResult {
133
+ if (!values.length) return html`<span>None</span>`;
134
+ return html`${values.map((value) => html`<span class="pill">${value}</span>`)}`;
135
+ }
136
+
137
+ private renderTargets(targets: interfaces.data.IGatewayClient['allowedRouteTargets']): TemplateResult {
138
+ if (!targets.length) return html`<span>None</span>`;
139
+ return html`${targets.map((target) => html`<span class="pill">${target.host}:${target.ports.join(',')}</span>`)}`;
140
+ }
141
+
142
+ private async showCreateClientDialog(): Promise<void> {
143
+ const { DeesModal } = await import('@design.estate/dees-catalog');
144
+ await DeesModal.createAndShow({
145
+ heading: 'Create Gateway Client',
146
+ content: html`
147
+ <dees-form>
148
+ <dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
149
+ <dees-input-text .key=${'type'} .label=${'Type'} .value=${'onebox'} .description=${'onebox, cloudly, or custom'}></dees-input-text>
150
+ <dees-input-text .key=${'id'} .label=${'Client ID'} .description=${'Optional stable ID; generated when empty'}></dees-input-text>
151
+ <dees-input-text .key=${'hostnamePatterns'} .label=${'Hostname Patterns'} .description=${'Comma separated, e.g. *.apps.example.com'}></dees-input-text>
152
+ <dees-input-text .key=${'allowedRouteTarget'} .label=${'Allowed Route Target'} .description=${'Optional host:ports, e.g. onebox-smartproxy:80'}></dees-input-text>
153
+ <dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
154
+ </dees-form>
155
+ `,
156
+ menuOptions: [
157
+ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
158
+ {
159
+ name: 'Create',
160
+ iconName: 'lucide:plus',
161
+ action: async (modalArg: any) => {
162
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
163
+ if (!form) return;
164
+ const formData = await (form as any).collectFormData();
165
+ const name = String(formData.name || '').trim();
166
+ if (!name) return;
167
+ await modalArg.destroy();
168
+ await appstate.createGatewayClient({
169
+ id: String(formData.id || '').trim() || undefined,
170
+ type: this.normalizeClientType(String(formData.type || 'onebox')),
171
+ name,
172
+ description: String(formData.description || '').trim() || undefined,
173
+ hostnamePatterns: this.parseList(String(formData.hostnamePatterns || '')),
174
+ allowedRouteTargets: this.parseAllowedRouteTargets(String(formData.allowedRouteTarget || '')),
175
+ });
176
+ await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
177
+ },
178
+ },
179
+ ],
180
+ });
181
+ }
182
+
183
+ private async showCreateTokenDialog(client: interfaces.data.IGatewayClient): Promise<void> {
184
+ const { DeesModal } = await import('@design.estate/dees-catalog');
185
+ await DeesModal.createAndShow({
186
+ heading: `Create Token for ${client.name}`,
187
+ content: html`
188
+ <div style="color: #888; margin-bottom: 12px; font-size: 13px;">
189
+ The token will be shown once. Configure Onebox with the dcrouter URL and this token.
190
+ </div>
191
+ <dees-form>
192
+ <dees-input-text .key=${'name'} .label=${'Token Name'} .value=${`${client.name} Token`}></dees-input-text>
193
+ <dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
194
+ </dees-form>
195
+ `,
196
+ menuOptions: [
197
+ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
198
+ {
199
+ name: 'Create Token',
200
+ iconName: 'lucide:key',
201
+ action: async (modalArg: any) => {
202
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
203
+ if (!form) return;
204
+ const formData = await (form as any).collectFormData();
205
+ const expiresInDays = formData.expiresInDays ? parseInt(formData.expiresInDays, 10) : null;
206
+ await modalArg.destroy();
207
+ const response = await appstate.createGatewayClientToken(
208
+ client.id,
209
+ String(formData.name || '').trim() || undefined,
210
+ expiresInDays,
211
+ );
212
+ await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
213
+ if (response.success && response.tokenValue) {
214
+ await DeesModal.createAndShow({
215
+ heading: 'Gateway Client Token Created',
216
+ content: html`
217
+ <p>Copy this token now. It will not be shown again.</p>
218
+ <div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
219
+ <code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
220
+ </div>
221
+ `,
222
+ menuOptions: [
223
+ { name: 'Done', iconName: 'lucide:check', action: async (m: any) => await m.destroy() },
224
+ ],
225
+ });
226
+ }
227
+ },
228
+ },
229
+ ],
230
+ });
231
+ }
232
+
233
+ private normalizeClientType(value: string): interfaces.data.IGatewayClient['type'] {
234
+ const normalized = value.trim().toLowerCase();
235
+ if (normalized === 'cloudly' || normalized === 'custom') return normalized;
236
+ return 'onebox';
237
+ }
238
+
239
+ private parseList(value: string): string[] {
240
+ return value.split(',').map((entry) => entry.trim()).filter(Boolean);
241
+ }
242
+
243
+ private parseAllowedRouteTargets(value: string): interfaces.data.IGatewayClient['allowedRouteTargets'] {
244
+ const target = value.trim();
245
+ if (!target.includes(':')) return [];
246
+ const [host, portsValue] = target.split(':');
247
+ const ports = portsValue.split(',').map((port) => Number(port.trim())).filter((port) => Number.isInteger(port));
248
+ return host.trim() && ports.length ? [{ host: host.trim(), ports }] : [];
249
+ }
250
+ }
@@ -11,6 +11,7 @@ import * as appstate from '../../appstate.js';
11
11
  import * as interfaces from '../../../dist_ts_interfaces/index.js';
12
12
  import { viewHostCss } from '../shared/css.js';
13
13
  import { type IStatsTile } from '@design.estate/dees-catalog';
14
+ import { appRouter } from '../../router.js';
14
15
 
15
16
  declare global {
16
17
  interface HTMLElementTagNameMap {
@@ -26,6 +27,9 @@ export class OpsViewCertificates extends DeesElement {
26
27
  @state()
27
28
  accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
28
29
 
30
+ @state()
31
+ accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
32
+
29
33
  constructor() {
30
34
  super();
31
35
  const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
@@ -36,12 +40,19 @@ export class OpsViewCertificates extends DeesElement {
36
40
  this.acmeState = newState;
37
41
  });
38
42
  this.rxSubscriptions.push(acmeSub);
43
+ const domainsSub = appstate.domainsStatePart.select().subscribe((newState) => {
44
+ this.domainsState = newState;
45
+ });
46
+ this.rxSubscriptions.push(domainsSub);
39
47
  }
40
48
 
41
49
  async connectedCallback() {
42
50
  await super.connectedCallback();
43
- await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
44
- await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
51
+ await Promise.all([
52
+ appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null),
53
+ appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null),
54
+ appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null),
55
+ ]);
45
56
  }
46
57
 
47
58
  public static styles = [
@@ -127,10 +138,16 @@ export class OpsViewCertificates extends DeesElement {
127
138
  .errorText {
128
139
  font-size: 12px;
129
140
  color: ${cssManager.bdTheme('#991b1b', '#f87171')};
130
- max-width: 200px;
131
- overflow: hidden;
132
- text-overflow: ellipsis;
133
- white-space: nowrap;
141
+ max-width: 420px;
142
+ line-height: 1.35;
143
+ white-space: normal;
144
+ }
145
+
146
+ .errorStack {
147
+ display: flex;
148
+ flex-direction: column;
149
+ align-items: flex-start;
150
+ gap: 4px;
134
151
  }
135
152
 
136
153
  .backoffIndicator {
@@ -160,6 +177,39 @@ export class OpsViewCertificates extends DeesElement {
160
177
  .expiryInfo .daysLeft.danger {
161
178
  color: ${cssManager.bdTheme('#991b1b', '#f87171')};
162
179
  }
180
+
181
+ .dnsWarningPanel {
182
+ border: 1px solid ${cssManager.bdTheme('#fed7aa', '#7c2d12')};
183
+ border-radius: 12px;
184
+ padding: 16px;
185
+ background: ${cssManager.bdTheme('#fff7ed', '#1c1917')};
186
+ color: ${cssManager.bdTheme('#7c2d12', '#fdba74')};
187
+ }
188
+
189
+ .dnsWarningTitle {
190
+ font-weight: 700;
191
+ margin-bottom: 6px;
192
+ }
193
+
194
+ .dnsWarningText {
195
+ font-size: 13px;
196
+ line-height: 1.45;
197
+ color: ${cssManager.bdTheme('#9a3412', '#fed7aa')};
198
+ }
199
+
200
+ .dnsWarningList {
201
+ margin: 12px 0 0;
202
+ padding-left: 18px;
203
+ font-size: 13px;
204
+ line-height: 1.5;
205
+ }
206
+
207
+ .dnsWarningActions {
208
+ display: flex;
209
+ flex-wrap: wrap;
210
+ gap: 8px;
211
+ margin-top: 14px;
212
+ }
163
213
  `,
164
214
  ];
165
215
 
@@ -172,11 +222,102 @@ export class OpsViewCertificates extends DeesElement {
172
222
  <div class="certificatesContainer">
173
223
  ${this.renderStatsTiles(summary)}
174
224
  ${this.renderAcmeSettingsTile()}
225
+ ${this.renderManagedDomainWarnings()}
175
226
  ${this.renderCertificateTable()}
176
227
  </div>
177
228
  `;
178
229
  }
179
230
 
231
+ private renderManagedDomainWarnings(): TemplateResult {
232
+ const issues = this.getMissingManagedDomainIssues();
233
+ if (issues.length === 0) {
234
+ return html``;
235
+ }
236
+
237
+ const shownIssues = issues.slice(0, 6);
238
+ const remaining = issues.length - shownIssues.length;
239
+
240
+ return html`
241
+ <div class="dnsWarningPanel">
242
+ <div class="dnsWarningTitle">DNS-01 certificate provisioning needs managed DNS domains</div>
243
+ <div class="dnsWarningText">
244
+ DcRouter can only create ACME TXT records for domains listed under Domains > Domains.
245
+ Add the zone directly or import it from a DNS provider before reprovisioning certificates.
246
+ </div>
247
+ <ul class="dnsWarningList">
248
+ ${shownIssues.map((issue) => html`
249
+ <li>
250
+ <strong>${issue.domain}</strong>: no managed DNS domain covers
251
+ <code>${issue.challengeHost}</code>. Add/import <code>${issue.requiredDomain}</code>
252
+ or a parent zone.
253
+ </li>
254
+ `)}
255
+ ${remaining > 0 ? html`<li>${remaining} more domain${remaining === 1 ? '' : 's'} need managed DNS.</li>` : ''}
256
+ </ul>
257
+ <div class="dnsWarningActions">
258
+ <dees-button @click=${() => appRouter.navigateToView('domains', 'domains')}>Manage Domains</dees-button>
259
+ <dees-button @click=${() => appRouter.navigateToView('domains', 'providers')}>DNS Providers</dees-button>
260
+ </div>
261
+ </div>
262
+ `;
263
+ }
264
+
265
+ private getMissingManagedDomainIssues(): Array<{
266
+ domain: string;
267
+ challengeHost: string;
268
+ requiredDomain: string;
269
+ }> {
270
+ const managedDomains = this.domainsState.domains
271
+ .map((domain) => this.normalizeDomain(domain.name))
272
+ .filter(Boolean);
273
+ const issues: Array<{ domain: string; challengeHost: string; requiredDomain: string }> = [];
274
+ const seen = new Set<string>();
275
+
276
+ for (const cert of this.certState.certificates) {
277
+ if (!cert.canReprovision || (cert.source !== 'acme' && cert.source !== 'provision-function')) {
278
+ continue;
279
+ }
280
+
281
+ const requiredDomain = this.getAcmeChallengeDomain(cert.domain);
282
+ if (!requiredDomain) {
283
+ continue;
284
+ }
285
+
286
+ const covered = managedDomains.some((managedDomain) =>
287
+ requiredDomain === managedDomain || requiredDomain.endsWith(`.${managedDomain}`),
288
+ );
289
+ if (covered) {
290
+ continue;
291
+ }
292
+
293
+ const key = `${cert.domain}:${requiredDomain}`;
294
+ if (seen.has(key)) {
295
+ continue;
296
+ }
297
+ seen.add(key);
298
+ issues.push({
299
+ domain: cert.domain,
300
+ challengeHost: `_acme-challenge.${requiredDomain}`,
301
+ requiredDomain,
302
+ });
303
+ }
304
+
305
+ return issues;
306
+ }
307
+
308
+ private getAcmeChallengeDomain(domain: string): string {
309
+ const normalized = this.normalizeDomain(domain).replace(/^\*\.?/, '');
310
+ const parts = normalized.split('.').filter(Boolean);
311
+ if (parts.length >= 2 && parts.length <= 3) {
312
+ return parts.slice(-2).join('.');
313
+ }
314
+ return normalized;
315
+ }
316
+
317
+ private normalizeDomain(domain: string): string {
318
+ return domain.trim().toLowerCase().replace(/^\*\.?/, '').replace(/\.$/, '');
319
+ }
320
+
180
321
  private renderAcmeSettingsTile(): TemplateResult {
181
322
  const config = this.acmeState.config;
182
323
 
@@ -349,11 +490,7 @@ export class OpsViewCertificates extends DeesElement {
349
490
  Status: this.renderStatusBadge(cert.status),
350
491
  Source: this.renderSourceBadge(cert.source),
351
492
  Expires: this.renderExpiry(cert.expiryDate),
352
- Error: cert.backoffInfo
353
- ? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
354
- : cert.error
355
- ? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
356
- : '',
493
+ Error: this.renderError(cert),
357
494
  })}
358
495
  .dataActions=${[
359
496
  {
@@ -632,6 +769,24 @@ export class OpsViewCertificates extends DeesElement {
632
769
  `;
633
770
  }
634
771
 
772
+ private renderError(cert: interfaces.requests.ICertificateInfo): TemplateResult | string {
773
+ if (cert.backoffInfo) {
774
+ const message = cert.backoffInfo.lastError || cert.error;
775
+ return html`
776
+ <span class="errorStack">
777
+ ${message ? html`<span class="errorText" title=${message}>${message}</span>` : ''}
778
+ <span class="backoffIndicator">
779
+ ${cert.backoffInfo.failures} failure${cert.backoffInfo.failures === 1 ? '' : 's'}, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}
780
+ </span>
781
+ </span>
782
+ `;
783
+ }
784
+ if (cert.error) {
785
+ return html`<span class="errorText" title=${cert.error}>${cert.error}</span>`;
786
+ }
787
+ return '';
788
+ }
789
+
635
790
  private formatRetryTime(retryAfter?: string): string {
636
791
  if (!retryAfter) return 'soon';
637
792
  const retryDate = new Date(retryAfter);
@@ -129,6 +129,7 @@ export class OpsViewRoutes extends DeesElement {
129
129
  mergedRoutes: [],
130
130
  warnings: [],
131
131
  apiTokens: [],
132
+ gatewayClients: [],
132
133
  isLoading: false,
133
134
  error: null,
134
135
  lastUpdated: 0,
@@ -35,6 +35,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
35
35
  import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
36
36
 
37
37
  // Access group
38
+ import { OpsViewGatewayClients } from './access/ops-view-gatewayclients.js';
38
39
  import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
39
40
  import { OpsViewUsers } from './access/ops-view-users.js';
40
41
 
@@ -121,6 +122,7 @@ export class OpsDashboard extends DeesElement {
121
122
  name: 'Access',
122
123
  iconName: 'lucide:keyRound',
123
124
  subViews: [
125
+ { slug: 'gatewayclients', name: 'Gateway Clients', iconName: 'lucide:plugZap', element: OpsViewGatewayClients },
124
126
  { slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
125
127
  { slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
126
128
  ],
package/ts_web/router.ts CHANGED
@@ -11,7 +11,7 @@ const subviewMap: Record<string, readonly string[]> = {
11
11
  overview: ['stats', 'configuration'] as const,
12
12
  network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
13
13
  email: ['log', 'security', 'domains'] as const,
14
- access: ['apitokens', 'users'] as const,
14
+ access: ['gatewayclients', 'apitokens', 'users'] as const,
15
15
  security: ['overview', 'blocked', 'authentication'] as const,
16
16
  domains: ['providers', 'domains', 'dns', 'certificates'] as const,
17
17
  };
@@ -21,7 +21,7 @@ const defaultSubview: Record<string, string> = {
21
21
  overview: 'stats',
22
22
  network: 'activity',
23
23
  email: 'log',
24
- access: 'apitokens',
24
+ access: 'gatewayclients',
25
25
  security: 'overview',
26
26
  domains: 'domains',
27
27
  };