Files
dcrouter/ts_web/elements/ops-view-remoteingress.ts

293 lines
9.3 KiB
TypeScript
Raw Normal View History

import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as appstate from '../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-remoteingress': OpsViewRemoteIngress;
}
}
@customElement('ops-view-remoteingress')
export class OpsViewRemoteIngress extends DeesElement {
@state()
accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState();
constructor() {
super();
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
this.riState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.remoteIngressStatePart.dispatchAction(appstate.fetchRemoteIngressAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.remoteIngressContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.statusBadge.connected {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disconnected {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.secretDialog {
padding: 16px;
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
border-radius: 8px;
margin-bottom: 16px;
}
.secretDialog code {
display: block;
padding: 8px 12px;
background: ${cssManager.bdTheme('#1f2937', '#111827')};
color: #10b981;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
word-break: break-all;
margin: 8px 0;
user-select: all;
}
.secretDialog .warning {
font-size: 12px;
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
margin-top: 8px;
}
.portsDisplay {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.portBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
}
`,
];
render(): TemplateResult {
const totalEdges = this.riState.edges.length;
const connectedEdges = this.riState.statuses.filter(s => s.connected).length;
const disconnectedEdges = totalEdges - connectedEdges;
const activeTunnels = this.riState.statuses.reduce((sum, s) => sum + s.activeTunnels, 0);
const statsTiles: IStatsTile[] = [
{
id: 'totalEdges',
title: 'Total Edges',
type: 'number',
value: totalEdges,
icon: 'lucide:server',
description: 'Registered edge nodes',
color: '#3b82f6',
},
{
id: 'connectedEdges',
title: 'Connected',
type: 'number',
value: connectedEdges,
icon: 'lucide:link',
description: 'Currently connected edges',
color: '#10b981',
},
{
id: 'disconnectedEdges',
title: 'Disconnected',
type: 'number',
value: disconnectedEdges,
icon: 'lucide:unlink',
description: 'Offline edge nodes',
color: disconnectedEdges > 0 ? '#ef4444' : '#6b7280',
},
{
id: 'activeTunnels',
title: 'Active Tunnels',
type: 'number',
value: activeTunnels,
icon: 'lucide:cable',
description: 'Active client connections',
color: '#8b5cf6',
},
];
return html`
<ops-sectionheading>Remote Ingress</ops-sectionheading>
${this.riState.newEdgeSecret ? html`
<div class="secretDialog">
<strong>Edge Secret (copy now - shown only once):</strong>
<code>${this.riState.newEdgeSecret}</code>
<div class="warning">This secret will not be shown again. Save it securely.</div>
<dees-button
@click=${() => appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeSecretAction, null)}
>Dismiss</dees-button>
</div>
` : ''}
<div class="remoteIngressContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Edge Nodes'}
.heading2=${'Manage remote ingress edge registrations'}
.data=${this.riState.edges}
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
name: edge.name,
status: this.getEdgeStatusHtml(edge),
publicIp: this.getEdgePublicIp(edge.id),
ports: this.getPortsHtml(edge.listenPorts),
tunnels: this.getEdgeTunnelCount(edge.id),
lastHeartbeat: this.getLastHeartbeat(edge.id),
})}
.dataActions=${[
{
name: 'Regenerate Secret',
iconName: 'lucide:key',
type: ['row'],
action: async (edge: interfaces.data.IRemoteIngress) => {
await appstate.remoteIngressStatePart.dispatchAction(
appstate.regenerateRemoteIngressSecretAction,
edge.id,
);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['row'],
action: async (edge: interfaces.data.IRemoteIngress) => {
await appstate.remoteIngressStatePart.dispatchAction(
appstate.deleteRemoteIngressAction,
edge.id,
);
},
},
]}
.createNewAction=${async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
const result = await DeesModal.createAndShow({
heading: 'Create Edge Node',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Listen Ports (comma-separated)'} .required=${true} .value=${'443,25'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
</dees-form>
`,
menuOptions: [],
});
if (result) {
const formData = result as any;
const ports = (formData.name ? formData.listenPorts : '443')
.split(',')
.map((p: string) => parseInt(p.trim(), 10))
.filter((p: number) => !isNaN(p));
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.createRemoteIngressAction,
{
name: formData.name,
listenPorts: ports,
tags,
},
);
}
}}
></dees-table>
</div>
`;
}
private getEdgeStatus(edgeId: string): interfaces.data.IRemoteIngressStatus | undefined {
return this.riState.statuses.find(s => s.edgeId === edgeId);
}
private getEdgeStatusHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
if (!edge.enabled) {
return html`<span class="statusBadge disabled">Disabled</span>`;
}
const status = this.getEdgeStatus(edge.id);
if (status?.connected) {
return html`<span class="statusBadge connected">Connected</span>`;
}
return html`<span class="statusBadge disconnected">Disconnected</span>`;
}
private getEdgePublicIp(edgeId: string): string {
const status = this.getEdgeStatus(edgeId);
return status?.publicIp || '-';
}
private getPortsHtml(ports: number[]): TemplateResult {
return html`<div class="portsDisplay">${ports.map(p => html`<span class="portBadge">${p}</span>`)}</div>`;
}
private getEdgeTunnelCount(edgeId: string): number {
const status = this.getEdgeStatus(edgeId);
return status?.activeTunnels || 0;
}
private getLastHeartbeat(edgeId: string): string {
const status = this.getEdgeStatus(edgeId);
if (!status?.lastHeartbeat) return '-';
const ago = Date.now() - status.lastHeartbeat;
if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`;
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
return `${Math.floor(ago / 3600000)}h ago`;
}
}