import * as appstate from '../appstate.js'; import * as interfaces from '../../dist_ts_interfaces/index.js'; import { viewHostCss } from './shared/css.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, viewHostCss, css` .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` Route Management
this.showCreateRouteDialog(), }, { name: 'Refresh', iconName: 'lucide:refreshCw', action: () => this.refreshData(), }, ]} > ${warnings.length > 0 ? html`
${warnings.map( (w) => html`
${w.message}
`, )}
` : ''} ${szRoutes.length > 0 ? html` 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`

No routes configured

Add a programmatic route or check your constructor configuration.

`}
`; } 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`

Source: hardcoded

Status: ${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}

Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.

`, menuOptions, }); } else { // Programmatic route const meta = merged.metadata; await DeesModal.createAndShow({ heading: `Route: ${merged.route.name}`, content: html`

Source: programmatic

Status: ${merged.enabled ? 'Enabled' : 'Disabled'}

ID: ${merged.storedRouteId}

${meta?.sourceProfileName ? html`

Source Profile: ${meta.sourceProfileName}

` : ''} ${meta?.networkTargetName ? html`

Network Target: ${meta.networkTargetName}

` : ''}
`, 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`

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: 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` o.key === (merged.metadata?.sourceProfileRef || '')) || null}> o.key === (merged.metadata?.networkTargetRef || '')) || null}> o.key === currentTlsMode) || tlsModeOptions[0]}>
o.key === currentTlsCert) || tlsCertOptions[0]}>
`, 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` `, 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); } }