410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
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')};
|
|
}
|
|
|
|
.portBadge.manual {
|
|
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
|
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
|
}
|
|
|
|
.portBadge.derived {
|
|
background: ${cssManager.bdTheme('#ecfdf5', '#022c22')};
|
|
color: ${cssManager.bdTheme('#047857', '#34d399')};
|
|
border: 1px dashed ${cssManager.bdTheme('#6ee7b7', '#065f46')};
|
|
}
|
|
`,
|
|
];
|
|
|
|
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),
|
|
tunnels: this.getEdgeTunnelCount(edge.id),
|
|
lastHeartbeat: this.getLastHeartbeat(edge.id),
|
|
})}
|
|
.dataActions=${[
|
|
{
|
|
name: 'Create Edge Node',
|
|
iconName: 'lucide:plus',
|
|
type: ['header'],
|
|
actionFunc: async () => {
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
const modal = 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=${'Additional Manual Ports (comma-separated, optional)'}></dees-input-text>
|
|
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
|
|
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
|
|
</dees-form>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
},
|
|
{
|
|
name: 'Create',
|
|
iconName: 'lucide:plus',
|
|
action: async (modalArg: any) => {
|
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
|
if (!form) return;
|
|
const formData = await form.collectFormData();
|
|
const name = formData.name;
|
|
if (!name) return;
|
|
const portsStr = formData.listenPorts?.trim();
|
|
const listenPorts = portsStr
|
|
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
|
: undefined;
|
|
const autoDerivePorts = formData.autoDerivePorts !== false;
|
|
const tags = formData.tags
|
|
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
|
: undefined;
|
|
await appstate.remoteIngressStatePart.dispatchAction(
|
|
appstate.createRemoteIngressAction,
|
|
{ name, listenPorts, autoDerivePorts, tags },
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: 'Enable',
|
|
iconName: 'lucide:play',
|
|
type: ['inRow', 'contextmenu'] as any,
|
|
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
|
actionFunc: async (actionData: any) => {
|
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
|
await appstate.remoteIngressStatePart.dispatchAction(
|
|
appstate.toggleRemoteIngressAction,
|
|
{ id: edge.id, enabled: true },
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: 'Disable',
|
|
iconName: 'lucide:pause',
|
|
type: ['inRow', 'contextmenu'] as any,
|
|
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
|
actionFunc: async (actionData: any) => {
|
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
|
await appstate.remoteIngressStatePart.dispatchAction(
|
|
appstate.toggleRemoteIngressAction,
|
|
{ id: edge.id, enabled: false },
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: 'Edit',
|
|
iconName: 'lucide:pencil',
|
|
type: ['inRow', 'contextmenu'] as any,
|
|
actionFunc: async (actionData: any) => {
|
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
await DeesModal.createAndShow({
|
|
heading: `Edit Edge: ${edge.name}`,
|
|
content: html`
|
|
<dees-form>
|
|
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
|
|
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports (comma-separated)'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
|
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
|
|
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated)'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
|
</dees-form>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
},
|
|
{
|
|
name: 'Save',
|
|
iconName: 'lucide:check',
|
|
action: async (modalArg: any) => {
|
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
|
if (!form) return;
|
|
const formData = await form.collectFormData();
|
|
const portsStr = formData.listenPorts?.trim();
|
|
const listenPorts = portsStr
|
|
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
|
|
: [];
|
|
const autoDerivePorts = formData.autoDerivePorts !== false;
|
|
const tags = formData.tags
|
|
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
|
: [];
|
|
await appstate.remoteIngressStatePart.dispatchAction(
|
|
appstate.updateRemoteIngressAction,
|
|
{
|
|
id: edge.id,
|
|
name: formData.name || edge.name,
|
|
listenPorts,
|
|
autoDerivePorts,
|
|
tags,
|
|
},
|
|
);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: 'Regenerate Secret',
|
|
iconName: 'lucide:key',
|
|
type: ['inRow', 'contextmenu'] as any,
|
|
actionFunc: async (actionData: any) => {
|
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
|
await appstate.remoteIngressStatePart.dispatchAction(
|
|
appstate.regenerateRemoteIngressSecretAction,
|
|
edge.id,
|
|
);
|
|
},
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash2',
|
|
type: ['inRow', 'contextmenu'] as any,
|
|
actionFunc: async (actionData: any) => {
|
|
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
|
await appstate.remoteIngressStatePart.dispatchAction(
|
|
appstate.deleteRemoteIngressAction,
|
|
edge.id,
|
|
);
|
|
},
|
|
},
|
|
]}
|
|
></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(edge: interfaces.data.IRemoteIngress): TemplateResult {
|
|
const manualPorts = edge.manualPorts || [];
|
|
const derivedPorts = edge.derivedPorts || [];
|
|
if (manualPorts.length === 0 && derivedPorts.length === 0) {
|
|
return html`<span style="color: var(--text-muted, #6b7280); font-size: 12px;">none</span>`;
|
|
}
|
|
return html`<div class="portsDisplay">${manualPorts.map(p => html`<span class="portBadge manual">${p}</span>`)}${derivedPorts.map(p => html`<span class="portBadge derived">${p}</span>`)}${derivedPorts.length > 0 ? html`<span style="font-size: 11px; color: var(--text-muted, #6b7280); align-self: center;">(auto)</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`;
|
|
}
|
|
}
|