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