feat(routes): add route UI components and demo view with list/card and app-shell integration
This commit is contained in:
326
ts_web/elements/sz-route-list-view.ts
Normal file
326
ts_web/elements/sz-route-list-view.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user