@serve.zone/dcrouter 16.3.0 → 16.4.1

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.
@@ -8,6 +8,7 @@ import {
8
8
  state,
9
9
  type TemplateResult,
10
10
  } from '@design.estate/dees-element';
11
+ import { type IStatsTile } from '@design.estate/dees-catalog';
11
12
  import * as appstate from '../../appstate.js';
12
13
  import * as interfaces from '../../../dist_ts_interfaces/index.js';
13
14
  import { viewHostCss } from '../shared/css.js';
@@ -16,36 +17,18 @@ import {
16
17
  routeMatchesOwner,
17
18
  type TRouteOwnerFilter,
18
19
  } from './route-view-filters.js';
19
-
20
- const managedRouteKind = 'letsencrypt-http01-forward' as const;
21
-
22
- function getDropdownKey(value: unknown): string {
23
- return typeof value === 'string'
24
- ? value
25
- : value && typeof value === 'object' && 'key' in value
26
- ? String((value as { key: unknown }).key)
27
- : '';
28
- }
29
-
30
- function getDomains(route: interfaces.data.IMergedRoute): string[] {
31
- const domains = route.route.match.domains;
32
- if (!domains) return [];
33
- return Array.isArray(domains) ? domains : [domains];
34
- }
35
-
36
- function getTarget(route: interfaces.data.IMergedRoute): { host: string; port: number } | undefined {
37
- const target = route.route.action.targets?.[0];
38
- if (!target || typeof target.port !== 'number') return undefined;
39
- const host = typeof target.host === 'string'
40
- ? target.host
41
- : Array.isArray(target.host)
42
- ? target.host[0]
43
- : undefined;
44
- return host ? { host, port: target.port } : undefined;
45
- }
20
+ import {
21
+ getSpecialForwardDomains,
22
+ getSpecialForwardTarget,
23
+ letsEncryptHttp01ManagedRouteKind,
24
+ showLetsEncryptHttp01ForwardDialog,
25
+ } from './special-forward-dialog.js';
46
26
 
47
27
  @customElement('ops-view-special-forwards')
48
28
  export class OpsViewSpecialForwards extends DeesElement {
29
+ @property({ type: Boolean, reflect: true })
30
+ accessor embedded = false;
31
+
49
32
  @property({ type: String })
50
33
  accessor ownerFilter: TRouteOwnerFilter = 'all';
51
34
 
@@ -76,6 +59,12 @@ export class OpsViewSpecialForwards extends DeesElement {
76
59
  display: block;
77
60
  }
78
61
 
62
+ :host([embedded]) {
63
+ margin: 0;
64
+ max-width: none;
65
+ padding: 0;
66
+ }
67
+
79
68
  .specialForwardsContainer {
80
69
  display: flex;
81
70
  flex-direction: column;
@@ -106,12 +95,62 @@ export class OpsViewSpecialForwards extends DeesElement {
106
95
 
107
96
  public render(): TemplateResult {
108
97
  const forwards = this.routeState.mergedRoutes.filter((route) => {
109
- return route.metadata?.managedRouteKind === managedRouteKind
98
+ return route.metadata?.managedRouteKind === letsEncryptHttp01ManagedRouteKind
110
99
  && routeMatchesOwner(route, this.ownerFilter);
111
100
  });
101
+ const enabledCount = forwards.filter((route) => route.enabled).length;
102
+ const disabledCount = forwards.length - enabledCount;
103
+ const readOnlyCount = forwards.filter((route) => getRouteOwner(route) !== 'operator').length;
104
+ const statsTiles: IStatsTile[] = [
105
+ {
106
+ id: 'matchingSpecialForwards',
107
+ title: 'Matching',
108
+ type: 'number',
109
+ value: forwards.length,
110
+ icon: 'lucide:route',
111
+ description: 'HTTP-01 forwards in this owner scope',
112
+ color: '#3b82f6',
113
+ },
114
+ {
115
+ id: 'enabledSpecialForwards',
116
+ title: 'Enabled',
117
+ type: 'number',
118
+ value: enabledCount,
119
+ icon: 'lucide:circleCheck',
120
+ description: 'Currently forwarding challenges',
121
+ color: '#22c55e',
122
+ },
123
+ {
124
+ id: 'disabledSpecialForwards',
125
+ title: 'Disabled',
126
+ type: 'number',
127
+ value: disabledCount,
128
+ icon: 'lucide:circlePause',
129
+ description: 'Configured but inactive',
130
+ color: disabledCount > 0 ? '#f59e0b' : '#6b7280',
131
+ },
132
+ {
133
+ id: 'readOnlySpecialForwards',
134
+ title: 'Read-only',
135
+ type: 'number',
136
+ value: readOnlyCount,
137
+ icon: 'lucide:lockKeyhole',
138
+ description: 'Managed by gateway clients or system',
139
+ color: '#8b5cf6',
140
+ },
141
+ ];
112
142
 
113
143
  return html`
144
+ ${this.embedded ? '' : html`<dees-heading level="3">Special Forwards</dees-heading>`}
114
145
  <div class="specialForwardsContainer">
146
+ ${this.embedded
147
+ ? ''
148
+ : html`
149
+ <dees-statsgrid
150
+ .tiles=${statsTiles}
151
+ .gridActions=${this.getHeaderActions()}
152
+ ></dees-statsgrid>
153
+ `}
115
154
  <div class="explanation">
116
155
  <strong>Let's Encrypt HTTP-01 Forward</strong> exposes only
117
156
  <code>/.well-known/acme-challenge/*</code> on public port 80. DCRouter manages the
@@ -128,7 +167,7 @@ export class OpsViewSpecialForwards extends DeesElement {
128
167
  .data=${forwards}
129
168
  .showColumnFilters=${true}
130
169
  .displayFunction=${(route: interfaces.data.IMergedRoute) => {
131
- const target = getTarget(route);
170
+ const target = getSpecialForwardTarget(route);
132
171
  const owner = getRouteOwner(route);
133
172
  return {
134
173
  Status: route.enabled ? 'Enabled' : 'Disabled',
@@ -139,7 +178,7 @@ export class OpsViewSpecialForwards extends DeesElement {
139
178
  ? 'Gateway Client'
140
179
  : 'System',
141
180
  Name: route.route.name || route.id,
142
- Domains: getDomains(route).join(', '),
181
+ Domains: getSpecialForwardDomains(route).join(', '),
143
182
  Path: route.route.match.path || '-',
144
183
  Target: route.metadata?.networkTargetName
145
184
  || (target ? `${target.host}:${target.port}` : '-'),
@@ -147,22 +186,6 @@ export class OpsViewSpecialForwards extends DeesElement {
147
186
  };
148
187
  }}
149
188
  .dataActions=${[
150
- ...(this.ownerFilter === 'all' || this.ownerFilter === 'operator'
151
- ? [
152
- {
153
- name: 'Add HTTP-01 Forward',
154
- iconName: 'lucide:plus',
155
- type: ['header' as const],
156
- actionFunc: async () => this.showForwardDialog(),
157
- },
158
- ]
159
- : []),
160
- {
161
- name: 'Refresh',
162
- iconName: 'lucide:refreshCw',
163
- type: ['header' as const],
164
- actionFunc: async () => this.refreshData(),
165
- },
166
189
  {
167
190
  name: 'Edit',
168
191
  iconName: 'lucide:pencil',
@@ -171,7 +194,10 @@ export class OpsViewSpecialForwards extends DeesElement {
171
194
  return getRouteOwner(route) === 'operator';
172
195
  },
173
196
  actionFunc: async (actionData: any) => {
174
- await this.showForwardDialog(actionData.item as interfaces.data.IMergedRoute);
197
+ await showLetsEncryptHttp01ForwardDialog({
198
+ existing: actionData.item as interfaces.data.IMergedRoute,
199
+ targets: this.profilesTargetsState.targets,
200
+ });
175
201
  },
176
202
  },
177
203
  {
@@ -216,147 +242,30 @@ export class OpsViewSpecialForwards extends DeesElement {
216
242
  ]);
217
243
  }
218
244
 
219
- private async showForwardDialog(existing?: interfaces.data.IMergedRoute): Promise<void> {
220
- const { DeesModal } = await import('@design.estate/dees-catalog');
221
- const target = existing ? getTarget(existing) : undefined;
222
- const targetOptions = [
223
- { key: '', option: '(none — inline target)' },
224
- ...this.profilesTargetsState.targets.map((networkTarget) => ({
225
- key: networkTarget.id,
226
- option: `${networkTarget.name} (${
227
- Array.isArray(networkTarget.host) ? networkTarget.host.join(', ') : networkTarget.host
228
- }:${networkTarget.port})`,
229
- })),
245
+ private getHeaderActions() {
246
+ return [
247
+ ...(this.ownerFilter === 'all' || this.ownerFilter === 'operator'
248
+ ? [{
249
+ name: 'Add User Special Forward',
250
+ iconName: 'lucide:plus',
251
+ action: async () => showLetsEncryptHttp01ForwardDialog({
252
+ targets: this.profilesTargetsState.targets,
253
+ }),
254
+ }]
255
+ : []),
256
+ {
257
+ name: 'Refresh',
258
+ iconName: 'lucide:refreshCw',
259
+ action: async () => this.refreshData(),
260
+ },
230
261
  ];
231
- const selectedTarget = targetOptions.find((option) => {
232
- return option.key === (existing?.metadata?.networkTargetRef || '');
233
- }) || targetOptions[0];
234
-
235
- await DeesModal.createAndShow({
236
- heading: existing
237
- ? `Edit HTTP-01 Forward: ${existing.route.name || existing.id}`
238
- : 'Add Let\'s Encrypt HTTP-01 Forward',
239
- content: html`
240
- <dees-form>
241
- <dees-input-text
242
- .key=${'name'}
243
- .label=${'Name'}
244
- .value=${existing?.route.name || ''}
245
- .required=${true}
246
- ></dees-input-text>
247
- <dees-input-list
248
- .key=${'domains'}
249
- .label=${'Domains'}
250
- .description=${'Exact public hostnames; HTTP-01 does not support wildcards'}
251
- .placeholder=${'Add domain...'}
252
- .value=${existing ? getDomains(existing) : []}
253
- ></dees-input-list>
254
- <dees-input-dropdown
255
- .key=${'networkTargetRef'}
256
- .label=${'Network Target'}
257
- .options=${targetOptions}
258
- .selectedOption=${selectedTarget}
259
- ></dees-input-dropdown>
260
- <dees-input-text
261
- .key=${'targetHost'}
262
- .label=${'Inline Target Host'}
263
- .description=${'Used only when no Network Target is selected'}
264
- .value=${target?.host || ''}
265
- ></dees-input-text>
266
- <dees-input-text
267
- .key=${'targetPort'}
268
- .label=${'Inline Target Port'}
269
- .description=${'Usually port 80 on the ACME responder'}
270
- .value=${target ? String(target.port) : '80'}
271
- ></dees-input-text>
272
- <dees-input-checkbox
273
- .key=${'remoteIngressEnabled'}
274
- .label=${'Enable Remote Ingress'}
275
- .value=${existing?.route.remoteIngress?.enabled === true}
276
- ></dees-input-checkbox>
277
- <dees-input-list
278
- .key=${'remoteIngressEdgeFilter'}
279
- .label=${'Remote Ingress Edge Filter'}
280
- .description=${'Optional edge IDs or tags; leave empty for all edges'}
281
- .placeholder=${'Add edge ID or tag...'}
282
- .value=${existing?.route.remoteIngress?.edgeFilter || []}
283
- ></dees-input-list>
284
- </dees-form>
285
- `,
286
- menuOptions: [
287
- {
288
- name: 'Cancel',
289
- iconName: 'lucide:x',
290
- action: async (modalArg: any) => modalArg.destroy(),
291
- },
292
- {
293
- name: existing ? 'Save' : 'Create',
294
- iconName: existing ? 'lucide:check' : 'lucide:plus',
295
- action: async (modalArg: any) => {
296
- const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
297
- if (!form) return;
298
- const data = await form.collectFormData();
299
- const name = String(data.name || '').trim();
300
- const domains = Array.isArray(data.domains)
301
- ? data.domains.map((domain: unknown) => String(domain).trim()).filter(Boolean)
302
- : [];
303
- const networkTargetRef = getDropdownKey(data.networkTargetRef).trim();
304
- const targetHost = String(data.targetHost || '').trim();
305
- const targetPort = Number.parseInt(String(data.targetPort || ''), 10);
306
- if (!name || domains.length === 0) {
307
- alert('Name and at least one domain are required.');
308
- return;
309
- }
310
- if (
311
- !networkTargetRef
312
- && (!targetHost || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
313
- ) {
314
- alert('A valid inline target host and port are required.');
315
- return;
316
- }
317
-
318
- const edgeFilter = Array.isArray(data.remoteIngressEdgeFilter)
319
- ? data.remoteIngressEdgeFilter.map((entry: unknown) => String(entry).trim()).filter(Boolean)
320
- : [];
321
- const config: interfaces.data.ILetsEncryptHttp01ForwardConfig = {
322
- name,
323
- domains,
324
- ...(networkTargetRef
325
- ? { networkTargetRef }
326
- : { target: { host: targetHost, port: targetPort } }),
327
- ...(data.remoteIngressEnabled
328
- ? {
329
- remoteIngress: {
330
- enabled: true,
331
- ...(edgeFilter.length ? { edgeFilter } : {}),
332
- },
333
- }
334
- : {}),
335
- };
336
-
337
- if (existing) {
338
- await appstate.routeManagementStatePart.dispatchAction(
339
- appstate.updateLetsEncryptHttp01ForwardAction,
340
- { id: existing.id, config, enabled: existing.enabled },
341
- );
342
- } else {
343
- await appstate.routeManagementStatePart.dispatchAction(
344
- appstate.createLetsEncryptHttp01ForwardAction,
345
- { config },
346
- );
347
- }
348
- await modalArg.destroy();
349
- },
350
- },
351
- ],
352
- });
353
262
  }
354
263
 
355
264
  private async confirmDelete(route: interfaces.data.IMergedRoute): Promise<void> {
356
265
  const { DeesModal } = await import('@design.estate/dees-catalog');
357
266
  await DeesModal.createAndShow({
358
267
  heading: `Delete Special Forward: ${route.route.name || route.id}`,
359
- content: html`<p>This removes the managed HTTP-01 route for ${getDomains(route).join(', ')}.</p>`,
268
+ content: html`<p>This removes the managed HTTP-01 route for ${getSpecialForwardDomains(route).join(', ')}.</p>`,
360
269
  menuOptions: [
361
270
  {
362
271
  name: 'Cancel',
@@ -0,0 +1,174 @@
1
+ import { html } from '@design.estate/dees-element';
2
+ import * as appstate from '../../appstate.js';
3
+ import * as interfaces from '../../../dist_ts_interfaces/index.js';
4
+
5
+ export const letsEncryptHttp01ManagedRouteKind = 'letsencrypt-http01-forward' as const;
6
+
7
+ export function getSpecialForwardDomains(
8
+ route: interfaces.data.IMergedRoute,
9
+ ): string[] {
10
+ const domains = route.route.match.domains;
11
+ if (!domains) return [];
12
+ return Array.isArray(domains) ? domains : [domains];
13
+ }
14
+
15
+ export function getSpecialForwardTarget(
16
+ route: interfaces.data.IMergedRoute,
17
+ ): { host: string; port: number } | undefined {
18
+ const target = route.route.action.targets?.[0];
19
+ if (!target || typeof target.port !== 'number') return undefined;
20
+ const host = typeof target.host === 'string'
21
+ ? target.host
22
+ : Array.isArray(target.host)
23
+ ? target.host[0]
24
+ : undefined;
25
+ return host ? { host, port: target.port } : undefined;
26
+ }
27
+
28
+ function getDropdownKey(value: unknown): string {
29
+ return typeof value === 'string'
30
+ ? value
31
+ : value && typeof value === 'object' && 'key' in value
32
+ ? String((value as { key: unknown }).key)
33
+ : '';
34
+ }
35
+
36
+ export async function showLetsEncryptHttp01ForwardDialog(options: {
37
+ existing?: interfaces.data.IMergedRoute;
38
+ targets: interfaces.data.INetworkTarget[];
39
+ }): Promise<void> {
40
+ const { DeesModal } = await import('@design.estate/dees-catalog');
41
+ const { existing, targets } = options;
42
+ const target = existing ? getSpecialForwardTarget(existing) : undefined;
43
+ const targetOptions = [
44
+ { key: '', option: '(none — inline target)' },
45
+ ...targets.map((networkTarget) => ({
46
+ key: networkTarget.id,
47
+ option: `${networkTarget.name} (${
48
+ Array.isArray(networkTarget.host) ? networkTarget.host.join(', ') : networkTarget.host
49
+ }:${networkTarget.port})`,
50
+ })),
51
+ ];
52
+ const selectedTarget = targetOptions.find((option) => {
53
+ return option.key === (existing?.metadata?.networkTargetRef || '');
54
+ }) || targetOptions[0];
55
+
56
+ await DeesModal.createAndShow({
57
+ heading: existing
58
+ ? `Edit User HTTP-01 Forward: ${existing.route.name || existing.id}`
59
+ : 'Add User Let\'s Encrypt HTTP-01 Forward',
60
+ content: html`
61
+ <dees-form>
62
+ <dees-input-text
63
+ .key=${'name'}
64
+ .label=${'Name'}
65
+ .value=${existing?.route.name || ''}
66
+ .required=${true}
67
+ ></dees-input-text>
68
+ <dees-input-list
69
+ .key=${'domains'}
70
+ .label=${'Domains'}
71
+ .description=${'Exact public hostnames; HTTP-01 does not support wildcards'}
72
+ .placeholder=${'Add domain...'}
73
+ .value=${existing ? getSpecialForwardDomains(existing) : []}
74
+ ></dees-input-list>
75
+ <dees-input-dropdown
76
+ .key=${'networkTargetRef'}
77
+ .label=${'Network Target'}
78
+ .options=${targetOptions}
79
+ .selectedOption=${selectedTarget}
80
+ ></dees-input-dropdown>
81
+ <dees-input-text
82
+ .key=${'targetHost'}
83
+ .label=${'Inline Target Host'}
84
+ .description=${'Used only when no Network Target is selected'}
85
+ .value=${target?.host || ''}
86
+ ></dees-input-text>
87
+ <dees-input-text
88
+ .key=${'targetPort'}
89
+ .label=${'Inline Target Port'}
90
+ .description=${'Usually port 80 on the ACME responder'}
91
+ .value=${target ? String(target.port) : '80'}
92
+ ></dees-input-text>
93
+ <dees-input-checkbox
94
+ .key=${'remoteIngressEnabled'}
95
+ .label=${'Enable Remote Ingress'}
96
+ .value=${existing?.route.remoteIngress?.enabled === true}
97
+ ></dees-input-checkbox>
98
+ <dees-input-list
99
+ .key=${'remoteIngressEdgeFilter'}
100
+ .label=${'Remote Ingress Edge Filter'}
101
+ .description=${'Optional edge IDs or tags; leave empty for all edges'}
102
+ .placeholder=${'Add edge ID or tag...'}
103
+ .value=${existing?.route.remoteIngress?.edgeFilter || []}
104
+ ></dees-input-list>
105
+ </dees-form>
106
+ `,
107
+ menuOptions: [
108
+ {
109
+ name: 'Cancel',
110
+ iconName: 'lucide:x',
111
+ action: async (modalArg: any) => modalArg.destroy(),
112
+ },
113
+ {
114
+ name: existing ? 'Save' : 'Create',
115
+ iconName: existing ? 'lucide:check' : 'lucide:plus',
116
+ action: async (modalArg: any) => {
117
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
118
+ if (!form) return;
119
+ const data = await form.collectFormData();
120
+ const name = String(data.name || '').trim();
121
+ const domains = Array.isArray(data.domains)
122
+ ? data.domains.map((domain: unknown) => String(domain).trim()).filter(Boolean)
123
+ : [];
124
+ const networkTargetRef = getDropdownKey(data.networkTargetRef).trim();
125
+ const targetHost = String(data.targetHost || '').trim();
126
+ const targetPort = Number.parseInt(String(data.targetPort || ''), 10);
127
+ if (!name || domains.length === 0) {
128
+ alert('Name and at least one domain are required.');
129
+ return;
130
+ }
131
+ if (
132
+ !networkTargetRef
133
+ && (!targetHost || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
134
+ ) {
135
+ alert('A valid inline target host and port are required.');
136
+ return;
137
+ }
138
+
139
+ const edgeFilter = Array.isArray(data.remoteIngressEdgeFilter)
140
+ ? data.remoteIngressEdgeFilter.map((entry: unknown) => String(entry).trim()).filter(Boolean)
141
+ : [];
142
+ const config: interfaces.data.ILetsEncryptHttp01ForwardConfig = {
143
+ name,
144
+ domains,
145
+ ...(networkTargetRef
146
+ ? { networkTargetRef }
147
+ : { target: { host: targetHost, port: targetPort } }),
148
+ ...(data.remoteIngressEnabled
149
+ ? {
150
+ remoteIngress: {
151
+ enabled: true,
152
+ ...(edgeFilter.length ? { edgeFilter } : {}),
153
+ },
154
+ }
155
+ : {}),
156
+ };
157
+
158
+ if (existing) {
159
+ await appstate.routeManagementStatePart.dispatchAction(
160
+ appstate.updateLetsEncryptHttp01ForwardAction,
161
+ { id: existing.id, config, enabled: existing.enabled },
162
+ );
163
+ } else {
164
+ await appstate.routeManagementStatePart.dispatchAction(
165
+ appstate.createLetsEncryptHttp01ForwardAction,
166
+ { config },
167
+ );
168
+ }
169
+ await modalArg.destroy();
170
+ },
171
+ },
172
+ ],
173
+ });
174
+ }