Files
catalog/ts_web/elements/sz-route-card.ts

668 lines
20 KiB
TypeScript

import {
DeesElement,
customElement,
html,
css,
cssManager,
property,
type TemplateResult,
} from '@design.estate/dees-element';
declare global {
interface HTMLElementTagNameMap {
'sz-route-card': SzRouteCard;
}
}
// Simplified route types for display purposes
export type TRouteActionType = 'forward' | 'socket-handler';
export type TTlsMode = 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
export type TPortRange = number | number[] | Array<{ from: number; to: number }>;
export interface IRouteMatch {
ports: TPortRange;
domains?: string | string[];
path?: string;
clientIp?: string[];
tlsVersion?: string[];
headers?: Record<string, string>;
protocol?: 'http' | 'tcp';
}
export interface IRouteTarget {
host: string | string[];
port: number | 'preserve';
}
export interface IRouteTls {
mode: TTlsMode;
certificate?: 'auto' | { key: string; cert: string };
}
export interface IRouteAction {
type: TRouteActionType;
targets?: IRouteTarget[];
tls?: IRouteTls;
websocket?: { enabled: boolean };
loadBalancing?: { algorithm: 'round-robin' | 'least-connections' | 'ip-hash' };
forwardingEngine?: 'node' | 'nftables';
}
export interface IRouteSecurity {
ipAllowList?: string[];
ipBlockList?: string[];
maxConnections?: number;
rateLimit?: { enabled: boolean; maxRequests: number; window: number };
}
export interface IRouteConfig {
id?: string;
match: IRouteMatch;
action: IRouteAction;
security?: IRouteSecurity;
headers?: { request?: Record<string, string>; response?: Record<string, string> };
name?: string;
description?: string;
priority?: number;
tags?: string[];
enabled?: boolean;
}
function formatPorts(ports: TPortRange): string {
if (typeof ports === 'number') return String(ports);
if (Array.isArray(ports)) {
return ports
.map((p) => {
if (typeof p === 'number') return String(p);
return `${p.from}\u2013${p.to}`;
})
.join(', ');
}
return String(ports);
}
function formatTargets(targets: IRouteTarget[]): string[] {
const result: string[] = [];
for (const t of targets) {
const hosts = Array.isArray(t.host) ? t.host : [t.host];
const portStr = t.port === 'preserve' ? '(preserve)' : String(t.port);
for (const h of hosts) {
result.push(`${h}:${portStr}`);
}
}
return result;
}
@customElement('sz-route-card')
export class SzRouteCard extends DeesElement {
public static demo = () => html`
<div style="padding: 24px; max-width: 520px;">
<sz-route-card
.route=${{
name: 'API Gateway',
description: 'Main API gateway with TLS termination and load balancing',
enabled: true,
priority: 10,
tags: ['web', 'api', 'production'],
match: {
ports: [443, 8443],
domains: ['api.example.com', '*.api.serve.zone'],
path: '/api/*',
protocol: 'http' as const,
clientIp: ['10.0.0.0/8'],
},
action: {
type: 'forward' as const,
targets: [
{ host: ['10.0.0.1', '10.0.0.2'], port: 8080 },
],
tls: { mode: 'terminate' as const, certificate: 'auto' as const },
websocket: { enabled: true },
loadBalancing: { algorithm: 'round-robin' as const },
forwardingEngine: 'nftables' as const,
},
security: {
ipAllowList: ['10.0.0.0/8'],
ipBlockList: ['192.168.100.0/24'],
rateLimit: { enabled: true, maxRequests: 100, window: 60 },
maxConnections: 1000,
},
} satisfies IRouteConfig}
></sz-route-card>
</div>
`;
public static demoGroups = ['Routes'];
@property({ type: Object })
public accessor route: IRouteConfig | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.card {
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
border-radius: 8px;
padding: 20px;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.card:hover {
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
box-shadow: 0 2px 8px ${cssManager.bdTheme('rgba(0,0,0,0.06)', 'rgba(0,0,0,0.2)')};
}
/* Header */
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.enabled {
background: ${cssManager.bdTheme('#22c55e', '#22c55e')};
box-shadow: 0 0 6px ${cssManager.bdTheme('rgba(34,197,94,0.4)', 'rgba(34,197,94,0.3)')};
}
.status-dot.disabled {
background: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
}
.route-name {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-badges {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.badge.forward {
background: ${cssManager.bdTheme('#dbeafe', 'rgba(59, 130, 246, 0.2)')};
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.badge.socket-handler {
background: ${cssManager.bdTheme('#ede9fe', 'rgba(139, 92, 246, 0.2)')};
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.badge.enabled {
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
}
.badge.disabled {
background: ${cssManager.bdTheme('#f4f4f5', 'rgba(113, 113, 122, 0.2)')};
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.description {
font-size: 13px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
margin-bottom: 8px;
line-height: 1.4;
}
.meta-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
padding: 2px 8px;
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
border-radius: 4px;
font-size: 11px;
font-weight: 500;
color: ${cssManager.bdTheme('#52525b', '#a1a1aa')};
}
.priority {
font-size: 11px;
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
font-weight: 500;
}
/* Sections */
.section {
border-left: 3px solid;
padding: 10px 14px;
margin-bottom: 12px;
border-radius: 0 6px 6px 0;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
.section:last-of-type {
margin-bottom: 0;
}
.section.match {
border-left-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
}
.section.action {
border-left-color: ${cssManager.bdTheme('#22c55e', '#22c55e')};
}
.section.security {
border-left-color: ${cssManager.bdTheme('#f59e0b', '#f59e0b')};
}
.section-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
margin-bottom: 8px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 5px;
font-size: 13px;
line-height: 1.5;
}
.field-row:last-child {
margin-bottom: 0;
}
.field-key {
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
min-width: 64px;
flex-shrink: 0;
font-weight: 500;
}
.field-value {
color: ${cssManager.bdTheme('#18181b', '#e4e4e7')};
word-break: break-all;
}
.domain-chip {
display: inline-flex;
padding: 1px 6px;
background: ${cssManager.bdTheme('#eff6ff', 'rgba(59, 130, 246, 0.1)')};
border-radius: 3px;
font-size: 12px;
margin-right: 4px;
margin-bottom: 2px;
font-family: monospace;
}
.domain-chip.glob {
background: ${cssManager.bdTheme('#fef3c7', 'rgba(245, 158, 11, 0.15)')};
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
}
.mono {
font-family: monospace;
font-size: 12px;
}
.protocol-badge {
display: inline-flex;
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
.protocol-badge.http {
background: ${cssManager.bdTheme('#dbeafe', 'rgba(59, 130, 246, 0.2)')};
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.protocol-badge.tcp {
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
}
.tls-badge {
display: inline-flex;
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
.tls-badge.auto {
background: ${cssManager.bdTheme('#dcfce7', 'rgba(34, 197, 94, 0.2)')};
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
}
.tls-badge.custom {
background: ${cssManager.bdTheme('#ffedd5', 'rgba(249, 115, 22, 0.2)')};
color: ${cssManager.bdTheme('#c2410c', '#fb923c')};
}
.engine-badge {
display: inline-flex;
padding: 1px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#fae8ff', 'rgba(168, 85, 247, 0.2)')};
color: ${cssManager.bdTheme('#7e22ce', '#c084fc')};
}
.header-pair {
display: inline;
font-family: monospace;
font-size: 12px;
}
/* Feature icons */
.features-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid ${cssManager.bdTheme('#f4f4f5', '#1a1a1a')};
}
.feature {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.feature-icon {
font-size: 13px;
}
.no-route {
text-align: center;
padding: 24px;
color: ${cssManager.bdTheme('#a1a1aa', '#52525b')};
font-size: 13px;
}
`,
];
public render(): TemplateResult {
if (!this.route) {
return html`<div class="card"><div class="no-route">No route data</div></div>`;
}
const r = this.route;
const isEnabled = r.enabled !== false;
const match = r.match;
const action = r.action;
const security = r.security;
return html`
<div class="card">
<!-- Header -->
<div class="header">
<div class="header-left">
<span class="status-dot ${isEnabled ? 'enabled' : 'disabled'}"></span>
<span class="route-name">${r.name || r.id || 'Unnamed Route'}</span>
</div>
<div class="header-badges">
<span class="badge ${action.type}">${action.type}</span>
<span class="badge ${isEnabled ? 'enabled' : 'disabled'}">${isEnabled ? 'enabled' : 'disabled'}</span>
</div>
</div>
${r.description ? html`<div class="description">${r.description}</div>` : ''}
<div class="meta-row">
${r.tags && r.tags.length > 0
? html`<div class="tags">${r.tags.map((t) => html`<span class="tag">${t}</span>`)}</div>`
: html`<div></div>`}
${r.priority != null ? html`<span class="priority">Priority: ${r.priority}</span>` : ''}
</div>
<!-- Match Section -->
<div class="section match">
<div class="section-label">Match</div>
<div class="field-row">
<span class="field-key">Ports</span>
<span class="field-value mono">${formatPorts(match.ports)}</span>
</div>
${match.domains
? html`
<div class="field-row">
<span class="field-key">Domains</span>
<span class="field-value">${this.renderDomains(match.domains)}</span>
</div>
`
: ''}
${match.path
? html`
<div class="field-row">
<span class="field-key">Path</span>
<span class="field-value mono">${match.path}</span>
</div>
`
: ''}
${match.protocol
? html`
<div class="field-row">
<span class="field-key">Protocol</span>
<span class="field-value">
<span class="protocol-badge ${match.protocol}">${match.protocol}</span>
</span>
</div>
`
: ''}
${match.clientIp && match.clientIp.length > 0
? html`
<div class="field-row">
<span class="field-key">Client</span>
<span class="field-value mono">${match.clientIp.join(', ')}</span>
</div>
`
: ''}
${match.tlsVersion && match.tlsVersion.length > 0
? html`
<div class="field-row">
<span class="field-key">TLS Ver</span>
<span class="field-value">${match.tlsVersion.join(', ')}</span>
</div>
`
: ''}
${match.headers
? html`
<div class="field-row">
<span class="field-key">Headers</span>
<span class="field-value">
${Object.entries(match.headers).map(
([k, v]) => html`<span class="header-pair">${k}=${v}</span> `
)}
</span>
</div>
`
: ''}
</div>
<!-- Action Section -->
<div class="section action">
<div class="section-label">Action</div>
${action.targets && action.targets.length > 0
? html`
<div class="field-row">
<span class="field-key">Targets</span>
<span class="field-value mono">${formatTargets(action.targets).join(', ')}</span>
</div>
`
: ''}
${action.tls
? html`
<div class="field-row">
<span class="field-key">TLS</span>
<span class="field-value">
${action.tls.mode}
${action.tls.certificate
? action.tls.certificate === 'auto'
? html` <span class="tls-badge auto">auto cert</span>`
: html` <span class="tls-badge custom">custom cert</span>`
: ''}
</span>
</div>
`
: ''}
${action.forwardingEngine
? html`
<div class="field-row">
<span class="field-key">Engine</span>
<span class="field-value"><span class="engine-badge">${action.forwardingEngine}</span></span>
</div>
`
: ''}
${action.loadBalancing
? html`
<div class="field-row">
<span class="field-key">LB</span>
<span class="field-value">${action.loadBalancing.algorithm}</span>
</div>
`
: ''}
${action.websocket?.enabled
? html`
<div class="field-row">
<span class="field-key">WS</span>
<span class="field-value"><span class="badge enabled">enabled</span></span>
</div>
`
: ''}
</div>
<!-- Security Section -->
${security
? html`
<div class="section security">
<div class="section-label">Security</div>
${security.ipAllowList && security.ipAllowList.length > 0
? html`
<div class="field-row">
<span class="field-key">Allow</span>
<span class="field-value mono">${security.ipAllowList.join(', ')}</span>
</div>
`
: ''}
${security.ipBlockList && security.ipBlockList.length > 0
? html`
<div class="field-row">
<span class="field-key">Block</span>
<span class="field-value mono">${security.ipBlockList.join(', ')}</span>
</div>
`
: ''}
${security.rateLimit?.enabled
? html`
<div class="field-row">
<span class="field-key">Rate</span>
<span class="field-value">${security.rateLimit.maxRequests} req / ${security.rateLimit.window}s</span>
</div>
`
: ''}
${security.maxConnections
? html`
<div class="field-row">
<span class="field-key">Max Conn</span>
<span class="field-value">${security.maxConnections}</span>
</div>
`
: ''}
</div>
`
: ''}
<!-- Feature Icons Row -->
${this.renderFeatures()}
</div>
`;
}
private renderDomains(domains: string | string[]): TemplateResult {
const list = Array.isArray(domains) ? domains : [domains];
return html`${list.map(
(d) =>
html`<span class="domain-chip ${d.includes('*') ? 'glob' : ''}">${d}</span>`
)}`;
}
private renderFeatures(): TemplateResult {
if (!this.route) return html``;
const features: TemplateResult[] = [];
const action = this.route.action;
const security = this.route.security;
const headers = this.route.headers;
if (action.tls) {
features.push(html`<span class="feature"><span class="feature-icon">&#x1f512;</span>TLS</span>`);
}
if (action.websocket?.enabled) {
features.push(html`<span class="feature"><span class="feature-icon">&#x2194;</span>WS</span>`);
}
if (action.loadBalancing) {
features.push(html`<span class="feature"><span class="feature-icon">&#x2696;</span>LB</span>`);
}
if (security) {
features.push(html`<span class="feature"><span class="feature-icon">&#x1f6e1;</span>Security</span>`);
}
if (headers) {
features.push(html`<span class="feature"><span class="feature-icon">&#x2699;</span>Headers</span>`);
}
if (features.length === 0) return html``;
return html`<div class="features-row">${features}</div>`;
}
}