@serve.zone/dcrouter 16.1.1 → 16.2.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 (42) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +11786 -7664
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/config/classes.route-config-manager.d.ts +24 -4
  5. package/dist_ts/config/classes.route-config-manager.js +195 -34
  6. package/dist_ts/config/helpers.http-redirects.js +21 -12
  7. package/dist_ts/config/helpers.special-forwards.d.ts +14 -0
  8. package/dist_ts/config/helpers.special-forwards.js +85 -0
  9. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +1 -0
  10. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +56 -9
  11. package/dist_ts/opsserver/handlers/route-management.handler.js +36 -1
  12. package/dist_ts_interfaces/data/route-management.d.ts +19 -1
  13. package/dist_ts_interfaces/data/route-management.js +5 -1
  14. package/dist_ts_interfaces/requests/route-management.d.ts +50 -1
  15. package/dist_ts_web/00_commitinfo_data.js +1 -1
  16. package/dist_ts_web/appstate/routes.d.ts +10 -0
  17. package/dist_ts_web/appstate/routes.js +67 -1
  18. package/dist_ts_web/elements/network/ops-view-redirects.d.ts +1 -0
  19. package/dist_ts_web/elements/network/ops-view-redirects.js +12 -4
  20. package/dist_ts_web/elements/network/ops-view-routes.d.ts +20 -1
  21. package/dist_ts_web/elements/network/ops-view-routes.js +182 -52
  22. package/dist_ts_web/elements/network/ops-view-special-forwards.d.ts +12 -0
  23. package/dist_ts_web/elements/network/ops-view-special-forwards.js +375 -0
  24. package/dist_ts_web/elements/ops-dashboard.js +1 -3
  25. package/dist_ts_web/router.js +10 -2
  26. package/package.json +5 -5
  27. package/readme.md +7 -3
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/config/classes.route-config-manager.ts +271 -32
  30. package/ts/config/helpers.http-redirects.ts +24 -16
  31. package/ts/config/helpers.special-forwards.ts +108 -0
  32. package/ts/opsserver/handlers/gatewayclient.handler.ts +55 -8
  33. package/ts/opsserver/handlers/route-management.handler.ts +59 -0
  34. package/ts_apiclient/readme.md +2 -2
  35. package/ts_web/00_commitinfo_data.ts +1 -1
  36. package/ts_web/appstate/routes.ts +84 -0
  37. package/ts_web/elements/network/ops-view-redirects.ts +5 -1
  38. package/ts_web/elements/network/ops-view-routes.ts +175 -31
  39. package/ts_web/elements/network/ops-view-special-forwards.ts +352 -0
  40. package/ts_web/elements/ops-dashboard.ts +0 -2
  41. package/ts_web/readme.md +9 -0
  42. package/ts_web/router.ts +10 -1
@@ -791,7 +791,14 @@ export class GatewayClientHandler {
791
791
  const routeKey = metadata.externalKey?.startsWith(prefix)
792
792
  ? metadata.externalKey.slice(prefix.length)
793
793
  : '';
794
- if (routeKey.startsWith('route:')) {
794
+ const combinedOwnership = this.parseCombinedGatewayClientRouteKey(routeKey);
795
+ if (combinedOwnership) {
796
+ ownership.hostname = combinedOwnership.hostname;
797
+ ownership.routeRef = combinedOwnership.routeRef;
798
+ } else if (routeKey.startsWith('v2:host-route:')) {
799
+ // A malformed versioned key must not be exposed as a hostname.
800
+ ownership.hostname = this.getRouteHostnames(routeArg.route)[0];
801
+ } else if (routeKey.startsWith('route:')) {
795
802
  ownership.routeRef = routeKey.slice('route:'.length);
796
803
  } else if (routeKey) {
797
804
  ownership.hostname = routeKey;
@@ -934,7 +941,7 @@ export class GatewayClientHandler {
934
941
  if (!existingRoute) {
935
942
  return { success: true, action: 'unchanged' };
936
943
  }
937
- const result = await manager.deleteRoute(existingRoute.id);
944
+ const result = await manager.deleteManagedRoute(existingRoute.id);
938
945
  return result.success
939
946
  ? { success: true, action: 'deleted', routeId: existingRoute.id }
940
947
  : { success: false, message: result.message };
@@ -965,24 +972,35 @@ export class GatewayClientHandler {
965
972
  const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
966
973
 
967
974
  if (existingRoute) {
968
- const routePatch: Partial<interfaces.data.IDcRouterRouteConfig> = { ...normalizedRoute };
969
- (routePatch as any).security = null;
970
- const result = await manager.updateRoute(existingRoute.id, {
971
- route: routePatch,
975
+ const result = await manager.updateManagedRoute(existingRoute.id, {
976
+ route: normalizedRoute,
972
977
  enabled: enabled ?? true,
973
978
  metadata,
979
+ }, {
980
+ replaceMetadata: true,
981
+ replaceRoute: true,
974
982
  });
975
983
  return result.success
976
984
  ? { success: true, action: 'updated', routeId: existingRoute.id }
977
985
  : { success: false, message: result.message };
978
986
  }
979
987
 
980
- const routeId = await manager.createRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
988
+ const routeId = await manager.createManagedRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
981
989
  return { success: true, action: 'created', routeId };
982
990
  }
983
991
 
984
992
  private buildGatewayClientExternalKey(ownership: TResolvedGatewayClientOwnership): string {
985
- const routeKey = ownership.hostname || `route:${ownership.routeRef}`;
993
+ const hostname = ownership.hostname?.trim().toLowerCase();
994
+ const routeRef = ownership.routeRef?.trim();
995
+ let routeKey: string;
996
+ if (hostname && routeRef) {
997
+ const payload = Buffer.from(JSON.stringify([hostname, routeRef]), 'utf8').toString('base64url');
998
+ routeKey = `v2:host-route:${payload}`;
999
+ } else if (hostname) {
1000
+ routeKey = hostname;
1001
+ } else {
1002
+ routeKey = `route:${routeRef}`;
1003
+ }
986
1004
  return [
987
1005
  ownership.gatewayClientType,
988
1006
  ownership.gatewayClientId,
@@ -991,6 +1009,35 @@ export class GatewayClientHandler {
991
1009
  ].map((part) => part.trim()).join(':');
992
1010
  }
993
1011
 
1012
+ private parseCombinedGatewayClientRouteKey(
1013
+ routeKeyArg: string,
1014
+ ): { hostname: string; routeRef: string } | undefined {
1015
+ const prefix = 'v2:host-route:';
1016
+ if (!routeKeyArg.startsWith(prefix)) {
1017
+ return undefined;
1018
+ }
1019
+ const payload = routeKeyArg.slice(prefix.length);
1020
+ if (!payload || !/^[A-Za-z0-9_-]+$/.test(payload)) {
1021
+ return undefined;
1022
+ }
1023
+ try {
1024
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
1025
+ if (
1026
+ !Array.isArray(parsed)
1027
+ || parsed.length !== 2
1028
+ || parsed.some((value) => typeof value !== 'string' || !value.trim())
1029
+ ) {
1030
+ return undefined;
1031
+ }
1032
+ return {
1033
+ hostname: parsed[0].trim().toLowerCase(),
1034
+ routeRef: parsed[1].trim(),
1035
+ };
1036
+ } catch {
1037
+ return undefined;
1038
+ }
1039
+ }
1040
+
994
1041
 
995
1042
  private normalizeGatewayClientRoute(
996
1043
  route: interfaces.data.IDcRouterRouteConfig,
@@ -122,5 +122,64 @@ export class RouteManagementHandler {
122
122
  },
123
123
  ),
124
124
  );
125
+
126
+ this.typedrouter.addTypedHandler(
127
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateLetsEncryptHttp01Forward>(
128
+ 'createLetsEncryptHttp01Forward',
129
+ async (dataArg) => {
130
+ const userId = await this.requireAuth(dataArg, 'routes:write');
131
+ const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
132
+ if (!manager) {
133
+ return { success: false, message: 'Route management not initialized' };
134
+ }
135
+ try {
136
+ const routeId = await manager.createLetsEncryptHttp01Forward(
137
+ dataArg.config,
138
+ userId,
139
+ dataArg.enabled ?? true,
140
+ );
141
+ return { success: true, routeId };
142
+ } catch (error) {
143
+ return { success: false, message: (error as Error).message };
144
+ }
145
+ },
146
+ ),
147
+ );
148
+
149
+ this.typedrouter.addTypedHandler(
150
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateLetsEncryptHttp01Forward>(
151
+ 'updateLetsEncryptHttp01Forward',
152
+ async (dataArg) => {
153
+ await this.requireAuth(dataArg, 'routes:write');
154
+ const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
155
+ if (!manager) {
156
+ return { success: false, message: 'Route management not initialized' };
157
+ }
158
+ try {
159
+ return await manager.updateLetsEncryptHttp01Forward(
160
+ dataArg.id,
161
+ dataArg.config,
162
+ dataArg.enabled,
163
+ );
164
+ } catch (error) {
165
+ return { success: false, message: (error as Error).message };
166
+ }
167
+ },
168
+ ),
169
+ );
170
+
171
+ this.typedrouter.addTypedHandler(
172
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteLetsEncryptHttp01Forward>(
173
+ 'deleteLetsEncryptHttp01Forward',
174
+ async (dataArg) => {
175
+ await this.requireAuth(dataArg, 'routes:write');
176
+ const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
177
+ if (!manager) {
178
+ return { success: false, message: 'Route management not initialized' };
179
+ }
180
+ return await manager.deleteLetsEncryptHttp01Forward(dataArg.id);
181
+ },
182
+ ),
183
+ );
125
184
  }
126
185
  }
@@ -63,7 +63,7 @@ const tokenClient = new DcRouterApiClient({
63
63
 
64
64
  | Manager | Purpose |
65
65
  | --- | --- |
66
- | `client.routes` | List merged routes, build API routes, update/delete API routes, and toggle routes. |
66
+ | `client.routes` | List merged routes, build/update/delete ordinary operator routes, and toggle routes. |
67
67
  | `client.certificates` | Inspect certificate summaries and trigger certificate operations. |
68
68
  | `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens. |
69
69
  | `client.remoteIngress` | Manage edge registrations, statuses, ports, tags, and connection tokens. |
@@ -98,7 +98,7 @@ await route.update({
98
98
  });
99
99
  ```
100
100
 
101
- System routes from `config`, `email`, and `dns` origins are designed to be toggled, not edited. Full create/update/delete behavior is for routes with origin `api`.
101
+ System routes from `config`, `email`, and `dns` origins are designed to be toggled, not edited. Generic create/update/delete behavior is for ordinary operator-owned routes with origin `api`. API-origin routes carrying managed ownership metadata, including gateway-client routes and HTTP-01 Special Forwards, must be changed through their owning specialized TypedRequest workflow; `client.routes` does not bypass that protection.
102
102
 
103
103
  ## API Tokens and Remote Ingress
104
104
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '16.1.1',
6
+ version: '16.2.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -221,6 +221,90 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
221
221
  }
222
222
  });
223
223
 
224
+ export const createLetsEncryptHttp01ForwardAction = routeManagementStatePart.createAction<{
225
+ config: interfaces.data.ILetsEncryptHttp01ForwardConfig;
226
+ enabled?: boolean;
227
+ }>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
228
+ const context = getActionContext();
229
+ const currentState = statePartArg.getState()!;
230
+
231
+ try {
232
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
233
+ interfaces.requests.IReq_CreateLetsEncryptHttp01Forward
234
+ >('/typedrequest', 'createLetsEncryptHttp01Forward');
235
+ const response = await request.fire({
236
+ identity: context.identity!,
237
+ config: dataArg.config,
238
+ enabled: dataArg.enabled,
239
+ });
240
+ if (!response.success) {
241
+ throw new Error(response.message || 'Failed to create Special Forward');
242
+ }
243
+ return await actionContext!.dispatch(fetchMergedRoutesAction, null);
244
+ } catch (error: unknown) {
245
+ return {
246
+ ...currentState,
247
+ error: error instanceof Error ? error.message : 'Failed to create Special Forward',
248
+ };
249
+ }
250
+ });
251
+
252
+ export const updateLetsEncryptHttp01ForwardAction = routeManagementStatePart.createAction<{
253
+ id: string;
254
+ config: interfaces.data.ILetsEncryptHttp01ForwardConfig;
255
+ enabled?: boolean;
256
+ }>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
257
+ const context = getActionContext();
258
+ const currentState = statePartArg.getState()!;
259
+
260
+ try {
261
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
262
+ interfaces.requests.IReq_UpdateLetsEncryptHttp01Forward
263
+ >('/typedrequest', 'updateLetsEncryptHttp01Forward');
264
+ const response = await request.fire({
265
+ identity: context.identity!,
266
+ id: dataArg.id,
267
+ config: dataArg.config,
268
+ enabled: dataArg.enabled,
269
+ });
270
+ if (!response.success) {
271
+ throw new Error(response.message || 'Failed to update Special Forward');
272
+ }
273
+ return await actionContext!.dispatch(fetchMergedRoutesAction, null);
274
+ } catch (error: unknown) {
275
+ return {
276
+ ...currentState,
277
+ error: error instanceof Error ? error.message : 'Failed to update Special Forward',
278
+ };
279
+ }
280
+ });
281
+
282
+ export const deleteLetsEncryptHttp01ForwardAction = routeManagementStatePart.createAction<string>(
283
+ async (statePartArg, routeId, actionContext): Promise<IRouteManagementState> => {
284
+ const context = getActionContext();
285
+ const currentState = statePartArg.getState()!;
286
+
287
+ try {
288
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
289
+ interfaces.requests.IReq_DeleteLetsEncryptHttp01Forward
290
+ >('/typedrequest', 'deleteLetsEncryptHttp01Forward');
291
+ const response = await request.fire({
292
+ identity: context.identity!,
293
+ id: routeId,
294
+ });
295
+ if (!response.success) {
296
+ throw new Error(response.message || 'Failed to delete Special Forward');
297
+ }
298
+ return await actionContext!.dispatch(fetchMergedRoutesAction, null);
299
+ } catch (error: unknown) {
300
+ return {
301
+ ...currentState,
302
+ error: error instanceof Error ? error.message : 'Failed to delete Special Forward',
303
+ };
304
+ }
305
+ },
306
+ );
307
+
224
308
  // ============================================================================
225
309
  // API Token Actions
226
310
  // ============================================================================
@@ -5,6 +5,7 @@ import {
5
5
  type TemplateResult,
6
6
  css,
7
7
  state,
8
+ property,
8
9
  cssManager,
9
10
  } from '@design.estate/dees-element';
10
11
  import { type IStatsTile } from '@design.estate/dees-catalog';
@@ -20,6 +21,9 @@ declare global {
20
21
 
21
22
  @customElement('ops-view-redirects')
22
23
  export class OpsViewRedirects extends DeesElement {
24
+ @property({ type: Boolean })
25
+ accessor embedded = false;
26
+
23
27
  @state()
24
28
  accessor routeState: appstate.IRouteManagementState = appstate.routeManagementStatePart.getState()!;
25
29
 
@@ -123,7 +127,7 @@ export class OpsViewRedirects extends DeesElement {
123
127
  ];
124
128
 
125
129
  return html`
126
- <dees-heading level="3">Redirects</dees-heading>
130
+ ${this.embedded ? '' : html`<dees-heading level="3">Redirects</dees-heading>`}
127
131
  <div class="redirectsContainer">
128
132
  <dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
129
133
 
@@ -1,7 +1,9 @@
1
1
  import * as appstate from '../../appstate.js';
2
2
  import * as interfaces from '../../../dist_ts_interfaces/index.js';
3
3
  import { viewHostCss } from '../shared/css.js';
4
- import { type IStatsTile } from '@design.estate/dees-catalog';
4
+ import { type DeesAppuiTabs, type IStatsTile } from '@design.estate/dees-catalog';
5
+ import './ops-view-redirects.js';
6
+ import './ops-view-special-forwards.js';
5
7
  import type {
6
8
  IRoutePathClassOption as ISzRoutePathClassOption,
7
9
  IRouteSourcePolicyPreset as ISzRouteSourcePolicyPreset,
@@ -19,6 +21,10 @@ import {
19
21
  type TemplateResult,
20
22
  } from '@design.estate/dees-element';
21
23
 
24
+ type TRoutesSection = 'standard' | 'special' | 'redirects';
25
+ type TRouteOwnerFilter = 'all' | 'operator' | 'gatewayClient' | 'system';
26
+ type IMenuItem = DeesAppuiTabs['tabs'][number];
27
+
22
28
  // TLS dropdown options shared by create and edit dialogs
23
29
  const tlsModeOptions = [
24
30
  { key: 'none', option: '(none — plain TCP/HTTP, use for SSH)' },
@@ -318,7 +324,42 @@ function setupTlsVisibility(formEl: any) {
318
324
 
319
325
  @customElement('ops-view-routes')
320
326
  export class OpsViewRoutes extends DeesElement {
321
- @state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
327
+ @state() accessor activeSection: TRoutesSection = 'standard';
328
+ @state() accessor ownerFilter: TRouteOwnerFilter = 'all';
329
+
330
+ private readonly sectionTabs: IMenuItem[] = [
331
+ {
332
+ key: 'Standard Routes',
333
+ iconName: 'lucide:route',
334
+ action: () => this.setRouteView('standard'),
335
+ },
336
+ {
337
+ key: 'Special Forwards',
338
+ iconName: 'lucide:split',
339
+ action: () => this.setRouteView('special'),
340
+ },
341
+ {
342
+ key: 'Redirects',
343
+ iconName: 'lucide:cornerDownRight',
344
+ action: () => this.setRouteView('redirects'),
345
+ },
346
+ ];
347
+
348
+ private readonly ownerTabs: IMenuItem[] = [
349
+ { key: 'All', action: () => this.setRouteOwnerFilter('all') },
350
+ { key: 'Operator', action: () => this.setRouteOwnerFilter('operator') },
351
+ {
352
+ key: 'Gateway Client',
353
+ action: () => this.setRouteOwnerFilter('gatewayClient'),
354
+ },
355
+ { key: 'System', action: () => this.setRouteOwnerFilter('system') },
356
+ ];
357
+
358
+ private readonly popStateHandler = () => {
359
+ const urlState = this.readUrlState();
360
+ this.activeSection = urlState.section;
361
+ this.ownerFilter = urlState.owner;
362
+ };
322
363
 
323
364
  @state() accessor routeState: appstate.IRouteManagementState = {
324
365
  mergedRoutes: [],
@@ -341,6 +382,10 @@ export class OpsViewRoutes extends DeesElement {
341
382
 
342
383
  constructor() {
343
384
  super();
385
+ const urlState = this.readUrlState();
386
+ this.activeSection = urlState.section;
387
+ this.ownerFilter = urlState.owner;
388
+ this.writeUrlState(urlState.section, urlState.owner, 'replace');
344
389
  const sub = appstate.routeManagementStatePart
345
390
  .select((s) => s)
346
391
  .subscribe((routeState) => {
@@ -368,6 +413,16 @@ export class OpsViewRoutes extends DeesElement {
368
413
  this.rxSubscriptions.push(loginSub);
369
414
  }
370
415
 
416
+ async connectedCallback() {
417
+ await super.connectedCallback();
418
+ window.addEventListener('popstate', this.popStateHandler);
419
+ }
420
+
421
+ async disconnectedCallback() {
422
+ window.removeEventListener('popstate', this.popStateHandler);
423
+ await super.disconnectedCallback();
424
+ }
425
+
371
426
  public static styles = [
372
427
  cssManager.defaultStyles,
373
428
  viewHostCss,
@@ -415,7 +470,11 @@ export class OpsViewRoutes extends DeesElement {
415
470
 
416
471
  const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
417
472
  const configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length;
418
- const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length;
473
+ const operatorCount = mergedRoutes.filter((mr) => {
474
+ return mr.origin === 'api'
475
+ && mr.metadata?.ownerType !== 'gatewayClient'
476
+ && !mr.metadata?.managedRouteKind;
477
+ }).length;
419
478
 
420
479
  const statsTiles: IStatsTile[] = [
421
480
  {
@@ -437,12 +496,12 @@ export class OpsViewRoutes extends DeesElement {
437
496
  color: '#8b5cf6',
438
497
  },
439
498
  {
440
- id: 'apiRoutes',
441
- title: 'User Routes',
499
+ id: 'operatorRoutes',
500
+ title: 'Operator Routes',
442
501
  type: 'number',
443
- value: apiCount,
502
+ value: operatorCount,
444
503
  icon: 'lucide:code',
445
- description: 'Created via API',
504
+ description: 'Created directly by operators',
446
505
  color: '#0ea5e9',
447
506
  },
448
507
  {
@@ -456,11 +515,19 @@ export class OpsViewRoutes extends DeesElement {
456
515
  },
457
516
  ];
458
517
 
459
- // Filter routes based on selected tab
460
- const isUserRoutes = this.routeFilter === 'User Routes';
461
- const filteredRoutes = mergedRoutes.filter((mr) =>
462
- isUserRoutes ? mr.origin === 'api' : mr.origin !== 'api'
463
- );
518
+ const standardRoutes = mergedRoutes.filter((route) => !route.metadata?.managedRouteKind);
519
+ const filteredRoutes = standardRoutes.filter((route) => {
520
+ switch (this.ownerFilter) {
521
+ case 'operator':
522
+ return route.origin === 'api' && route.metadata?.ownerType !== 'gatewayClient';
523
+ case 'gatewayClient':
524
+ return route.metadata?.ownerType === 'gatewayClient';
525
+ case 'system':
526
+ return route.origin !== 'api' || route.metadata?.ownerType === 'system';
527
+ default:
528
+ return true;
529
+ }
530
+ });
464
531
 
465
532
  // Map filtered routes to sz-route-list-view format
466
533
  const szRoutes = filteredRoutes.map((mr) => {
@@ -482,6 +549,16 @@ export class OpsViewRoutes extends DeesElement {
482
549
  <dees-heading level="3">Route Management</dees-heading>
483
550
 
484
551
  <div class="routesContainer">
552
+ <dees-appui-tabs
553
+ .tabs=${this.sectionTabs}
554
+ .selectedTab=${this.getSelectedSectionTab()}
555
+ ></dees-appui-tabs>
556
+
557
+ ${this.activeSection === 'special'
558
+ ? html`<ops-view-special-forwards></ops-view-special-forwards>`
559
+ : this.activeSection === 'redirects'
560
+ ? html`<ops-view-redirects .embedded=${true}></ops-view-redirects>`
561
+ : html`
485
562
  <dees-statsgrid
486
563
  .tiles=${statsTiles}
487
564
  .gridActions=${[
@@ -498,12 +575,10 @@ export class OpsViewRoutes extends DeesElement {
498
575
  ]}
499
576
  ></dees-statsgrid>
500
577
 
501
- <dees-input-multitoggle
502
- class="routeFilterToggle"
503
- .type=${'single'}
504
- .options=${['User Routes', 'System Routes']}
505
- .selectedOption=${this.routeFilter}
506
- ></dees-input-multitoggle>
578
+ <dees-appui-tabs
579
+ .tabs=${this.ownerTabs}
580
+ .selectedTab=${this.getSelectedOwnerTab()}
581
+ ></dees-appui-tabs>
507
582
 
508
583
  ${warnings.length > 0
509
584
  ? html`
@@ -524,7 +599,7 @@ export class OpsViewRoutes extends DeesElement {
524
599
  ? html`
525
600
  <sz-route-list-view
526
601
  .routes=${szRoutes}
527
- .showActionsFilter=${isUserRoutes ? () => true : () => false}
602
+ .showActionsFilter=${(route: { id?: string; name?: string }) => this.canEditRoute(route)}
528
603
  @route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
529
604
  @route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
530
605
  @route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
@@ -532,10 +607,11 @@ export class OpsViewRoutes extends DeesElement {
532
607
  `
533
608
  : html`
534
609
  <div class="empty-state">
535
- <p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
536
- <p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
610
+ <p>No ${this.getOwnerFilterLabel().toLowerCase()} routes</p>
611
+ <p>Choose another ownership filter or add an operator route.</p>
537
612
  </div>
538
613
  `}
614
+ `}
539
615
  </div>
540
616
  `;
541
617
  }
@@ -560,7 +636,7 @@ export class OpsViewRoutes extends DeesElement {
560
636
  <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
561
637
  ${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
562
638
  <p>ID: <code style="color: #888;">${merged.id}</code></p>
563
- ${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
639
+ ${isSystemManaged ? html`<p>This route is managed by the system, a gateway client, or a specialized workflow.</p>` : ''}
564
640
  ${sourceBindingSummary ? html`<p>Source Bindings: <strong style="color: #a78bfa;">${sourceBindingSummary}</strong></p>` : ''}
565
641
  ${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
566
642
  </div>
@@ -1031,6 +1107,80 @@ export class OpsViewRoutes extends DeesElement {
1031
1107
  appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
1032
1108
  }
1033
1109
 
1110
+ private getSelectedSectionTab(): IMenuItem {
1111
+ const index = this.activeSection === 'special'
1112
+ ? 1
1113
+ : this.activeSection === 'redirects'
1114
+ ? 2
1115
+ : 0;
1116
+ return this.sectionTabs[index];
1117
+ }
1118
+
1119
+ private getSelectedOwnerTab(): IMenuItem {
1120
+ const index = this.ownerFilter === 'operator'
1121
+ ? 1
1122
+ : this.ownerFilter === 'gatewayClient'
1123
+ ? 2
1124
+ : this.ownerFilter === 'system'
1125
+ ? 3
1126
+ : 0;
1127
+ return this.ownerTabs[index];
1128
+ }
1129
+
1130
+ private getOwnerFilterLabel(): string {
1131
+ return this.getSelectedOwnerTab().key;
1132
+ }
1133
+
1134
+ private setRouteView(section: TRoutesSection): void {
1135
+ if (this.activeSection === section) return;
1136
+ this.activeSection = section;
1137
+ this.writeUrlState(section, this.ownerFilter, 'push');
1138
+ }
1139
+
1140
+ private setRouteOwnerFilter(owner: TRouteOwnerFilter): void {
1141
+ if (this.ownerFilter === owner) return;
1142
+ this.ownerFilter = owner;
1143
+ this.writeUrlState(this.activeSection, owner, 'push');
1144
+ }
1145
+
1146
+ private readUrlState(): { section: TRoutesSection; owner: TRouteOwnerFilter } {
1147
+ const params = new URL(window.location.href).searchParams;
1148
+ const tabParam = params.get('tab');
1149
+ const ownerParam = params.get('owner');
1150
+ const section: TRoutesSection = tabParam === 'special' || tabParam === 'redirects'
1151
+ ? tabParam
1152
+ : 'standard';
1153
+ const owner: TRouteOwnerFilter = (
1154
+ ownerParam === 'operator'
1155
+ || ownerParam === 'gatewayClient'
1156
+ || ownerParam === 'system'
1157
+ )
1158
+ ? ownerParam
1159
+ : 'all';
1160
+ return { section, owner };
1161
+ }
1162
+
1163
+ private writeUrlState(
1164
+ section: TRoutesSection,
1165
+ owner: TRouteOwnerFilter,
1166
+ mode: 'push' | 'replace',
1167
+ ): void {
1168
+ if (window.location.pathname !== '/network/routes') return;
1169
+ const url = new URL(window.location.href);
1170
+ url.searchParams.set('tab', section);
1171
+ url.searchParams.set('owner', owner);
1172
+ window.history[mode === 'push' ? 'pushState' : 'replaceState'](
1173
+ window.history.state,
1174
+ '',
1175
+ url,
1176
+ );
1177
+ }
1178
+
1179
+ private canEditRoute(clickedRoute: { id?: string; name?: string }): boolean {
1180
+ const merged = this.findMergedRoute(clickedRoute);
1181
+ return Boolean(merged && !this.isSystemManagedRoute(merged));
1182
+ }
1183
+
1034
1184
  private getSourceBindingRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
1035
1185
  const bindingRefs = metadata?.sourceBindings
1036
1186
  ?.map((binding) => binding.sourceProfileRef)
@@ -1064,18 +1214,12 @@ export class OpsViewRoutes extends DeesElement {
1064
1214
  }
1065
1215
 
1066
1216
  private isSystemManagedRoute(merged: interfaces.data.IMergedRoute): boolean {
1067
- return merged.origin !== 'api';
1217
+ return merged.origin !== 'api'
1218
+ || merged.metadata?.ownerType === 'gatewayClient'
1219
+ || Boolean(merged.metadata?.managedRouteKind);
1068
1220
  }
1069
1221
 
1070
1222
  async firstUpdated() {
1071
1223
  await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
1072
-
1073
- const toggle = this.shadowRoot!.querySelector('.routeFilterToggle') as any;
1074
- if (toggle) {
1075
- const sub = toggle.changeSubject.subscribe(() => {
1076
- this.routeFilter = toggle.selectedOption;
1077
- });
1078
- this.rxSubscriptions.push(sub);
1079
- }
1080
1224
  }
1081
1225
  }