Compare commits

...

6 Commits

Author SHA1 Message Date
491e51f40b v12.5.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-03 08:18:28 +00:00
b46247d9cb fix(ops-view-network): centralize traffic chart timing constants for consistent rolling window updates 2026-04-03 08:18:28 +00:00
9c0e46ff4e v12.5.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-02 22:55:57 +00:00
f62bc4a526 feat(ops-view-routes): add priority support and list-based domain editing for routes 2026-04-02 22:55:57 +00:00
8f23600ec1 v12.4.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-02 22:37:49 +00:00
141f185fbf feat(routes): add route edit and delete actions to the ops routes view 2026-04-02 22:37:49 +00:00
9 changed files with 260 additions and 45 deletions

View File

@@ -1,5 +1,26 @@
# Changelog
## 2026-04-03 - 12.5.1 - fix(ops-view-network)
centralize traffic chart timing constants for consistent rolling window updates
- Defines shared constants for the chart window, update interval, and maximum buffered data points
- Replaces hardcoded traffic history sizes and timer intervals with derived values across initialization, history loading, and live updates
- Keeps the chart rolling window configuration aligned with the in-memory traffic buffer
## 2026-04-02 - 12.5.0 - feat(ops-view-routes)
add priority support and list-based domain editing for routes
- Adds a priority field to route create and edit forms so route matching order can be configured.
- Replaces comma-separated domain text input with a list-based domain editor and updates form handling to persist domains as arrays.
## 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

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "12.3.0",
"version": "12.5.1",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -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",

10
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '12.3.0',
version: '12.5.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '12.3.0',
version: '12.5.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -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<IRouteManagementState> => {
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<string>(
async (statePartArg, routeId, actionContext): Promise<IRouteManagementState> => {
const context = getActionContext();

View File

@@ -28,6 +28,13 @@ interface INetworkRequest {
@customElement('ops-view-network')
export class OpsViewNetwork extends DeesElement {
/** How far back the traffic chart shows */
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
/** How often a new data point is added */
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
/** Derived: max data points the buffer holds */
private static readonly MAX_DATA_POINTS = OpsViewNetwork.CHART_WINDOW_MS / OpsViewNetwork.UPDATE_INTERVAL_MS;
@state()
accessor statsState = appstate.statsStatePart.getState()!;
@@ -46,7 +53,7 @@ export class OpsViewNetwork extends DeesElement {
// Track if we need to update the chart to avoid unnecessary re-renders
private lastChartUpdate = 0;
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
private chartUpdateThreshold = OpsViewNetwork.UPDATE_INTERVAL_MS; // Minimum ms between chart updates
private trafficUpdateTimer: any = null;
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
@@ -104,13 +111,11 @@ export class OpsViewNetwork extends DeesElement {
private initializeTrafficData() {
const now = Date.now();
// Fixed 5 minute time range
const range = 5 * 60 * 1000; // 5 minutes
const bucketSize = range / 60; // 60 data points
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
// Initialize with empty data points for both in and out
const emptyData = Array.from({ length: 60 }, (_, i) => {
const time = now - ((59 - i) * bucketSize);
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => {
const time = now - ((MAX_DATA_POINTS - 1 - i) * UPDATE_INTERVAL_MS);
return {
x: new Date(time).toISOString(),
y: 0,
@@ -143,23 +148,23 @@ export class OpsViewNetwork extends DeesElement {
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
}));
// Use history as the chart data, keeping the most recent 60 points (5 min window)
const sliceStart = Math.max(0, historyIn.length - 60);
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
// Use history as the chart data, keeping the most recent points within the window
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
this.trafficDataIn = historyIn.slice(sliceStart);
this.trafficDataOut = historyOut.slice(sliceStart);
// If fewer than 60 points, pad the front with zeros
if (this.trafficDataIn.length < 60) {
// If fewer than MAX_DATA_POINTS, pad the front with zeros
if (this.trafficDataIn.length < MAX_DATA_POINTS) {
const now = Date.now();
const range = 5 * 60 * 1000;
const bucketSize = range / 60;
const padCount = 60 - this.trafficDataIn.length;
const padCount = MAX_DATA_POINTS - this.trafficDataIn.length;
const firstTimestamp = this.trafficDataIn.length > 0
? new Date(this.trafficDataIn[0].x).getTime()
: now;
const padIn = Array.from({ length: padCount }, (_, i) => ({
x: new Date(firstTimestamp - ((padCount - i) * bucketSize)).toISOString(),
x: new Date(firstTimestamp - ((padCount - i) * UPDATE_INTERVAL_MS)).toISOString(),
y: 0,
}));
const padOut = padIn.map(p => ({ ...p }));
@@ -287,27 +292,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=${OpsViewNetwork.CHART_WINDOW_MS}
.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 `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px;">${timestamp}</div>
<div>${seriesName}: ${mbps.toFixed(2)} Mbit/s</div>
</div>
`;
}}
></dees-chart-area>
<!-- Top IPs Section -->
@@ -719,9 +714,8 @@ export class OpsViewNetwork extends DeesElement {
private startTrafficUpdateTimer() {
this.stopTrafficUpdateTimer(); // Clear any existing timer
this.trafficUpdateTimer = setInterval(() => {
// Add a new data point every second
this.addTrafficDataPoint();
}, 1000); // Update every second
}, OpsViewNetwork.UPDATE_INTERVAL_MS);
}
private addTrafficDataPoint() {
@@ -752,7 +746,7 @@ export class OpsViewNetwork extends DeesElement {
};
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
if (this.trafficDataIn.length >= 60) {
if (this.trafficDataIn.length >= OpsViewNetwork.MAX_DATA_POINTS) {
this.trafficDataIn.shift();
this.trafficDataOut.shift();
}

View File

@@ -121,11 +121,15 @@ export class OpsViewOverview extends DeesElement {
<dees-chart-area
.label=${'Email Traffic (24h)'}
.series=${this.getEmailTrafficSeries()}
.realtimeMode=${true}
.rollingWindow=${86400000}
.yAxisFormatter=${(val: number) => `${val}`}
></dees-chart-area>
<dees-chart-area
.label=${'DNS Queries (24h)'}
.series=${this.getDnsQuerySeries()}
.realtimeMode=${true}
.rollingWindow=${86400000}
.yAxisFormatter=${(val: number) => `${val}`}
></dees-chart-area>
<dees-chart-log

View File

@@ -204,7 +204,10 @@ export class OpsViewRoutes extends DeesElement {
? html`
<sz-route-list-view
.routes=${szRoutes}
.showActionsFilter=${(route: any) => 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)}
></sz-route-list-view>
`
: html`
@@ -337,6 +340,165 @@ 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`
<div style="color: #ccc; padding: 8px 0;">
<p>Are you sure you want to delete this route? This action cannot be undone.</p>
</div>
`,
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: string[] = route.match.domains
? (Array.isArray(route.match.domains) ? route.match.domains : [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`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .value=${route.name || ''} .required=${true}></dees-input-text>
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security Profile'} .options=${profileOptions} .selectedKey=${merged.metadata?.securityProfileRef || ''}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedKey=${merged.metadata?.networkTargetRef || ''}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
</dees-form>
`,
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: string[] = Array.isArray(formData.domains)
? formData.domains.filter(Boolean)
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const updatedRoute: any = {
name: formData.name,
match: {
ports,
...(domains.length > 0 ? { domains } : {}),
},
action: {
type: 'forward',
targets: [
{
host: formData.targetHost || 'localhost',
port: parseInt(formData.targetPort, 10) || 443,
},
],
},
...(priority != null && !isNaN(priority) ? { priority } : {}),
};
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;
@@ -364,7 +526,8 @@ export class OpsViewRoutes extends DeesElement {
<dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, optional)'}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'}></dees-input-text>
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security Profile'} .options=${profileOptions} .selectedKey=${''}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedKey=${''}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
@@ -387,15 +550,16 @@ export class OpsViewRoutes extends DeesElement {
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 domains: string[] = Array.isArray(formData.domains)
? formData.domains.filter(Boolean)
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const route: any = {
name: formData.name,
match: {
ports,
...(domains && domains.length > 0 ? { domains } : {}),
...(domains.length > 0 ? { domains } : {}),
},
action: {
type: 'forward',
@@ -406,6 +570,7 @@ export class OpsViewRoutes extends DeesElement {
},
],
},
...(priority != null && !isNaN(priority) ? { priority } : {}),
};
// Build metadata if profile/target selected