feat(remoteingress): add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '6.3.0',
|
||||
version: '6.4.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||
// Determine initial view from URL path
|
||||
const getInitialView = (): string => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'];
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'];
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
@@ -192,6 +192,34 @@ export const certificateStatePart = await appState.getStatePart<ICertificateStat
|
||||
'soft'
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Remote Ingress State
|
||||
// ============================================================================
|
||||
|
||||
export interface IRemoteIngressState {
|
||||
edges: interfaces.data.IRemoteIngress[];
|
||||
statuses: interfaces.data.IRemoteIngressStatus[];
|
||||
selectedEdgeId: string | null;
|
||||
newEdgeSecret: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngressState>(
|
||||
'remoteIngress',
|
||||
{
|
||||
edges: [],
|
||||
statuses: [],
|
||||
selectedEdgeId: null,
|
||||
newEdgeSecret: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
'soft'
|
||||
);
|
||||
|
||||
// Actions for state management
|
||||
interface IActionContext {
|
||||
identity: interfaces.data.IIdentity | null;
|
||||
@@ -378,6 +406,13 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to remoteingress view, ensure we fetch edge data
|
||||
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
|
||||
setTimeout(() => {
|
||||
remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
activeView: viewName,
|
||||
@@ -745,6 +780,150 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Remote Ingress Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetRemoteIngresses
|
||||
>('/typedrequest', 'getRemoteIngresses');
|
||||
|
||||
const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetRemoteIngressStatus
|
||||
>('/typedrequest', 'getRemoteIngressStatus');
|
||||
|
||||
const [edgesResponse, statusResponse] = await Promise.all([
|
||||
edgesRequest.fire({ identity: context.identity }),
|
||||
statusRequest.fire({ identity: context.identity }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
edges: edgesResponse.edges,
|
||||
statuses: statusResponse.statuses,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch remote ingress data',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
name: string;
|
||||
listenPorts: number[];
|
||||
tags?: string[];
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateRemoteIngress
|
||||
>('/typedrequest', 'createRemoteIngress');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
name: dataArg.name,
|
||||
listenPorts: dataArg.listenPorts,
|
||||
tags: dataArg.tags,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh the list and store the new secret for display
|
||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
newEdgeSecret: response.edge.secret,
|
||||
};
|
||||
}
|
||||
|
||||
return currentState;
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to create edge',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
|
||||
async (statePartArg, edgeId) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteRemoteIngress
|
||||
>('/typedrequest', 'deleteRemoteIngress');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
id: edgeId,
|
||||
});
|
||||
|
||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete edge',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
|
||||
async (statePartArg, edgeId) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RegenerateRemoteIngressSecret
|
||||
>('/typedrequest', 'regenerateRemoteIngressSecret');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
id: edgeId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
return {
|
||||
...currentState,
|
||||
newEdgeSecret: response.secret,
|
||||
};
|
||||
}
|
||||
|
||||
return currentState;
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to regenerate secret',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const clearNewEdgeSecretAction = remoteIngressStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
newEdgeSecret: null,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Combined refresh action for efficient polling
|
||||
async function dispatchCombinedRefreshAction() {
|
||||
const context = getActionContext();
|
||||
|
||||
@@ -6,4 +6,5 @@ export * from './ops-view-logs.js';
|
||||
export * from './ops-view-config.js';
|
||||
export * from './ops-view-security.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './shared/index.js';
|
||||
@@ -20,6 +20,7 @@ import { OpsViewLogs } from './ops-view-logs.js';
|
||||
import { OpsViewConfig } from './ops-view-config.js';
|
||||
import { OpsViewSecurity } from './ops-view-security.js';
|
||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -66,6 +67,10 @@ export class OpsDashboard extends DeesElement {
|
||||
name: 'Certificates',
|
||||
element: OpsViewCertificates,
|
||||
},
|
||||
{
|
||||
name: 'RemoteIngress',
|
||||
element: OpsViewRemoteIngress,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
290
ts_web/elements/ops-view-remoteingress.ts
Normal file
290
ts_web/elements/ops-view-remoteingress.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
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',
|
||||
action: async (edge: interfaces.data.IRemoteIngress) => {
|
||||
await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.regenerateRemoteIngressSecretAction,
|
||||
edge.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
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`;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'] as const;
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
||||
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
|
||||
Reference in New Issue
Block a user