From 141f185fbfd921d77d134336c7dad4996f1ad8ff Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 2 Apr 2026 22:37:49 +0000 Subject: [PATCH] feat(routes): add route edit and delete actions to the ops routes view --- changelog.md | 8 ++ package.json | 2 +- pnpm-lock.yaml | 10 +- ts/00_commitinfo_data.ts | 2 +- ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 31 ++++++ ts_web/elements/ops-view-network.ts | 18 +-- ts_web/elements/ops-view-overview.ts | 4 + ts_web/elements/ops-view-routes.ts | 159 +++++++++++++++++++++++++++ 9 files changed, 214 insertions(+), 22 deletions(-) diff --git a/changelog.md b/changelog.md index 3de5c41..d1d496b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-02 - 12.4.0 - feat(routes) +add route edit and delete actions to the ops routes view + +- introduces an update route action in web app state and refreshes merged routes after changes +- adds edit and delete handlers with modal-based confirmation and route form inputs for programmatic routes +- enables realtime chart window configuration in network and overview dashboards +- bumps @serve.zone/catalog to ^2.11.0 + ## 2026-04-02 - 12.3.0 - feat(docs,ops-dashboard) document unified database and reusable security profile and network target management diff --git a/package.json b/package.json index edf7d3e..7378b6c 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartvpn": "1.19.1", "@push.rocks/taskbuffer": "^8.0.2", - "@serve.zone/catalog": "^2.10.0", + "@serve.zone/catalog": "^2.11.0", "@serve.zone/interfaces": "^5.3.0", "@serve.zone/remoteingress": "^4.15.3", "@tsclass/tsclass": "^9.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8de1c3..5561d02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,8 +102,8 @@ importers: specifier: ^8.0.2 version: 8.0.2 '@serve.zone/catalog': - specifier: ^2.10.0 - version: 2.10.0(@tiptap/pm@2.27.2) + specifier: ^2.11.0 + version: 2.11.0(@tiptap/pm@2.27.2) '@serve.zone/interfaces': specifier: ^5.3.0 version: 5.3.0 @@ -1583,8 +1583,8 @@ packages: '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@serve.zone/catalog@2.10.0': - resolution: {integrity: sha512-/y3gDrf3UHXaDhLJtqJTeHSXOCKGQ4ou6Dd80tMxQYm8/I/OJmifkgerLKP05WdbMyj0pLp33QhjLElJrpME8Q==} + '@serve.zone/catalog@2.11.0': + resolution: {integrity: sha512-4DFDewp1PFRhw5P+yQAoAw+i6gG2lfR3h+uPgbNxB5jCfW14eNDXi3nuwTMBQWRHL9jv8o0BokASjV9A0+q66g==} '@serve.zone/interfaces@5.3.0': resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} @@ -6904,7 +6904,7 @@ snapshots: domhandler: 5.0.3 selderee: 0.11.0 - '@serve.zone/catalog@2.10.0(@tiptap/pm@2.27.2)': + '@serve.zone/catalog@2.11.0(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-catalog': 3.50.2(@tiptap/pm@2.27.2) '@design.estate/dees-domtools': 2.5.4 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 671a8fe..9461bf9 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '12.3.0', + version: '12.4.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 671a8fe..9461bf9 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '12.3.0', + version: '12.4.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 3b40edc..130762a 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -1441,6 +1441,37 @@ export const createRouteAction = routeManagementStatePart.createAction<{ } }); +export const updateRouteAction = routeManagementStatePart.createAction<{ + id: string; + route?: any; + enabled?: boolean; + metadata?: any; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateRoute + >('/typedrequest', 'updateRoute'); + + await request.fire({ + identity: context.identity!, + id: dataArg.id, + route: dataArg.route, + enabled: dataArg.enabled, + metadata: dataArg.metadata, + }); + + return await actionContext!.dispatch(fetchMergedRoutesAction, null); + } catch (error: unknown) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to update route', + }; + } +}); + export const deleteRouteAction = routeManagementStatePart.createAction( async (statePartArg, routeId, actionContext): Promise => { const context = getActionContext(); diff --git a/ts_web/elements/ops-view-network.ts b/ts_web/elements/ops-view-network.ts index 086c2e7..75c437a 100644 --- a/ts_web/elements/ops-view-network.ts +++ b/ts_web/elements/ops-view-network.ts @@ -287,27 +287,17 @@ export class OpsViewNetwork extends DeesElement { { name: 'Inbound', data: this.trafficDataIn, - color: '#22c55e', // Green for download + color: '#22c55e', }, { name: 'Outbound', data: this.trafficDataOut, - color: '#8b5cf6', // Purple for upload + color: '#8b5cf6', } ]} - .stacked=${false} + .realtimeMode=${true} + .rollingWindow=${300000} .yAxisFormatter=${(val: number) => `${val} Mbit/s`} - .tooltipFormatter=${(point: any) => { - const mbps = point.y || 0; - const seriesName = point.series?.name || 'Throughput'; - const timestamp = new Date(point.x).toLocaleTimeString(); - return ` -
-
${timestamp}
-
${seriesName}: ${mbps.toFixed(2)} Mbit/s
-
- `; - }} > diff --git a/ts_web/elements/ops-view-overview.ts b/ts_web/elements/ops-view-overview.ts index 07cd96b..6f56f15 100644 --- a/ts_web/elements/ops-view-overview.ts +++ b/ts_web/elements/ops-view-overview.ts @@ -121,11 +121,15 @@ export class OpsViewOverview extends DeesElement { `${val}`} > `${val}`} > route.tags?.includes('programmatic') ?? false} @route-click=${(e: CustomEvent) => this.handleRouteClick(e)} + @route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)} + @route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)} > ` : html` @@ -337,6 +340,162 @@ export class OpsViewRoutes extends DeesElement { } } + private async handleRouteEdit(e: CustomEvent) { + const clickedRoute = e.detail; + if (!clickedRoute) return; + + const merged = this.routeState.mergedRoutes.find( + (mr) => mr.route.name === clickedRoute.name, + ); + if (!merged || !merged.storedRouteId) return; + + this.showEditRouteDialog(merged); + } + + private async handleRouteDelete(e: CustomEvent) { + const clickedRoute = e.detail; + if (!clickedRoute) return; + + const merged = this.routeState.mergedRoutes.find( + (mr) => mr.route.name === clickedRoute.name, + ); + if (!merged || !merged.storedRouteId) return; + + const { DeesModal } = await import('@design.estate/dees-catalog'); + await DeesModal.createAndShow({ + heading: `Delete Route: ${merged.route.name}`, + content: html` +
+

Are you sure you want to delete this route? This action cannot be undone.

+
+ `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Delete', + iconName: 'lucide:trash-2', + action: async (modalArg: any) => { + await appstate.routeManagementStatePart.dispatchAction( + appstate.deleteRouteAction, + merged.storedRouteId!, + ); + await modalArg.destroy(); + }, + }, + ], + }); + } + + private async showEditRouteDialog(merged: interfaces.data.IMergedRoute) { + const { DeesModal } = await import('@design.estate/dees-catalog'); + const profiles = this.profilesTargetsState.profiles; + const targets = this.profilesTargetsState.targets; + + const profileOptions = [ + { key: '', option: '(none — inline security)' }, + ...profiles.map((p) => ({ + key: p.id, + option: `${p.name}${p.description ? ' — ' + p.description : ''}`, + })), + ]; + const targetOptions = [ + { key: '', option: '(none — inline target)' }, + ...targets.map((t) => ({ + key: t.id, + option: `${t.name} (${Array.isArray(t.host) ? t.host.join(',') : t.host}:${t.port})`, + })), + ]; + + const route = merged.route; + const currentPorts = Array.isArray(route.match.ports) + ? route.match.ports.map((p: any) => typeof p === 'number' ? String(p) : `${p.from}-${p.to}`).join(', ') + : String(route.match.ports); + const currentDomains = route.match.domains + ? (Array.isArray(route.match.domains) ? route.match.domains.join(', ') : route.match.domains) + : ''; + const firstTarget = route.action.targets?.[0]; + const currentTargetHost = firstTarget + ? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host) + : ''; + const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : ''; + + await DeesModal.createAndShow({ + heading: `Edit Route: ${route.name}`, + content: html` + + + + + + + + + + `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Save', + iconName: 'lucide:check', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); + if (!form) return; + const formData = await form.collectFormData(); + if (!formData.name || !formData.ports) return; + + const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p)); + const domains = formData.domains + ? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean) + : undefined; + + const updatedRoute: any = { + name: formData.name, + match: { + ports, + ...(domains && domains.length > 0 ? { domains } : {}), + }, + action: { + type: 'forward', + targets: [ + { + host: formData.targetHost || 'localhost', + port: parseInt(formData.targetPort, 10) || 443, + }, + ], + }, + }; + + const metadata: any = {}; + if (formData.securityProfileRef) { + metadata.securityProfileRef = formData.securityProfileRef; + } + if (formData.networkTargetRef) { + metadata.networkTargetRef = formData.networkTargetRef; + } + + await appstate.routeManagementStatePart.dispatchAction( + appstate.updateRouteAction, + { + id: merged.storedRouteId!, + route: updatedRoute, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }, + ); + await modalArg.destroy(); + }, + }, + ], + }); + } + private async showCreateRouteDialog() { const { DeesModal } = await import('@design.estate/dees-catalog'); const profiles = this.profilesTargetsState.profiles;