@serve.zone/dcrouter 16.2.1 → 16.4.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 (41) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +1190 -1100
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/config/helpers.http-redirects.d.ts +1 -1
  5. package/dist_ts/config/helpers.http-redirects.js +4 -3
  6. package/dist_ts_interfaces/data/route-management.d.ts +2 -0
  7. package/dist_ts_interfaces/data/route-management.js +6 -1
  8. package/dist_ts_web/00_commitinfo_data.js +1 -1
  9. package/dist_ts_web/elements/access/ops-view-apitokens.js +3 -3
  10. package/dist_ts_web/elements/access/ops-view-gatewayclients.js +3 -3
  11. package/dist_ts_web/elements/network/ops-view-network-activity.js +6 -6
  12. package/dist_ts_web/elements/network/ops-view-redirects.d.ts +3 -0
  13. package/dist_ts_web/elements/network/ops-view-redirects.js +38 -21
  14. package/dist_ts_web/elements/network/ops-view-remoteingress.js +3 -3
  15. package/dist_ts_web/elements/network/ops-view-routes.d.ts +8 -7
  16. package/dist_ts_web/elements/network/ops-view-routes.js +331 -161
  17. package/dist_ts_web/elements/network/ops-view-special-forwards.d.ts +4 -1
  18. package/dist_ts_web/elements/network/ops-view-special-forwards.js +118 -168
  19. package/dist_ts_web/elements/network/ops-view-vpn.js +3 -3
  20. package/dist_ts_web/elements/network/route-view-filters.d.ts +6 -0
  21. package/dist_ts_web/elements/network/route-view-filters.js +29 -0
  22. package/dist_ts_web/elements/network/special-forward-dialog.d.ts +11 -0
  23. package/dist_ts_web/elements/network/special-forward-dialog.js +154 -0
  24. package/dist_ts_web/elements/security/ops-view-security-blocked.js +6 -6
  25. package/package.json +2 -2
  26. package/readme.md +1 -1
  27. package/ts/00_commitinfo_data.ts +1 -1
  28. package/ts/config/helpers.http-redirects.ts +6 -3
  29. package/ts_web/00_commitinfo_data.ts +1 -1
  30. package/ts_web/elements/access/ops-view-apitokens.ts +2 -2
  31. package/ts_web/elements/access/ops-view-gatewayclients.ts +2 -2
  32. package/ts_web/elements/network/ops-view-network-activity.ts +5 -5
  33. package/ts_web/elements/network/ops-view-redirects.ts +34 -19
  34. package/ts_web/elements/network/ops-view-remoteingress.ts +2 -2
  35. package/ts_web/elements/network/ops-view-routes.ts +371 -171
  36. package/ts_web/elements/network/ops-view-special-forwards.ts +115 -178
  37. package/ts_web/elements/network/ops-view-vpn.ts +2 -2
  38. package/ts_web/elements/network/route-view-filters.ts +43 -0
  39. package/ts_web/elements/network/special-forward-dialog.ts +174 -0
  40. package/ts_web/elements/security/ops-view-security-blocked.ts +5 -5
  41. package/ts_web/readme.md +4 -4
@@ -4,42 +4,34 @@ import {
4
4
  cssManager,
5
5
  customElement,
6
6
  html,
7
+ property,
7
8
  state,
8
9
  type TemplateResult,
9
10
  } from '@design.estate/dees-element';
11
+ import { type IStatsTile } from '@design.estate/dees-catalog';
10
12
  import * as appstate from '../../appstate.js';
11
13
  import * as interfaces from '../../../dist_ts_interfaces/index.js';
12
14
  import { viewHostCss } from '../shared/css.js';
13
-
14
- const managedRouteKind = 'letsencrypt-http01-forward' as const;
15
-
16
- function getDropdownKey(value: unknown): string {
17
- return typeof value === 'string'
18
- ? value
19
- : value && typeof value === 'object' && 'key' in value
20
- ? String((value as { key: unknown }).key)
21
- : '';
22
- }
23
-
24
- function getDomains(route: interfaces.data.IMergedRoute): string[] {
25
- const domains = route.route.match.domains;
26
- if (!domains) return [];
27
- return Array.isArray(domains) ? domains : [domains];
28
- }
29
-
30
- function getTarget(route: interfaces.data.IMergedRoute): { host: string; port: number } | undefined {
31
- const target = route.route.action.targets?.[0];
32
- if (!target || typeof target.port !== 'number') return undefined;
33
- const host = typeof target.host === 'string'
34
- ? target.host
35
- : Array.isArray(target.host)
36
- ? target.host[0]
37
- : undefined;
38
- return host ? { host, port: target.port } : undefined;
39
- }
15
+ import {
16
+ getRouteOwner,
17
+ routeMatchesOwner,
18
+ type TRouteOwnerFilter,
19
+ } from './route-view-filters.js';
20
+ import {
21
+ getSpecialForwardDomains,
22
+ getSpecialForwardTarget,
23
+ letsEncryptHttp01ManagedRouteKind,
24
+ showLetsEncryptHttp01ForwardDialog,
25
+ } from './special-forward-dialog.js';
40
26
 
41
27
  @customElement('ops-view-special-forwards')
42
28
  export class OpsViewSpecialForwards extends DeesElement {
29
+ @property({ type: Boolean, reflect: true })
30
+ accessor embedded = false;
31
+
32
+ @property({ type: String })
33
+ accessor ownerFilter: TRouteOwnerFilter = 'all';
34
+
43
35
  @state()
44
36
  accessor routeState: appstate.IRouteManagementState = appstate.routeManagementStatePart.getState()!;
45
37
 
@@ -67,6 +59,12 @@ export class OpsViewSpecialForwards extends DeesElement {
67
59
  display: block;
68
60
  }
69
61
 
62
+ :host([embedded]) {
63
+ margin: 0;
64
+ max-width: none;
65
+ padding: 0;
66
+ }
67
+
70
68
  .specialForwardsContainer {
71
69
  display: flex;
72
70
  flex-direction: column;
@@ -97,12 +95,62 @@ export class OpsViewSpecialForwards extends DeesElement {
97
95
 
98
96
  public render(): TemplateResult {
99
97
  const forwards = this.routeState.mergedRoutes.filter((route) => {
100
- return route.metadata?.managedRouteKind === managedRouteKind
101
- && route.metadata.ownerType !== 'gatewayClient';
98
+ return route.metadata?.managedRouteKind === letsEncryptHttp01ManagedRouteKind
99
+ && routeMatchesOwner(route, this.ownerFilter);
102
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
+ ];
103
142
 
104
143
  return html`
144
+ ${this.embedded ? '' : html`<dees-heading level="3">Special Forwards</dees-heading>`}
105
145
  <div class="specialForwardsContainer">
146
+ ${this.embedded
147
+ ? ''
148
+ : html`
149
+ <dees-statsgrid
150
+ .tiles=${statsTiles}
151
+ .gridActions=${this.getHeaderActions()}
152
+ ></dees-statsgrid>
153
+ `}
106
154
  <div class="explanation">
107
155
  <strong>Let's Encrypt HTTP-01 Forward</strong> exposes only
108
156
  <code>/.well-known/acme-challenge/*</code> on public port 80. DCRouter manages the
@@ -119,12 +167,18 @@ export class OpsViewSpecialForwards extends DeesElement {
119
167
  .data=${forwards}
120
168
  .showColumnFilters=${true}
121
169
  .displayFunction=${(route: interfaces.data.IMergedRoute) => {
122
- const target = getTarget(route);
170
+ const target = getSpecialForwardTarget(route);
171
+ const owner = getRouteOwner(route);
123
172
  return {
124
173
  Status: route.enabled ? 'Enabled' : 'Disabled',
125
174
  Type: 'Let\'s Encrypt HTTP-01',
175
+ Owner: owner === 'operator'
176
+ ? 'User'
177
+ : owner === 'gatewayClient'
178
+ ? 'Gateway Client'
179
+ : 'System',
126
180
  Name: route.route.name || route.id,
127
- Domains: getDomains(route).join(', '),
181
+ Domains: getSpecialForwardDomains(route).join(', '),
128
182
  Path: route.route.match.path || '-',
129
183
  Target: route.metadata?.networkTargetName
130
184
  || (target ? `${target.host}:${target.port}` : '-'),
@@ -132,30 +186,27 @@ export class OpsViewSpecialForwards extends DeesElement {
132
186
  };
133
187
  }}
134
188
  .dataActions=${[
135
- {
136
- name: 'Add HTTP-01 Forward',
137
- iconName: 'lucide:plus',
138
- type: ['header' as const],
139
- actionFunc: async () => this.showForwardDialog(),
140
- },
141
- {
142
- name: 'Refresh',
143
- iconName: 'lucide:refreshCw',
144
- type: ['header' as const],
145
- actionFunc: async () => this.refreshData(),
146
- },
147
189
  {
148
190
  name: 'Edit',
149
191
  iconName: 'lucide:pencil',
150
192
  type: ['inRow', 'contextmenu'] as any,
193
+ actionRelevancyCheckFunc: (route: interfaces.data.IMergedRoute) => {
194
+ return getRouteOwner(route) === 'operator';
195
+ },
151
196
  actionFunc: async (actionData: any) => {
152
- 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
+ });
153
201
  },
154
202
  },
155
203
  {
156
204
  name: 'Enable / Disable',
157
205
  iconName: 'lucide:power',
158
206
  type: ['inRow', 'contextmenu'] as any,
207
+ actionRelevancyCheckFunc: (route: interfaces.data.IMergedRoute) => {
208
+ return getRouteOwner(route) === 'operator';
209
+ },
159
210
  actionFunc: async (actionData: any) => {
160
211
  const route = actionData.item as interfaces.data.IMergedRoute;
161
212
  await appstate.routeManagementStatePart.dispatchAction(
@@ -168,6 +219,9 @@ export class OpsViewSpecialForwards extends DeesElement {
168
219
  name: 'Delete',
169
220
  iconName: 'lucide:trash2',
170
221
  type: ['inRow', 'contextmenu'] as any,
222
+ actionRelevancyCheckFunc: (route: interfaces.data.IMergedRoute) => {
223
+ return getRouteOwner(route) === 'operator';
224
+ },
171
225
  actionFunc: async (actionData: any) => {
172
226
  await this.confirmDelete(actionData.item as interfaces.data.IMergedRoute);
173
227
  },
@@ -188,147 +242,30 @@ export class OpsViewSpecialForwards extends DeesElement {
188
242
  ]);
189
243
  }
190
244
 
191
- private async showForwardDialog(existing?: interfaces.data.IMergedRoute): Promise<void> {
192
- const { DeesModal } = await import('@design.estate/dees-catalog');
193
- const target = existing ? getTarget(existing) : undefined;
194
- const targetOptions = [
195
- { key: '', option: '(none — inline target)' },
196
- ...this.profilesTargetsState.targets.map((networkTarget) => ({
197
- key: networkTarget.id,
198
- option: `${networkTarget.name} (${
199
- Array.isArray(networkTarget.host) ? networkTarget.host.join(', ') : networkTarget.host
200
- }:${networkTarget.port})`,
201
- })),
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
+ },
202
261
  ];
203
- const selectedTarget = targetOptions.find((option) => {
204
- return option.key === (existing?.metadata?.networkTargetRef || '');
205
- }) || targetOptions[0];
206
-
207
- await DeesModal.createAndShow({
208
- heading: existing
209
- ? `Edit HTTP-01 Forward: ${existing.route.name || existing.id}`
210
- : 'Add Let\'s Encrypt HTTP-01 Forward',
211
- content: html`
212
- <dees-form>
213
- <dees-input-text
214
- .key=${'name'}
215
- .label=${'Name'}
216
- .value=${existing?.route.name || ''}
217
- .required=${true}
218
- ></dees-input-text>
219
- <dees-input-list
220
- .key=${'domains'}
221
- .label=${'Domains'}
222
- .description=${'Exact public hostnames; HTTP-01 does not support wildcards'}
223
- .placeholder=${'Add domain...'}
224
- .value=${existing ? getDomains(existing) : []}
225
- ></dees-input-list>
226
- <dees-input-dropdown
227
- .key=${'networkTargetRef'}
228
- .label=${'Network Target'}
229
- .options=${targetOptions}
230
- .selectedOption=${selectedTarget}
231
- ></dees-input-dropdown>
232
- <dees-input-text
233
- .key=${'targetHost'}
234
- .label=${'Inline Target Host'}
235
- .description=${'Used only when no Network Target is selected'}
236
- .value=${target?.host || ''}
237
- ></dees-input-text>
238
- <dees-input-text
239
- .key=${'targetPort'}
240
- .label=${'Inline Target Port'}
241
- .description=${'Usually port 80 on the ACME responder'}
242
- .value=${target ? String(target.port) : '80'}
243
- ></dees-input-text>
244
- <dees-input-checkbox
245
- .key=${'remoteIngressEnabled'}
246
- .label=${'Enable Remote Ingress'}
247
- .value=${existing?.route.remoteIngress?.enabled === true}
248
- ></dees-input-checkbox>
249
- <dees-input-list
250
- .key=${'remoteIngressEdgeFilter'}
251
- .label=${'Remote Ingress Edge Filter'}
252
- .description=${'Optional edge IDs or tags; leave empty for all edges'}
253
- .placeholder=${'Add edge ID or tag...'}
254
- .value=${existing?.route.remoteIngress?.edgeFilter || []}
255
- ></dees-input-list>
256
- </dees-form>
257
- `,
258
- menuOptions: [
259
- {
260
- name: 'Cancel',
261
- iconName: 'lucide:x',
262
- action: async (modalArg: any) => modalArg.destroy(),
263
- },
264
- {
265
- name: existing ? 'Save' : 'Create',
266
- iconName: existing ? 'lucide:check' : 'lucide:plus',
267
- action: async (modalArg: any) => {
268
- const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
269
- if (!form) return;
270
- const data = await form.collectFormData();
271
- const name = String(data.name || '').trim();
272
- const domains = Array.isArray(data.domains)
273
- ? data.domains.map((domain: unknown) => String(domain).trim()).filter(Boolean)
274
- : [];
275
- const networkTargetRef = getDropdownKey(data.networkTargetRef).trim();
276
- const targetHost = String(data.targetHost || '').trim();
277
- const targetPort = Number.parseInt(String(data.targetPort || ''), 10);
278
- if (!name || domains.length === 0) {
279
- alert('Name and at least one domain are required.');
280
- return;
281
- }
282
- if (
283
- !networkTargetRef
284
- && (!targetHost || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
285
- ) {
286
- alert('A valid inline target host and port are required.');
287
- return;
288
- }
289
-
290
- const edgeFilter = Array.isArray(data.remoteIngressEdgeFilter)
291
- ? data.remoteIngressEdgeFilter.map((entry: unknown) => String(entry).trim()).filter(Boolean)
292
- : [];
293
- const config: interfaces.data.ILetsEncryptHttp01ForwardConfig = {
294
- name,
295
- domains,
296
- ...(networkTargetRef
297
- ? { networkTargetRef }
298
- : { target: { host: targetHost, port: targetPort } }),
299
- ...(data.remoteIngressEnabled
300
- ? {
301
- remoteIngress: {
302
- enabled: true,
303
- ...(edgeFilter.length ? { edgeFilter } : {}),
304
- },
305
- }
306
- : {}),
307
- };
308
-
309
- if (existing) {
310
- await appstate.routeManagementStatePart.dispatchAction(
311
- appstate.updateLetsEncryptHttp01ForwardAction,
312
- { id: existing.id, config, enabled: existing.enabled },
313
- );
314
- } else {
315
- await appstate.routeManagementStatePart.dispatchAction(
316
- appstate.createLetsEncryptHttp01ForwardAction,
317
- { config },
318
- );
319
- }
320
- await modalArg.destroy();
321
- },
322
- },
323
- ],
324
- });
325
262
  }
326
263
 
327
264
  private async confirmDelete(route: interfaces.data.IMergedRoute): Promise<void> {
328
265
  const { DeesModal } = await import('@design.estate/dees-catalog');
329
266
  await DeesModal.createAndShow({
330
267
  heading: `Delete Special Forward: ${route.route.name || route.id}`,
331
- 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>`,
332
269
  menuOptions: [
333
270
  {
334
271
  name: 'Cancel',
@@ -515,7 +515,7 @@ export class OpsViewVpn extends DeesElement {
515
515
  name: 'Enable',
516
516
  iconName: 'lucide:power',
517
517
  type: ['contextmenu', 'inRow'],
518
- actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
518
+ actionRelevancyCheckFunc: (client: any) => !client.enabled,
519
519
  actionFunc: async (actionData: any) => {
520
520
  const client = actionData.item as interfaces.data.IVpnClient;
521
521
  await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
@@ -528,7 +528,7 @@ export class OpsViewVpn extends DeesElement {
528
528
  name: 'Disable',
529
529
  iconName: 'lucide:power',
530
530
  type: ['contextmenu', 'inRow'],
531
- actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
531
+ actionRelevancyCheckFunc: (client: any) => client.enabled,
532
532
  actionFunc: async (actionData: any) => {
533
533
  const client = actionData.item as interfaces.data.IVpnClient;
534
534
  await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
@@ -0,0 +1,43 @@
1
+ import {
2
+ canonicalizeSourceRouteId,
3
+ type IHttpRedirectInfo,
4
+ type IMergedRoute,
5
+ } from '../../../dist_ts_interfaces/data/route-management.js';
6
+
7
+ export type TRouteOwnerFilter = 'all' | 'operator' | 'gatewayClient' | 'system';
8
+ export type TResolvedRouteOwner = Exclude<TRouteOwnerFilter, 'all'>;
9
+
10
+ export function getRouteOwner(route: IMergedRoute): TResolvedRouteOwner {
11
+ if (route.metadata?.ownerType === 'gatewayClient') return 'gatewayClient';
12
+ if (route.metadata?.ownerType === 'system') return 'system';
13
+ if (route.metadata?.ownerType === 'operator') return 'operator';
14
+ return route.origin === 'api' ? 'operator' : 'system';
15
+ }
16
+
17
+ export function routeMatchesOwner(
18
+ route: IMergedRoute,
19
+ ownerFilter: TRouteOwnerFilter,
20
+ ): boolean {
21
+ return ownerFilter === 'all' || getRouteOwner(route) === ownerFilter;
22
+ }
23
+
24
+ function getCanonicalRouteIds(route: IMergedRoute): string[] {
25
+ const configuredRouteId = (route.route as { id?: string }).id;
26
+ return [route.id, configuredRouteId]
27
+ .filter((routeId): routeId is string => Boolean(routeId))
28
+ .map(canonicalizeSourceRouteId);
29
+ }
30
+
31
+ export function redirectMatchesOwner(
32
+ redirect: IHttpRedirectInfo,
33
+ mergedRoutes: IMergedRoute[],
34
+ ownerFilter: TRouteOwnerFilter,
35
+ ): boolean {
36
+ if (ownerFilter === 'all') return true;
37
+
38
+ const sourceRouteIds = new Set(redirect.sourceRouteIds.map(canonicalizeSourceRouteId));
39
+ return mergedRoutes.some((route) => {
40
+ return getCanonicalRouteIds(route).some((routeId) => sourceRouteIds.has(routeId))
41
+ && getRouteOwner(route) === ownerFilter;
42
+ });
43
+ }
@@ -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
+ }
@@ -268,7 +268,7 @@ export class OpsViewSecurityBlocked extends DeesElement {
268
268
  name: 'Enable',
269
269
  iconName: 'lucide:play',
270
270
  type: ['contextmenu'] as any,
271
- actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
271
+ actionRelevancyCheckFunc: (entry: any) => !entry.enabled,
272
272
  actionFunc: async (actionData: any) => {
273
273
  const rule = actionData.item as interfaces.data.ISecurityBlockRule;
274
274
  await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
@@ -281,7 +281,7 @@ export class OpsViewSecurityBlocked extends DeesElement {
281
281
  name: 'Disable',
282
282
  iconName: 'lucide:pause',
283
283
  type: ['contextmenu'] as any,
284
- actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
284
+ actionRelevancyCheckFunc: (entry: any) => entry.enabled,
285
285
  actionFunc: async (actionData: any) => {
286
286
  const rule = actionData.item as interfaces.data.ISecurityBlockRule;
287
287
  await appstate.securityPolicyStatePart.dispatchAction(appstate.updateSecurityBlockRuleAction, {
@@ -331,7 +331,7 @@ export class OpsViewSecurityBlocked extends DeesElement {
331
331
  name: 'Block Network Range',
332
332
  iconName: 'lucide:network',
333
333
  type: ['contextmenu'] as any,
334
- actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.networkRange),
334
+ actionRelevancyCheckFunc: (entry: any) => Boolean(entry.networkRange),
335
335
  actionFunc: async (actionData: any) => {
336
336
  const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
337
337
  await this.showRuleDialog(undefined, {
@@ -345,7 +345,7 @@ export class OpsViewSecurityBlocked extends DeesElement {
345
345
  name: 'Block ASN',
346
346
  iconName: 'lucide:radio-tower',
347
347
  type: ['contextmenu'] as any,
348
- actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asn),
348
+ actionRelevancyCheckFunc: (entry: any) => Boolean(entry.asn),
349
349
  actionFunc: async (actionData: any) => {
350
350
  const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
351
351
  await this.showRuleDialog(undefined, {
@@ -359,7 +359,7 @@ export class OpsViewSecurityBlocked extends DeesElement {
359
359
  name: 'Block Organization',
360
360
  iconName: 'lucide:building-2',
361
361
  type: ['contextmenu'] as any,
362
- actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.asnOrg || actionData.item.registrantOrg),
362
+ actionRelevancyCheckFunc: (entry: any) => Boolean(entry.asnOrg || entry.registrantOrg),
363
363
  actionFunc: async (actionData: any) => {
364
364
  const record = actionData.item as interfaces.data.IIpIntelligenceRecord;
365
365
  await this.showRuleDialog(undefined, {
package/ts_web/readme.md CHANGED
@@ -29,12 +29,12 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
29
29
 
30
30
  ## Routes Navigation
31
31
 
32
- The `/network/routes` view uses two levels of tabs:
32
+ The `/network/routes` view uses two adjacent, independent multitoggles:
33
33
 
34
- - primary sections: **Standard Routes**, **Special Forwards**, and **Redirects**
35
- - ownership filters within Standard Routes: **All**, **Operator**, **Gateway Client**, and **System**
34
+ - type: **All**, **Standard**, **Redirect**, and **Special**
35
+ - owner: **All**, **User**, **Gateway Client**, and **System**
36
36
 
37
- Special Forwards create server-managed Let's Encrypt HTTP-01 routes for exact domains and a selected inline or Network Target backend. Redirects remain a derived runtime view. Tab state is stored in the `tab` and `owner` query parameters so deep links and browser history remain stable; the legacy `/network/redirects` URL is normalized to `/network/routes?tab=redirects`.
37
+ The owner selection filters every displayed route projection. **All** keeps Standard, Special, and Redirect routes in separate sections rather than mixing unlike rows. Special routes include managed Let's Encrypt HTTP-01 forwards for exact domains and a selected inline or Network Target backend; non-user routes are read-only. Redirects remain a derived runtime view and inherit ownership from their canonical source routes. Filter state is stored in the `tab` and `owner` query parameters so deep links and browser history remain stable; the legacy `/network/redirects` URL is normalized to `/network/routes?tab=redirects`.
38
38
 
39
39
  ## Runtime Communication
40
40