Files
catalog/ts_web/elements/sz-route-list-view.ts

327 lines
9.2 KiB
TypeScript

import {
DeesElement,
customElement,
html,
css,
cssManager,
property,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import type { IRouteConfig, TRouteActionType } from './sz-route-card.js';
declare global {
interface HTMLElementTagNameMap {
'sz-route-list-view': SzRouteListView;
}
}
@customElement('sz-route-list-view')
export class SzRouteListView extends DeesElement {
public static demo = () => html`
<div style="padding: 24px; max-width: 1200px;">
<sz-route-list-view
.routes=${[
{
name: 'HTTPS Gateway',
description: 'Main web gateway with TLS termination',
enabled: true,
tags: ['web', 'https', 'production'],
match: { ports: 443, domains: ['*.example.com', 'serve.zone'], protocol: 'http' as const },
action: {
type: 'forward' as const,
targets: [{ host: '10.0.0.1', port: 8080 }],
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
},
},
{
name: 'SMTP Inbound',
description: 'Email relay for incoming mail',
enabled: true,
tags: ['email', 'smtp'],
match: { ports: 25, domains: 'mail.serve.zone' },
action: {
type: 'forward' as const,
targets: [{ host: '10.0.1.5', port: 25 }],
},
},
{
name: 'WebSocket API',
description: 'Real-time WebSocket connections',
enabled: true,
tags: ['web', 'api'],
match: { ports: 443, domains: 'ws.example.com', path: '/ws/*' },
action: {
type: 'forward' as const,
targets: [{ host: '10.0.0.3', port: 9090 }],
websocket: { enabled: true },
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
},
},
{
name: 'Maintenance Page',
enabled: false,
tags: ['web'],
match: { ports: [80, 443], domains: 'old.example.com' },
action: { type: 'socket-handler' as const },
},
] satisfies IRouteConfig[]}
></sz-route-list-view>
</div>
`;
public static demoGroups = ['Routes'];
@property({ type: Array })
public accessor routes: IRouteConfig[] = [];
@state()
private accessor searchQuery: string = '';
@state()
private accessor actionFilter: TRouteActionType | 'all' = 'all';
@state()
private accessor enabledFilter: 'all' | 'enabled' | 'disabled' = 'all';
private get filteredRoutes(): IRouteConfig[] {
return this.routes.filter((route) => {
// Action type filter
if (this.actionFilter !== 'all' && route.action.type !== this.actionFilter) return false;
// Enabled/disabled filter
if (this.enabledFilter === 'enabled' && route.enabled === false) return false;
if (this.enabledFilter === 'disabled' && route.enabled !== false) return false;
// Search query
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
return this.routeMatchesSearch(route, q);
}
return true;
});
}
private routeMatchesSearch(route: IRouteConfig, q: string): boolean {
// Name and description
if (route.name?.toLowerCase().includes(q)) return true;
if (route.description?.toLowerCase().includes(q)) return true;
// Domains
if (route.match.domains) {
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
if (domains.some((d) => d.toLowerCase().includes(q))) return true;
}
// Ports
const portsStr = this.formatPortsForSearch(route.match.ports);
if (portsStr.includes(q)) return true;
// Path
if (route.match.path?.toLowerCase().includes(q)) return true;
// Client IPs
if (route.match.clientIp?.some((ip) => ip.includes(q))) return true;
// Targets
if (route.action.targets) {
for (const t of route.action.targets) {
const hosts = Array.isArray(t.host) ? t.host : [t.host];
if (hosts.some((h) => h.toLowerCase().includes(q))) return true;
}
}
// Tags
if (route.tags?.some((t) => t.toLowerCase().includes(q))) return true;
return false;
}
private formatPortsForSearch(ports: import('./sz-route-card.js').TPortRange): string {
if (typeof ports === 'number') return String(ports);
if (Array.isArray(ports)) {
return ports
.map((p) => (typeof p === 'number' ? String(p) : `${p.from}-${p.to}`))
.join(' ');
}
return String(ports);
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 6px;
font-size: 14px;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
outline: none;
transition: border-color 200ms ease;
}
.search-input::placeholder {
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
}
.search-input:focus {
border-color: ${cssManager.bdTheme('#2563eb', '#3b82f6')};
}
.chip-group {
display: flex;
gap: 4px;
}
.chip {
padding: 6px 12px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
cursor: pointer;
transition: all 200ms ease;
white-space: nowrap;
}
.chip:hover {
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
.chip.active {
background: ${cssManager.bdTheme('#18181b', '#fafafa')};
color: ${cssManager.bdTheme('#fafafa', '#18181b')};
border-color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
.results-count {
font-size: 13px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 16px;
}
.grid sz-route-card {
cursor: pointer;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
font-size: 14px;
}
.empty-state-icon {
font-size: 32px;
margin-bottom: 12px;
opacity: 0.5;
}
`,
];
public render(): TemplateResult {
const filtered = this.filteredRoutes;
return html`
<div class="filter-bar">
<input
class="search-input"
type="text"
placeholder="Search routes by domain, IP, port, path, or tag..."
.value=${this.searchQuery}
@input=${(e: InputEvent) => {
this.searchQuery = (e.target as HTMLInputElement).value;
}}
/>
<div class="chip-group">
${(['all', 'forward', 'socket-handler'] as const).map(
(type) => html`
<button
class="chip ${this.actionFilter === type ? 'active' : ''}"
@click=${() => {
this.actionFilter = type;
}}
>
${type === 'all' ? 'All' : type === 'forward' ? 'Forward' : 'Socket Handler'}
</button>
`
)}
</div>
<div class="chip-group">
${(['all', 'enabled', 'disabled'] as const).map(
(status) => html`
<button
class="chip ${this.enabledFilter === status ? 'active' : ''}"
@click=${() => {
this.enabledFilter = status;
}}
>
${status.charAt(0).toUpperCase() + status.slice(1)}
</button>
`
)}
</div>
</div>
<div class="results-count">
Showing ${filtered.length} of ${this.routes.length} routes
</div>
${filtered.length > 0
? html`
<div class="grid">
${filtered.map(
(route) => html`
<sz-route-card
.route=${route}
@click=${() => this.handleRouteClick(route)}
></sz-route-card>
`
)}
</div>
`
: html`
<div class="empty-state">
<div class="empty-state-icon">&#x1f50d;</div>
<div>No routes match your filters</div>
</div>
`}
`;
}
private handleRouteClick(route: IRouteConfig) {
this.dispatchEvent(
new CustomEvent('route-click', {
detail: route,
bubbles: true,
composed: true,
})
);
}
}