fix(vpn): handle VPN forwarding mode downgrades and support runtime VPN config updates

This commit is contained in:
2026-04-17 14:28:19 +00:00
parent e26ea9e114
commit a466b88408
8 changed files with 292 additions and 45 deletions

View File

@@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement {
@state()
accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
@state()
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
constructor() {
super();
const sub = appstate.vpnStatePart.select().subscribe((newState) => {
this.vpnState = newState;
});
this.rxSubscriptions.push(sub);
const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
this.targetProfilesState = newState;
});
this.rxSubscriptions.push(targetProfilesSub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
// Ensure target profiles are loaded for autocomplete candidates
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
await Promise.all([
appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null),
appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null),
]);
}
public static styles = [
@@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml,
'Routing': routingHtml,
'VPN IP': client.assignedIp || '-',
'Target Profiles': client.targetProfileIds?.length
? html`${client.targetProfileIds.map(id => {
const profileState = appstate.targetProfilesStatePart.getState();
const profile = profileState?.profiles.find(p => p.id === id);
return html`<span class="tagBadge">${profile?.name || id}</span>`;
})}`
: '-',
'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(),
};
@@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement {
iconName: 'lucide:plus',
type: ['header'],
actionFunc: async () => {
await this.ensureTargetProfilesLoaded();
const { DeesModal } = await import('@design.estate/dees-catalog');
const profileCandidates = this.getTargetProfileCandidates();
const createModal = await DeesModal.createAndShow({
@@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement {
type: ['contextmenu', 'inRow'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
await this.ensureTargetProfilesLoaded();
const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? '';
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
@@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement {
`;
}
private async ensureTargetProfilesLoaded(): Promise<void> {
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
}
private renderTargetProfileBadges(ids?: string[]): TemplateResult | string {
const labels = this.resolveProfileIdsToLabels(ids, {
pendingLabel: 'Loading profile...',
missingLabel: (id) => `Unknown profile (${id})`,
});
if (!labels?.length) {
return '-';
}
return html`${labels.map((label) => html`<span class="tagBadge">${label}</span>`)}`;
}
/**
* Build stable profile labels for list inputs.
*/
private getTargetProfileChoices() {
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
const profiles = this.targetProfilesState.profiles || [];
const nameCounts = new Map<string, number>();
for (const profile of profiles) {
@@ -837,12 +858,27 @@ export class OpsViewVpn extends DeesElement {
/**
* Convert profile IDs to form labels (for populating edit form values).
*/
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
private resolveProfileIdsToLabels(
ids?: string[],
options: {
pendingLabel?: string;
missingLabel?: (id: string) => string;
} = {},
): string[] | undefined {
if (!ids?.length) return undefined;
const choices = this.getTargetProfileChoices();
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
return ids.map((id) => {
return labelsById.get(id) || id;
const label = labelsById.get(id);
if (label) {
return label;
}
if (this.targetProfilesState.lastUpdated === 0 && !this.targetProfilesState.error) {
return options.pendingLabel || 'Loading profile...';
}
return options.missingLabel?.(id) || id;
});
}