import * as appstate from '../../appstate.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js'; import { viewHostCss } from '../shared/css.js'; import { type DeesInputMultitoggle, type IStatsTile, } from '@design.estate/dees-catalog'; import './ops-view-redirects.js'; import './ops-view-special-forwards.js'; import { getRouteOwner, redirectMatchesOwner, routeMatchesOwner, type TRouteOwnerFilter, } from './route-view-filters.js'; import { letsEncryptHttp01ManagedRouteKind, showLetsEncryptHttp01ForwardDialog, } from './special-forward-dialog.js'; import type { IRoutePathClassOption as ISzRoutePathClassOption, IRouteSourcePolicyPreset as ISzRouteSourcePolicyPreset, ISourceProfileOption as ISzSourceProfileOption, SzInputRouteSourcePolicy, } from '@serve.zone/catalog'; import { DeesElement, css, cssManager, customElement, html, state, type TemplateResult, } from '@design.estate/dees-element'; type TRoutesSection = 'all' | 'standard' | 'redirects' | 'special'; const routeTypeOptions = ['All', 'Standard', 'Redirect', 'Special']; const routeOwnerOptions = ['All', 'User', 'Gateway Client', 'System']; // TLS dropdown options shared by create and edit dialogs const tlsModeOptions = [ { key: 'none', option: '(none — plain TCP/HTTP, use for SSH)' }, { key: 'passthrough', option: 'Passthrough (TLS only)' }, { key: 'terminate', option: 'Terminate TLS' }, { key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt TLS' }, ]; const tlsCertOptions = [ { key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' }, { key: 'custom', option: 'Custom certificate' }, ]; const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const; type TSzRouteSecurityBase = NonNullable; type TSzRouteSecurity = Omit & { rateLimit?: interfaces.data.IRouteSecurity['rateLimit']; challenge?: interfaces.data.IRouteSecurity['challenge']; }; type TSourceProfileOption = Omit & { security?: TSzRouteSecurity; }; type TSzRouteSourcePolicyPreset = Omit & { bindings: interfaces.data.IRouteSourceBinding[]; }; function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] { return { enabled: true, maxRequests, window: 60, keyBy: 'ip' }; } function getDropdownKey(value: any): string { return typeof value === 'string' ? value : value?.key || ''; } function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): { refs: string[]; missingNames: string[]; } { const refs: string[] = []; const missingNames: string[] = []; for (const profileName of giteaSourcePolicyProfileNames) { const profile = profiles.find((item) => item.name.trim().toUpperCase() === profileName); if (profile) { refs.push(profile.id); } else { missingNames.push(profileName); } } return { refs, missingNames }; } function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.data.IRouteSourceBinding[] { const [trustedRef, aiRef, publicRef] = profileRefs; return [ { sourceProfileRef: trustedRef, onExceeded: { type: '429' as const }, }, { sourceProfileRef: aiRef, onExceeded: { type: '429' as const }, pathPolicies: [ { pathClass: 'git-smart-http', rateLimit: rateLimit(1200) }, { pathClass: 'static', rateLimit: rateLimit(240) }, { pathClass: 'raw', rateLimit: rateLimit(20) }, { pathClass: 'archive', rateLimit: rateLimit(6) }, { pathClass: 'expensive-html', rateLimit: rateLimit(6) }, { pathClass: 'normal-html', rateLimit: rateLimit(20) }, ], }, { sourceProfileRef: publicRef, onExceeded: { type: '429' as const }, pathPolicies: [ { pathClass: 'git-smart-http', rateLimit: rateLimit(1200) }, { pathClass: 'static', rateLimit: rateLimit(600) }, { pathClass: 'raw', rateLimit: rateLimit(120) }, { pathClass: 'archive', rateLimit: rateLimit(30) }, { pathClass: 'expensive-html', rateLimit: rateLimit(30) }, { pathClass: 'normal-html', rateLimit: rateLimit(120) }, ], }, ]; } function getGiteaSourcePolicyPresets(profiles: interfaces.data.ISourceProfile[]): TSzRouteSourcePolicyPreset[] { const { refs, missingNames } = getGiteaPresetProfileRefs(profiles); if (missingNames.length > 0) { return []; } return [ { key: 'gitea-bot-protection', label: 'Gitea bot protection', description: 'TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC with path-class rate limits.', bindings: buildGiteaSourceBindingsMetadata(refs), }, ]; } function normalizeSecurityListEntries(entries: unknown): string[] { if (!Array.isArray(entries)) { return []; } return entries .map((entry) => { if (typeof entry === 'string') return entry.trim(); if (entry && typeof entry === 'object' && 'ip' in entry) { const ip = (entry as Record).ip; return typeof ip === 'string' ? ip.trim() : ''; } return ''; }) .filter(Boolean); } function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean { return normalizeSecurityListEntries(profile.security?.ipAllowList).some((source) => { return ['*', '0.0.0.0/0', '::/0'].includes(source.trim()); }); } function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile): boolean { return normalizeSecurityListEntries(profile.security?.ipAllowList).length > 0; } function normalizeCatalogRateLimit( rateLimitValue: interfaces.data.IRouteSecurity['rateLimit'] | undefined, ): TSzRouteSecurity['rateLimit'] | undefined { if (rateLimitValue === null) return null; if (!rateLimitValue) return undefined; const keyBy = ['ip', 'path', 'header'].includes(String(rateLimitValue.keyBy)) ? rateLimitValue.keyBy as 'ip' | 'path' | 'header' : undefined; return { enabled: Boolean(rateLimitValue.enabled), maxRequests: Number(rateLimitValue.maxRequests) || 0, window: Number(rateLimitValue.window) || 0, ...(keyBy ? { keyBy } : {}), ...(rateLimitValue.onExceeded ? { onExceeded: rateLimitValue.onExceeded } : {}), }; } function normalizeCatalogChallenge( challengeValue: interfaces.data.IRouteSecurity['challenge'] | undefined, ): TSzRouteSecurity['challenge'] | undefined { if (challengeValue === null) return null; if (!challengeValue) return undefined; return structuredClone(challengeValue) as TSzRouteSecurity['challenge']; } function getSourceProfileOptions(profiles: interfaces.data.ISourceProfile[]): TSourceProfileOption[] { return profiles.map((profile) => { const ipAllowList = normalizeSecurityListEntries(profile.security?.ipAllowList); const ipBlockList = normalizeSecurityListEntries(profile.security?.ipBlockList); const rateLimitValue = normalizeCatalogRateLimit(profile.security?.rateLimit); const challengeValue = normalizeCatalogChallenge(profile.security?.challenge); const security: TSzRouteSecurity = { ...(ipAllowList.length ? { ipAllowList } : {}), ...(ipBlockList.length ? { ipBlockList } : {}), ...(typeof profile.security?.maxConnections === 'number' ? { maxConnections: profile.security.maxConnections } : {}), ...(rateLimitValue !== undefined ? { rateLimit: rateLimitValue } : {}), ...(challengeValue !== undefined ? { challenge: challengeValue } : {}), }; return { id: profile.id, name: profile.name, description: profile.description, security, hasSourceMatches: sourceProfileHasSourceMatches(profile), matchesAllSources: sourceProfileMatchesAll(profile), }; }); } function getRoutePathClassOptions(): ISzRoutePathClassOption[] { return interfaces.data.routePathClasses.map((pathClass) => ({ key: pathClass, label: interfaces.data.giteaRoutePathClassLabels[pathClass], defaultPatterns: interfaces.data.giteaRoutePathClassPatterns[pathClass], })); } function getSourcePolicyInfoText(profiles: interfaces.data.ISourceProfile[]): string { const { missingNames } = getGiteaPresetProfileRefs(profiles); const presetText = missingNames.length > 0 ? `Gitea preset hidden until these source profiles exist: ${missingNames.join(', ')}.` : 'Use the Gitea preset as a starting point, then edit the generated bindings before saving.'; return `First matching source profile wins. Leave empty for no route-level source access control. ${presetText}`; } function validateSourcePolicyInput(form: Element): boolean { const sourcePolicyInput = form.querySelector('sz-input-route-source-policy') as SzInputRouteSourcePolicy | null; if (!sourcePolicyInput || sourcePolicyInput.isValid()) { return true; } alert(sourcePolicyInput.getValidationMessages().join('\n')); return false; } function getSourceBindingsFromFormData(formData: Record): interfaces.data.IRouteSourceBinding[] { const sourceBindings = formData.sourceBindings; return Array.isArray(sourceBindings) ? sourceBindings as interfaces.data.IRouteSourceBinding[] : []; } function parseTargetPort(value: any): number | undefined { const parsed = typeof value === 'number' ? value : typeof value === 'string' ? parseInt(value.trim(), 10) : Number.NaN; if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { return undefined; } return parsed; } function getRouteTargetInputs(formEl: any) { const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[]; const checkboxInputs = Array.from(formEl.querySelectorAll('dees-input-checkbox')) as any[]; return { hostInput: textInputs.find((input) => input.key === 'targetHost'), portInput: textInputs.find((input) => input.key === 'targetPort'), preservePortInput: checkboxInputs.find((input) => input.key === 'preserveMatchPort'), }; } function setupTargetInputState(formEl: any) { const updateState = async () => { const data = await formEl.collectFormData(); const contentEl = formEl.closest('.content') || formEl.parentElement; const usesNetworkTarget = !!getDropdownKey(data.networkTargetRef); const preserveMatchPort = !usesNetworkTarget && Boolean(data.preserveMatchPort); const { hostInput, portInput, preservePortInput } = getRouteTargetInputs(formEl); const hostDescription = usesNetworkTarget ? 'Controlled by the selected network target' : 'Used when no network target is selected'; const portDescription = usesNetworkTarget ? 'Controlled by the selected network target' : preserveMatchPort ? 'Forwarded to the backend on the same port the client matched' : 'Used when no network target is selected'; if (hostInput) { hostInput.disabled = usesNetworkTarget; hostInput.required = !usesNetworkTarget; hostInput.description = hostDescription; } if (portInput) { portInput.disabled = usesNetworkTarget || preserveMatchPort; portInput.required = !usesNetworkTarget && !preserveMatchPort; portInput.description = portDescription; } if (preservePortInput) { preservePortInput.disabled = usesNetworkTarget; preservePortInput.description = usesNetworkTarget ? 'Unavailable when a network target is selected' : 'Forward to the backend using the same port that matched this route'; if (usesNetworkTarget) { preservePortInput.value = false; } } const remoteIngressGroup = contentEl?.querySelector('.remoteIngressGroup') as HTMLElement | null; if (remoteIngressGroup) { remoteIngressGroup.style.display = Boolean(data.remoteIngressEnabled) ? 'flex' : 'none'; } await formEl.updateRequiredStatus?.(); }; formEl.changeSubject.subscribe(() => updateState()); updateState(); } /** * 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 activeSection: TRoutesSection = 'standard'; @state() accessor ownerFilter: TRouteOwnerFilter = 'all'; private readonly popStateHandler = () => { const urlState = this.readUrlState(); this.activeSection = urlState.section; this.ownerFilter = urlState.owner; }; @state() accessor routeState: appstate.IRouteManagementState = { mergedRoutes: [], warnings: [], httpRedirects: [], apiTokens: [], gatewayClients: [], isLoading: false, error: null, lastUpdated: 0, }; @state() accessor profilesTargetsState: appstate.IProfilesTargetsState = { profiles: [], targets: [], isLoading: false, error: null, lastUpdated: 0, }; constructor() { super(); const urlState = this.readUrlState(); this.activeSection = urlState.section; this.ownerFilter = urlState.owner; this.writeUrlState(urlState.section, urlState.owner, 'replace'); 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) { void this.refreshData(); } }); this.rxSubscriptions.push(loginSub); } async connectedCallback() { await super.connectedCallback(); window.addEventListener('popstate', this.popStateHandler); } async disconnectedCallback() { window.removeEventListener('popstate', this.popStateHandler); await super.disconnectedCallback(); } public static styles = [ cssManager.defaultStyles, viewHostCss, css` .routesContainer { display: flex; flex-direction: column; gap: 24px; } .routeFilters { display: flex; align-items: flex-end; gap: 16px; flex-wrap: nowrap; } .routeFilter { flex: 0 1 auto; min-width: 0; max-width: 100%; overflow-x: auto; } @media (max-width: 760px) { .routeFilters { flex-direction: column; align-items: stretch; } .routeFilter { width: 100%; } } .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 standardRoutes = mergedRoutes.filter((route) => !route.metadata?.managedRouteKind); const filteredStandardRoutes = standardRoutes.filter((route) => { return routeMatchesOwner(route, this.ownerFilter); }); const filteredSpecialRoutes = mergedRoutes.filter((route) => { return route.metadata?.managedRouteKind === letsEncryptHttp01ManagedRouteKind && routeMatchesOwner(route, this.ownerFilter); }); const filteredRedirects = (this.routeState.httpRedirects || []).filter((redirect) => { return redirectMatchesOwner(redirect, mergedRoutes, this.ownerFilter); }); const statsTiles = this.getStatsTiles( filteredStandardRoutes, filteredSpecialRoutes, filteredRedirects, ); // Map filtered routes to sz-route-list-view format const szRoutes = filteredStandardRoutes.map((mr) => { const tags = [...(mr.route.tags || [])]; tags.push(mr.origin); if (!mr.enabled) tags.push('disabled'); if (mr.route.vpnOnly) tags.push('vpn-only'); if (mr.route.ingress?.directHub) tags.push('direct-hub'); if (mr.route.ingress?.smartVpn && !mr.route.vpnOnly) tags.push('smartvpn'); return { ...mr.route, enabled: mr.enabled, tags, id: mr.id || mr.route.name || undefined, metadata: mr.metadata, }; }); return html` Route Management
${this.activeSection === 'all' || this.activeSection === 'standard' ? html` ${this.activeSection === 'all' ? html`Standard Routes` : ''} ${warnings.length > 0 ? html`
${warnings.map( (warning) => html`
${warning.message}
`, )}
` : ''} this.canEditRoute(route)} @route-click=${(event: CustomEvent) => this.handleRouteClick(event)} @route-edit=${(event: CustomEvent) => this.handleRouteEdit(event)} @route-delete=${(event: CustomEvent) => this.handleRouteDelete(event)} > ${szRoutes.length === 0 ? html`

No standard routes match these filters

Choose another ownership filter or add a User Route.

` : ''} ` : ''} ${this.activeSection === 'all' || this.activeSection === 'special' ? html` ` : ''} ${this.activeSection === 'all' || this.activeSection === 'redirects' ? html` ` : ''}
`; } private getStatsTiles( standardRoutes: interfaces.data.IMergedRoute[], specialRoutes: interfaces.data.IMergedRoute[], redirects: interfaces.data.IHttpRedirectInfo[], ): IStatsTile[] { if (this.activeSection === 'all') { const disabledCount = [...standardRoutes, ...specialRoutes] .filter((route) => !route.enabled).length; return [ { id: 'allStandardRoutes', title: 'Standard', type: 'number', value: standardRoutes.length, icon: 'lucide:route', description: 'Matching standard routes', color: '#3b82f6', }, { id: 'allSpecialRoutes', title: 'Special', type: 'number', value: specialRoutes.length, icon: 'lucide:wandSparkles', description: 'Matching managed special forwards', color: '#8b5cf6', }, { id: 'allRedirects', title: 'Redirects', type: 'number', value: redirects.length, icon: 'lucide:cornerDownRight', description: 'Matching derived redirects', color: '#0ea5e9', }, { id: 'allDisabledRoutes', title: 'Disabled', type: 'number', value: disabledCount, icon: 'lucide:circlePause', description: 'Disabled Standard and Special routes', color: disabledCount > 0 ? '#ef4444' : '#6b7280', }, ]; } if (this.activeSection === 'redirects') { const activeCount = redirects.filter((redirect) => redirect.status === 'active').length; const coveredCount = redirects.filter((redirect) => redirect.status === 'covered').length; const skippedCount = redirects.filter((redirect) => redirect.status === 'skipped').length; return [ { id: 'matchingRedirects', title: 'Matching', type: 'number', value: redirects.length, icon: 'lucide:cornerDownRight', description: 'Redirects in this owner scope', color: '#3b82f6', }, { id: 'activeRedirects', title: 'Active', type: 'number', value: activeCount, icon: 'lucide:circleCheck', description: 'Generated at runtime', color: '#22c55e', }, { id: 'coveredRedirects', title: 'Covered', type: 'number', value: coveredCount, icon: 'lucide:shieldCheck', description: 'Handled by explicit HTTP routes', color: '#8b5cf6', }, { id: 'skippedRedirects', title: 'Skipped', type: 'number', value: skippedCount, icon: 'lucide:triangleAlert', description: 'Overlaps explicit HTTP routes', color: skippedCount > 0 ? '#f59e0b' : '#6b7280', }, ]; } const routes = this.activeSection === 'special' ? specialRoutes : standardRoutes; const enabledCount = routes.filter((route) => route.enabled).length; const disabledCount = routes.length - enabledCount; const readOnlyCount = routes.filter((route) => getRouteOwner(route) !== 'operator').length; const scopeName = this.activeSection === 'special' ? 'Special' : 'Standard'; return [ { id: `matching${scopeName}Routes`, title: 'Matching', type: 'number', value: routes.length, icon: 'lucide:route', description: `${scopeName} routes in this owner scope`, color: '#3b82f6', }, { id: `enabled${scopeName}Routes`, title: 'Enabled', type: 'number', value: enabledCount, icon: 'lucide:circleCheck', description: 'Currently active', color: '#22c55e', }, { id: `disabled${scopeName}Routes`, title: 'Disabled', type: 'number', value: disabledCount, icon: 'lucide:circlePause', description: 'Configured but inactive', color: disabledCount > 0 ? '#f59e0b' : '#6b7280', }, { id: `readOnly${scopeName}Routes`, title: 'Read-only', type: 'number', value: readOnlyCount, icon: 'lucide:lockKeyhole', description: 'Managed by gateway clients or system', color: '#8b5cf6', }, ]; } private getGridActions() { const canCreateUserRoutes = this.ownerFilter === 'all' || this.ownerFilter === 'operator'; return [ ...(canCreateUserRoutes && (this.activeSection === 'standard' || this.activeSection === 'all') ? [{ name: 'Add User Route', iconName: 'lucide:plus', action: () => this.showCreateRouteDialog(), }] : []), ...(canCreateUserRoutes && (this.activeSection === 'special' || this.activeSection === 'all') ? [{ name: 'Add User Special Forward', iconName: 'lucide:wandSparkles', action: () => showLetsEncryptHttp01ForwardDialog({ targets: this.profilesTargetsState.targets, }), }] : []), { name: 'Refresh', iconName: 'lucide:refreshCw', action: () => this.refreshData(), }, ]; } private async handleRouteClick(e: CustomEvent) { const clickedRoute = e.detail; if (!clickedRoute) return; const merged = this.findMergedRoute(clickedRoute); if (!merged) return; const { DeesModal } = await import('@design.estate/dees-catalog'); const meta = merged.metadata; const isSystemManaged = this.isSystemManagedRoute(merged); const sourceBindingSummary = this.describeSourcePolicy(meta); const ingressSummary = [ ...(merged.route.ingress?.directHub ? ['Direct hub'] : []), ...(merged.route.ingress?.smartVpn ? ['SmartVPN'] : []), ...(merged.route.remoteIngress?.enabled ? ['RemoteIngress'] : []), ].join(', ') || 'None'; await DeesModal.createAndShow({ heading: `Route: ${merged.route.name}`, content: html`

Origin: ${merged.origin}

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

Ingress: ${ingressSummary}

${merged.route.vpnOnly ? html`

Access: VPN only

` : ''}

ID: ${merged.id}

${isSystemManaged ? html`

This route is managed by the system, a gateway client, or a specialized workflow.

` : ''} ${sourceBindingSummary ? html`

Source Bindings: ${sourceBindingSummary}

` : ''} ${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.id, enabled: !merged.enabled }, ); await modalArg.destroy(); }, }, ...(!isSystemManaged ? [ { name: 'Edit', iconName: 'lucide:pencil', action: async (modalArg: any) => { await modalArg.destroy(); this.showEditRouteDialog(merged); }, }, { name: 'Delete', iconName: 'lucide:trash-2', action: async (modalArg: any) => { await appstate.routeManagementStatePart.dispatchAction( appstate.deleteRouteAction, merged.id, ); 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.findMergedRoute(clickedRoute); if (!merged) return; if (this.isSystemManagedRoute(merged)) return; this.showEditRouteDialog(merged); } private async handleRouteDelete(e: CustomEvent) { const clickedRoute = e.detail; if (!clickedRoute) return; const merged = this.findMergedRoute(clickedRoute); if (!merged) return; if (this.isSystemManagedRoute(merged)) 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.id, ); 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 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 currentPreserveMatchPort = firstTarget?.port === 'preserve'; const currentTargetHost = firstTarget ? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host) : ''; const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : ''; const currentVpnOnly = route.vpnOnly === true; const currentDirectHub = route.ingress?.directHub === true; const currentSmartVpn = route.ingress?.smartVpn === true; const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true; const currentEdgeFilter = route.remoteIngress?.edgeFilter || []; const sourceProfileOptions = getSourceProfileOptions(profiles); const pathClassOptions = getRoutePathClassOptions(); const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles); const sourcePolicyInfoText = getSourcePolicyInfoText(profiles); // 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?.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; if (!validateSourcePolicyInput(form)) 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 sourceBindings = getSourceBindingsFromFormData(formData); const targetKey = getDropdownKey(formData.networkTargetRef); const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort); const targetPort = preserveMatchPort ? 'preserve' : parseTargetPort(formData.targetPort) ?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined); if (targetPort === undefined) { alert('Target Port must be a valid port number when no network target is selected.'); return; } const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled); const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter) ? formData.remoteIngressEdgeFilter.filter(Boolean) : []; const vpnOnly = Boolean(formData.vpnOnly); const directHub = Boolean(formData.directHub); if (vpnOnly && (directHub || remoteIngressEnabled)) { alert('VPN only cannot be combined with direct hub or RemoteIngress.'); return; } if (!directHub && !vpnOnly && !currentSmartVpn && !remoteIngressEnabled) { alert('Enable at least one ingress path.'); return; } const updatedRoute: any = { name: formData.name, match: { ports, ...(domains.length > 0 ? { domains } : {}), }, action: { type: 'forward', targets: [ { host: formData.targetHost || currentTargetHost || 'localhost', port: targetPort, }, ], }, ingress: { directHub, smartVpn: vpnOnly || currentSmartVpn, }, vpnOnly: vpnOnly ? true : null, remoteIngress: remoteIngressEnabled ? { enabled: true, ...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}), } : null, ...(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 = {}; if (sourceBindings.length > 0) { metadata.sourceBindings = sourceBindings; } else if (merged.metadata?.sourceBindings) { metadata.sourceBindings = []; } if (targetKey) { metadata.networkTargetRef = targetKey; } else if (merged.metadata?.networkTargetRef) { metadata.networkTargetRef = ''; metadata.networkTargetName = ''; } await appstate.routeManagementStatePart.dispatchAction( appstate.updateRouteAction, { id: merged.id, 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); setupTargetInputState(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 targets and source policy metadata const sourceProfileOptions = getSourceProfileOptions(profiles); const pathClassOptions = getRoutePathClassOptions(); const sourcePolicyPresets = getGiteaSourcePolicyPresets(profiles); const sourcePolicyInfoText = getSourcePolicyInfoText(profiles); 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 User 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; if (!validateSourcePolicyInput(form)) 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 sourceBindings = getSourceBindingsFromFormData(formData); const targetKey = getDropdownKey(formData.networkTargetRef); const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort); const targetPort = preserveMatchPort ? 'preserve' : parseTargetPort(formData.targetPort) ?? (targetKey ? ports[0] : undefined); if (targetPort === undefined) { alert('Target Port must be a valid port number when no network target is selected.'); return; } const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled); const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter) ? formData.remoteIngressEdgeFilter.filter(Boolean) : []; const vpnOnly = Boolean(formData.vpnOnly); const directHub = Boolean(formData.directHub); if (vpnOnly && (directHub || remoteIngressEnabled)) { alert('VPN only cannot be combined with direct hub or RemoteIngress.'); return; } if (!directHub && !vpnOnly && !remoteIngressEnabled) { alert('Enable at least one ingress path.'); return; } const route: any = { name: formData.name, match: { ports, ...(domains.length > 0 ? { domains } : {}), }, action: { type: 'forward', targets: [ { host: formData.targetHost || 'localhost', port: targetPort, }, ], }, ingress: { directHub, smartVpn: vpnOnly, }, ...(vpnOnly ? { vpnOnly: true } : {}), ...(remoteIngressEnabled ? { remoteIngress: { enabled: true, ...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}), }, } : {}), ...(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 = {}; if (sourceBindings.length > 0) { metadata.sourceBindings = sourceBindings; } 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); setupTargetInputState(createForm); } } private async refreshData(): Promise { await Promise.all([ appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null), appstate.routeManagementStatePart.dispatchAction(appstate.fetchHttpRedirectsAction, null), appstate.profilesTargetsStatePart.dispatchAction( appstate.fetchProfilesAndTargetsAction, null, ), ]); } private getSelectedRouteTypeOption(): string { switch (this.activeSection) { case 'all': return 'All'; case 'redirects': return 'Redirect'; case 'special': return 'Special'; default: return 'Standard'; } } private getSelectedOwnerOption(): string { switch (this.ownerFilter) { case 'operator': return 'User'; case 'gatewayClient': return 'Gateway Client'; case 'system': return 'System'; default: return 'All'; } } private handleRouteTypeChange(toggle: DeesInputMultitoggle): void { const section: TRoutesSection = toggle.selectedOption === 'All' ? 'all' : toggle.selectedOption === 'Redirect' ? 'redirects' : toggle.selectedOption === 'Special' ? 'special' : 'standard'; this.setRouteView(section); } private handleRouteOwnerChange(toggle: DeesInputMultitoggle): void { const owner: TRouteOwnerFilter = toggle.selectedOption === 'User' ? 'operator' : toggle.selectedOption === 'Gateway Client' ? 'gatewayClient' : toggle.selectedOption === 'System' ? 'system' : 'all'; this.setRouteOwnerFilter(owner); } private setRouteView(section: TRoutesSection): void { if (this.activeSection === section) return; this.activeSection = section; this.writeUrlState(section, this.ownerFilter, 'push'); } private setRouteOwnerFilter(owner: TRouteOwnerFilter): void { if (this.ownerFilter === owner) return; this.ownerFilter = owner; this.writeUrlState(this.activeSection, owner, 'push'); } private readUrlState(): { section: TRoutesSection; owner: TRouteOwnerFilter } { const params = new URL(window.location.href).searchParams; const tabParam = params.get('tab'); const ownerParam = params.get('owner'); const section: TRoutesSection = ( tabParam === 'all' || tabParam === 'special' || tabParam === 'redirects' ) ? tabParam : 'standard'; const owner: TRouteOwnerFilter = ( ownerParam === 'operator' || ownerParam === 'gatewayClient' || ownerParam === 'system' ) ? ownerParam : 'all'; return { section, owner }; } private writeUrlState( section: TRoutesSection, owner: TRouteOwnerFilter, mode: 'push' | 'replace', ): void { if (window.location.pathname !== '/network/routes') return; const url = new URL(window.location.href); url.searchParams.set('tab', section); url.searchParams.set('owner', owner); window.history[mode === 'push' ? 'pushState' : 'replaceState']( window.history.state, '', url, ); } private canEditRoute(clickedRoute: { id?: string; name?: string }): boolean { const merged = this.findMergedRoute(clickedRoute); return Boolean(merged && !this.isSystemManagedRoute(merged)); } private getSourceBindingRefs(metadata?: interfaces.data.IRouteMetadata): string[] { const bindingRefs = metadata?.sourceBindings ?.map((binding) => binding.sourceProfileRef) .filter(Boolean) || []; return bindingRefs; } private describeSourcePolicy(metadata?: interfaces.data.IRouteMetadata): string { const refs = this.getSourceBindingRefs(metadata); if (refs.length === 0) { return ''; } return refs.map((ref) => { const binding = metadata?.sourceBindings?.find((item) => item.sourceProfileRef === ref); const profile = this.profilesTargetsState.profiles.find((item) => item.id === ref); return binding?.sourceProfileName || profile?.name || ref.slice(0, 8); }).join(' → '); } private findMergedRoute(clickedRoute: { id?: string; name?: string }): interfaces.data.IMergedRoute | undefined { if (clickedRoute.id) { const routeById = this.routeState.mergedRoutes.find((mr) => mr.id === clickedRoute.id); if (routeById) return routeById; } if (clickedRoute.name) { return this.routeState.mergedRoutes.find((mr) => mr.route.name === clickedRoute.name); } return undefined; } private isSystemManagedRoute(merged: interfaces.data.IMergedRoute): boolean { return getRouteOwner(merged) !== 'operator' || Boolean(merged.metadata?.managedRouteKind); } async firstUpdated() { const typeToggle = this.shadowRoot?.querySelector( '#routeTypeFilter', ) as DeesInputMultitoggle | null; const ownerToggle = this.shadowRoot?.querySelector( '#routeOwnerFilter', ) as DeesInputMultitoggle | null; if (typeToggle) { this.rxSubscriptions.push(typeToggle.changeSubject.subscribe((toggle) => { this.handleRouteTypeChange(toggle); })); } if (ownerToggle) { this.rxSubscriptions.push(ownerToggle.changeSubject.subscribe((toggle) => { this.handleRouteOwnerChange(toggle); })); } await this.refreshData(); } }