668 lines
20 KiB
TypeScript
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">🔒</span>TLS</span>`);
|
|
}
|
|
if (action.websocket?.enabled) {
|
|
features.push(html`<span class="feature"><span class="feature-icon">↔</span>WS</span>`);
|
|
}
|
|
if (action.loadBalancing) {
|
|
features.push(html`<span class="feature"><span class="feature-icon">⚖</span>LB</span>`);
|
|
}
|
|
if (security) {
|
|
features.push(html`<span class="feature"><span class="feature-icon">🛡</span>Security</span>`);
|
|
}
|
|
if (headers) {
|
|
features.push(html`<span class="feature"><span class="feature-icon">⚙</span>Headers</span>`);
|
|
}
|
|
|
|
if (features.length === 0) return html``;
|
|
return html`<div class="features-row">${features}</div>`;
|
|
}
|
|
}
|