feat(web-ui): reorganize network and security views into tabbed subviews with route-aware navigation
This commit is contained in:
722
ts_web/elements/network/ops-view-routes.ts
Normal file
722
ts_web/elements/network/ops-view-routes.ts
Normal file
@@ -0,0 +1,722 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
// TLS dropdown options shared by create and edit dialogs
|
||||
const tlsModeOptions = [
|
||||
{ key: 'none', option: '(none — no TLS)' },
|
||||
{ key: 'passthrough', option: 'Passthrough' },
|
||||
{ key: 'terminate', option: 'Terminate' },
|
||||
{ key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt' },
|
||||
];
|
||||
const tlsCertOptions = [
|
||||
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
|
||||
{ key: 'custom', option: 'Custom certificate' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Toggle TLS form field visibility based on selected TLS mode and certificate type.
|
||||
*/
|
||||
function setupTlsVisibility(formEl: any) {
|
||||
const updateVisibility = async () => {
|
||||
const data = await formEl.collectFormData();
|
||||
const contentEl = formEl.closest('.content') || formEl.parentElement;
|
||||
if (!contentEl) return;
|
||||
const tlsModeValue = data.tlsMode;
|
||||
const modeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
|
||||
const needsCert = modeKey === 'terminate' || modeKey === 'terminate-and-reencrypt';
|
||||
const certGroup = contentEl.querySelector('.tlsCertificateGroup') as HTMLElement;
|
||||
if (certGroup) certGroup.style.display = needsCert ? 'flex' : 'none';
|
||||
const tlsCertValue = data.tlsCertificate;
|
||||
const certKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
|
||||
const customGroup = contentEl.querySelector('.tlsCustomCertGroup') as HTMLElement;
|
||||
if (customGroup) customGroup.style.display = (needsCert && certKey === 'custom') ? 'flex' : 'none';
|
||||
};
|
||||
formEl.changeSubject.subscribe(() => updateVisibility());
|
||||
updateVisibility();
|
||||
}
|
||||
|
||||
@customElement('ops-view-routes')
|
||||
export class OpsViewRoutes extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
@state() accessor profilesTargetsState: appstate.IProfilesTargetsState = {
|
||||
profiles: [],
|
||||
targets: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.routeManagementStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((routeState) => {
|
||||
this.routeState = routeState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
const ptSub = appstate.profilesTargetsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((ptState) => {
|
||||
this.profilesTargetsState = ptState;
|
||||
});
|
||||
this.rxSubscriptions.push(ptSub);
|
||||
|
||||
// Re-fetch routes when user logs in (fixes race condition where
|
||||
// the view is created before authentication completes)
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((s) => s.isLoggedIn)
|
||||
.subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host { display: block; }
|
||||
.routesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.warnings-bar {
|
||||
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.warning-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#b45309', '#fa0')};
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#666')};
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { mergedRoutes, warnings } = this.routeState;
|
||||
|
||||
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
|
||||
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
|
||||
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalRoutes',
|
||||
title: 'Total Routes',
|
||||
type: 'number',
|
||||
value: mergedRoutes.length,
|
||||
icon: 'lucide:route',
|
||||
description: 'All configured routes',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'hardcoded',
|
||||
title: 'Hardcoded',
|
||||
type: 'number',
|
||||
value: hardcodedCount,
|
||||
icon: 'lucide:lock',
|
||||
description: 'Routes from constructor config',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'programmatic',
|
||||
title: 'Programmatic',
|
||||
type: 'number',
|
||||
value: programmaticCount,
|
||||
icon: 'lucide:code',
|
||||
description: 'Routes added via API',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
{
|
||||
id: 'disabled',
|
||||
title: 'Disabled',
|
||||
type: 'number',
|
||||
value: disabledCount,
|
||||
icon: 'lucide:pauseCircle',
|
||||
description: 'Currently disabled routes',
|
||||
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
|
||||
},
|
||||
];
|
||||
|
||||
// Map merged routes to sz-route-list-view format
|
||||
const szRoutes = mergedRoutes.map((mr) => {
|
||||
const tags = [...(mr.route.tags || [])];
|
||||
tags.push(mr.source);
|
||||
if (!mr.enabled) tags.push('disabled');
|
||||
if (mr.overridden) tags.push('overridden');
|
||||
|
||||
return {
|
||||
...mr.route,
|
||||
enabled: mr.enabled,
|
||||
tags,
|
||||
id: mr.storedRouteId || mr.route.name || undefined,
|
||||
metadata: mr.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
return html`
|
||||
<dees-heading level="hr">Route Management</dees-heading>
|
||||
|
||||
<div class="routesContainer">
|
||||
<dees-statsgrid
|
||||
.tiles=${statsTiles}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Add Route',
|
||||
iconName: 'lucide:plus',
|
||||
action: () => this.showCreateRouteDialog(),
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:refreshCw',
|
||||
action: () => this.refreshData(),
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
${warnings.length > 0
|
||||
? html`
|
||||
<div class="warnings-bar">
|
||||
${warnings.map(
|
||||
(w) => html`
|
||||
<div class="warning-item">
|
||||
<span class="warning-icon">⚠</span>
|
||||
<span>${w.message}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
${szRoutes.length > 0
|
||||
? 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`
|
||||
<div class="empty-state">
|
||||
<p>No routes configured</p>
|
||||
<p>Add a programmatic route or check your constructor configuration.</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async handleRouteClick(e: CustomEvent) {
|
||||
const clickedRoute = e.detail;
|
||||
if (!clickedRoute) return;
|
||||
|
||||
// Find the corresponding merged route
|
||||
const merged = this.routeState.mergedRoutes.find(
|
||||
(mr) => mr.route.name === clickedRoute.name,
|
||||
);
|
||||
if (!merged) return;
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
if (merged.source === 'hardcoded') {
|
||||
const menuOptions = merged.enabled
|
||||
? [
|
||||
{
|
||||
name: 'Disable Route',
|
||||
iconName: 'lucide:pause',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: false },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Enable Route',
|
||||
iconName: 'lucide:play',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: true },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Remove Override',
|
||||
iconName: 'lucide:undo',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.removeRouteOverrideAction,
|
||||
merged.route.name!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
|
||||
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions,
|
||||
});
|
||||
} else {
|
||||
// Programmatic route
|
||||
const meta = merged.metadata;
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
|
||||
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
|
||||
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: merged.enabled ? 'Disable' : 'Enable',
|
||||
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.toggleRouteAction,
|
||||
{ id: merged.storedRouteId!, enabled: !merged.enabled },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.storedRouteId!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) : '';
|
||||
|
||||
// Compute current TLS state for pre-population
|
||||
const currentTls = (route.action as any).tls;
|
||||
const currentTlsMode = currentTls?.mode || 'none';
|
||||
const currentTlsCert = currentTls
|
||||
? (currentTls.certificate === 'auto' || !currentTls.certificate ? 'auto' : 'custom')
|
||||
: 'auto';
|
||||
const currentCustomKey = (typeof currentTls?.certificate === 'object') ? currentTls.certificate.key : '';
|
||||
const currentCustomCert = (typeof currentTls?.certificate === 'object') ? currentTls.certificate.cert : '';
|
||||
const needsCert = currentTlsMode === 'terminate' || currentTlsMode === 'terminate-and-reencrypt';
|
||||
const isCustom = currentTlsCert === 'custom';
|
||||
|
||||
const editModal = 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=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></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-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: ${needsCert && isCustom ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'} .value=${currentCustomKey}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</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 } : {}),
|
||||
};
|
||||
|
||||
// Build TLS config from form
|
||||
const tlsModeValue = formData.tlsMode as any;
|
||||
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
|
||||
if (tlsModeKey && tlsModeKey !== 'none') {
|
||||
const tls: any = { mode: tlsModeKey };
|
||||
if (tlsModeKey !== 'passthrough') {
|
||||
const tlsCertValue = formData.tlsCertificate as any;
|
||||
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
|
||||
if (tlsCertKey === 'custom' && formData.tlsCertKey && formData.tlsCertCert) {
|
||||
tls.certificate = { key: formData.tlsCertKey, cert: formData.tlsCertCert };
|
||||
} else {
|
||||
tls.certificate = 'auto';
|
||||
}
|
||||
}
|
||||
updatedRoute.action.tls = tls;
|
||||
} else {
|
||||
updatedRoute.action.tls = null; // explicit removal
|
||||
}
|
||||
|
||||
const metadata: any = {};
|
||||
const profileRefValue = formData.sourceProfileRef as any;
|
||||
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||
if (profileKey) {
|
||||
metadata.sourceProfileRef = profileKey;
|
||||
}
|
||||
const targetRefValue = formData.networkTargetRef as any;
|
||||
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
}
|
||||
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.updateRouteAction,
|
||||
{
|
||||
id: merged.storedRouteId!,
|
||||
route: updatedRoute,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
},
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// Setup conditional TLS field visibility after modal renders
|
||||
const editForm = editModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
|
||||
if (editForm) {
|
||||
await editForm.updateComplete;
|
||||
setupTlsVisibility(editForm);
|
||||
}
|
||||
}
|
||||
|
||||
private async showCreateRouteDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const profiles = this.profilesTargetsState.profiles;
|
||||
const targets = this.profilesTargetsState.targets;
|
||||
|
||||
// Build dropdown options for profiles and 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 createModal = await DeesModal.createAndShow({
|
||||
heading: 'Add Programmatic Route',
|
||||
content: html`
|
||||
<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-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=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:plus',
|
||||
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 route: 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 } : {}),
|
||||
};
|
||||
|
||||
// Build TLS config from form
|
||||
const tlsModeValue = formData.tlsMode as any;
|
||||
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
|
||||
if (tlsModeKey && tlsModeKey !== 'none') {
|
||||
const tls: any = { mode: tlsModeKey };
|
||||
if (tlsModeKey !== 'passthrough') {
|
||||
const tlsCertValue = formData.tlsCertificate as any;
|
||||
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
|
||||
if (tlsCertKey === 'custom' && formData.tlsCertKey && formData.tlsCertCert) {
|
||||
tls.certificate = { key: formData.tlsCertKey, cert: formData.tlsCertCert };
|
||||
} else {
|
||||
tls.certificate = 'auto';
|
||||
}
|
||||
}
|
||||
route.action.tls = tls;
|
||||
}
|
||||
|
||||
// Build metadata if profile/target selected
|
||||
const metadata: any = {};
|
||||
const profileRefValue = formData.sourceProfileRef as any;
|
||||
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||
if (profileKey) {
|
||||
metadata.sourceProfileRef = profileKey;
|
||||
}
|
||||
const targetRefValue = formData.networkTargetRef as any;
|
||||
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
}
|
||||
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.createRouteAction,
|
||||
{
|
||||
route,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
},
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// Setup conditional TLS field visibility after modal renders
|
||||
const createForm = createModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
|
||||
if (createForm) {
|
||||
await createForm.updateComplete;
|
||||
setupTlsVisibility(createForm);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshData() {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user