fix(vpn,target-profiles): normalize target profile route references and stabilize VPN host-IP client routing behavior

This commit is contained in:
2026-04-13 23:02:42 +00:00
parent 466654ee4c
commit aec8b72ca3
11 changed files with 446 additions and 131 deletions

View File

@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
: '-',
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
: '-',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
`;
}
private getRouteCandidates() {
private getRouteChoices() {
const routeState = appstate.routeManagementStatePart.getState();
const routes = routeState?.mergedRoutes || [];
return routes
.filter((mr) => mr.route.name)
.map((mr) => ({ viewKey: mr.route.name! }));
.filter((mr) => mr.route.name && mr.id)
.map((mr) => ({
routeId: mr.id!,
routeName: mr.route.name!,
label: `${mr.route.name} (${mr.id})`,
}));
}
private getRouteCandidates() {
return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
}
private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
if (!routeRefs?.length) return undefined;
const routeChoices = this.getRouteChoices();
const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
const routeByName = new Map<string, string[]>();
for (const route of routeChoices) {
const labels = routeByName.get(route.routeName) || [];
labels.push(route.label);
routeByName.set(route.routeName, labels);
}
return routeRefs.map((routeRef) => {
const routeLabel = routeById.get(routeRef);
if (routeLabel) return routeLabel;
const labelsForName = routeByName.get(routeRef) || [];
if (labelsForName.length === 1) return labelsForName[0];
return routeRef;
});
}
private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
if (!routeRefs.length) return [];
const labelToId = new Map(
this.getRouteChoices().map((route) => [route.label, route.routeId]),
);
return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
}
private formatRouteRef(routeRef: string): string {
return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
}
private async ensureRoutesLoaded() {
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
const routeRefs = this.resolveRouteLabelsToRefs(
Array.isArray(data.routeRefs) ? data.routeRefs : [],
);
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
const currentRouteRefs = profile.routeRefs || [];
const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
const routeRefs = this.resolveRouteLabelsToRefs(
Array.isArray(data.routeRefs) ? data.routeRefs : [],
);
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.routeRefs?.length
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
: '-'}
</div>
</div>

View File

@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
if (hostIpGroup) hostIpGroup.style.display = show;
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
if (!form) return;
const data = await form.collectFormData();
if (!data.clientId) return;
const targetProfileIds = this.resolveProfileNamesToIds(
const targetProfileIds = this.resolveProfileLabelsToIds(
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
);
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
description: data.description || undefined,
targetProfileIds,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
useHostIp,
useDhcp,
staticIp,
forceVlan: forceVlan || undefined,
forceVlan,
vlanId,
destinationAllowList,
destinationBlockList,
@@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
${client.useHostIp ? html`
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? '';
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
const profileCandidates = this.getTargetProfileCandidates();
const currentUseHostIp = client.useHostIp ?? false;
const currentUseDhcp = client.useDhcp ?? false;
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const targetProfileIds = this.resolveProfileNamesToIds(
const targetProfileIds = this.resolveProfileLabelsToIds(
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
);
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
description: data.description || undefined,
targetProfileIds,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
useHostIp,
useDhcp,
staticIp,
forceVlan: forceVlan || undefined,
forceVlan,
vlanId,
destinationAllowList,
destinationBlockList,
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
}
/**
* Build autocomplete candidates from loaded target profiles.
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
* Build stable profile labels for list inputs.
*/
private getTargetProfileCandidates() {
private getTargetProfileChoices() {
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
const nameCounts = new Map<string, number>();
for (const profile of profiles) {
nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1);
}
return profiles.map((profile) => ({
id: profile.id,
label: (nameCounts.get(profile.name) || 0) > 1
? `${profile.name} (${profile.id})`
: profile.name,
}));
}
private getTargetProfileCandidates() {
return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label }));
}
/**
* Convert profile IDs to profile names (for populating edit form values).
* Convert profile IDs to form labels (for populating edit form values).
*/
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
if (!ids?.length) return undefined;
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
const choices = this.getTargetProfileChoices();
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
return ids.map((id) => {
const profile = profiles.find((p) => p.id === id);
return profile?.name || id;
return labelsById.get(id) || id;
});
}
/**
* Convert profile names back to IDs (for saving form data).
* Uses the dees-input-list candidates' payload when available.
* Convert profile form labels back to IDs.
*/
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
if (!names.length) return undefined;
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
return names
.map((name) => {
const profile = profiles.find((p) => p.name === name);
return profile?.id;
})
private resolveProfileLabelsToIds(labels: string[]): string[] {
if (!labels.length) return [];
const labelsToIds = new Map(
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
);
return labels
.map((label) => labelsToIds.get(label))
.filter((id): id is string => !!id);
}
}