327 lines
9.2 KiB
TypeScript
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">🔍</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,
|
|
})
|
|
);
|
|
}
|
|
}
|