Compare commits

..

4 Commits
v6.7.0 ... main

Author SHA1 Message Date
ac3a42fc41 v6.9.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 16:28:33 +00:00
c23f16149c feat(certificates): add certificate import, export, and deletion support (server handlers, request types, and UI) 2026-02-17 16:28:33 +00:00
529a4bae00 v6.8.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-17 14:17:18 +00:00
49606ae007 feat(remote-ingress): support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI 2026-02-17 14:17:18 +00:00
13 changed files with 637 additions and 23 deletions

View File

@@ -1,5 +1,23 @@
# Changelog
## 2026-02-17 - 6.9.0 - feat(certificates)
add certificate import, export, and deletion support (server handlers, request types, and UI)
- Add typed request handlers in opsserver: deleteCertificate, exportCertificate, importCertificate (ts/opsserver/handlers/certificate.handler.ts)
- Implement deleteCertificate/exportCertificate/importCertificate functions handling storage paths, in-memory status map updates, backoff clearing, validation, and SmartAcme-compatible /certs/ and /proxy-certs/ formats
- Add request interfaces IReq_DeleteCertificate, IReq_ExportCertificate, IReq_ImportCertificate (ts_interfaces/requests/certificate.ts)
- Add web app actions deleteCertificateAction, importCertificateAction and fetchCertificateExport to call new typed requests (ts_web/appstate.ts)
- Update certificates UI to support Import, Export, and Delete actions and add downloadJsonFile helper (ts_web/elements/ops-view-certificates.ts)
## 2026-02-17 - 6.8.0 - feat(remote-ingress)
support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI
- Add autoDerivePorts flag to IRemoteIngress with default true and migration to set existing stored edges to autoDerivePorts = true
- RemoteIngressManager: getEffectiveListenPorts now returns the union of manual + derived ports when autoDerivePorts is enabled; added getPortBreakdown to return manual vs derived lists
- API handlers updated: create/update requests accept autoDerivePorts; responses now include effectiveListenPorts, manualPorts, and derivedPorts (secrets still masked)
- Web UI updated: create and edit dialogs include an Auto-derive checkbox; port badges now visually distinguish manual vs derived ports; added updateRemoteIngressAction
- Non-breaking change: new field defaults to true so existing behavior is preserved
## 2026-02-17 - 6.7.0 - feat(remote-ingress)
Support auto-derived effective listen ports, make listenPorts optional, add toggle action and refine remote ingress creation/management UI

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "6.7.0",
"version": "6.9.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '6.7.0',
version: '6.9.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -42,6 +42,36 @@ export class CertificateHandler {
}
)
);
// Delete certificate
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
'deleteCertificate',
async (dataArg) => {
return this.deleteCertificate(dataArg.domain);
}
)
);
// Export certificate
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
'exportCertificate',
async (dataArg) => {
return this.exportCertificate(dataArg.domain);
}
)
);
// Import certificate
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
'importCertificate',
async (dataArg) => {
return this.importCertificate(dataArg.cert);
}
)
);
}
/**
@@ -324,4 +354,154 @@ export class CertificateHandler {
return { success: false, message: `No routes found for domain '${domain}'` };
}
/**
* Delete certificate data for a domain from storage
*/
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
// Delete from all known storage paths
const paths = [
`/proxy-certs/${domain}`,
`/proxy-certs/${cleanDomain}`,
`/certs/${cleanDomain}`,
];
for (const path of paths) {
try {
await dcRouter.storageManager.delete(path);
} catch {
// Path may not exist — ignore
}
}
// Clear from in-memory status map
dcRouter.certificateStatusMap.delete(domain);
// Clear backoff info
if (dcRouter.certProvisionScheduler) {
await dcRouter.certProvisionScheduler.clearBackoff(domain);
}
return { success: true, message: `Certificate data deleted for '${domain}'` };
}
/**
* Export certificate data for a domain as ICert-shaped JSON
*/
private async exportCertificate(domain: string): Promise<{
success: boolean;
cert?: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
message?: string;
}> {
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, '');
// Try SmartAcme /certs/ path first (has full ICert fields)
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certData && certData.publicKey && certData.privateKey) {
return {
success: true,
cert: {
id: certData.id || plugins.crypto.randomUUID(),
domainName: certData.domainName || domain,
created: certData.created || Date.now(),
validUntil: certData.validUntil || 0,
privateKey: certData.privateKey,
publicKey: certData.publicKey,
csr: certData.csr || '',
},
};
}
// Fallback: try /proxy-certs/ with original domain
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
if (!certData || !certData.publicKey) {
// Try with clean domain
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
}
if (certData && certData.publicKey && certData.privateKey) {
return {
success: true,
cert: {
id: plugins.crypto.randomUUID(),
domainName: domain,
created: certData.validFrom || Date.now(),
validUntil: certData.validUntil || 0,
privateKey: certData.privateKey,
publicKey: certData.publicKey,
csr: '',
},
};
}
return { success: false, message: `No certificate data found for '${domain}'` };
}
/**
* Import a certificate from ICert-shaped JSON
*/
private async importCertificate(cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
}): Promise<{ success: boolean; message?: string }> {
// Validate PEM content
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
}
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
}
const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
// Save to /certs/ (SmartAcme-compatible path)
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
validUntil: cert.validUntil,
privateKey: cert.privateKey,
publicKey: cert.publicKey,
csr: cert.csr || '',
});
// Also save to /proxy-certs/ (proxy-cert format)
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
domain: cert.domainName,
publicKey: cert.publicKey,
privateKey: cert.privateKey,
ca: undefined,
validUntil: cert.validUntil,
validFrom: cert.created,
});
// Update in-memory status map
dcRouter.certificateStatusMap.set(cert.domainName, {
status: 'valid',
source: 'static',
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
routeNames: [],
});
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
}
}

View File

@@ -20,12 +20,17 @@ export class RemoteIngressHandler {
if (!manager) {
return { edges: [] };
}
// Return edges without secrets, enriched with effective listen ports
const edges = manager.getAllEdges().map((e) => ({
...e,
secret: '********', // Never expose secrets via API
effectiveListenPorts: manager.getEffectiveListenPorts(e),
}));
// Return edges without secrets, enriched with effective listen ports and breakdown
const edges = manager.getAllEdges().map((e) => {
const breakdown = manager.getPortBreakdown(e);
return {
...e,
secret: '********', // Never expose secrets via API
effectiveListenPorts: manager.getEffectiveListenPorts(e),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
};
});
return { edges };
},
),
@@ -50,6 +55,7 @@ export class RemoteIngressHandler {
dataArg.name,
dataArg.listenPorts || [],
dataArg.tags,
dataArg.autoDerivePorts ?? true,
);
// Sync allowed edges with the hub
@@ -102,6 +108,7 @@ export class RemoteIngressHandler {
const edge = await manager.updateEdge(dataArg.id, {
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
enabled: dataArg.enabled,
tags: dataArg.tags,
});
@@ -115,7 +122,17 @@ export class RemoteIngressHandler {
await tunnelManager.syncAllowedEdges();
}
return { success: true, edge: { ...edge, secret: '********' } };
const breakdown = manager.getPortBreakdown(edge);
return {
success: true,
edge: {
...edge,
secret: '********',
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
},
};
},
),
);

View File

@@ -47,6 +47,11 @@ export class RemoteIngressManager {
for (const key of keys) {
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
if (edge) {
// Migration: old edges without autoDerivePorts default to true
if ((edge as any).autoDerivePorts === undefined) {
edge.autoDerivePorts = true;
await this.storageManager.setJSON(key, edge);
}
this.edges.set(edge.id, edge);
}
}
@@ -91,13 +96,28 @@ export class RemoteIngressManager {
/**
* Get the effective listen ports for an edge.
* Returns manual listenPorts if non-empty, otherwise derives ports from tagged routes.
* Manual ports are always included. Auto-derived ports are added (union) when autoDerivePorts is true.
*/
public getEffectiveListenPorts(edge: IRemoteIngress): number[] {
if (edge.listenPorts && edge.listenPorts.length > 0) {
return edge.listenPorts;
}
return this.derivePortsForEdge(edge.id, edge.tags);
const manualPorts = edge.listenPorts || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return [...manualPorts].sort((a, b) => a - b);
const derivedPorts = this.derivePortsForEdge(edge.id, edge.tags);
return [...new Set([...manualPorts, ...derivedPorts])].sort((a, b) => a - b);
}
/**
* Get manual and derived port breakdown for an edge (used in API responses).
* Derived ports exclude any ports already present in the manual list.
*/
public getPortBreakdown(edge: IRemoteIngress): { manual: number[]; derived: number[] } {
const manual = edge.listenPorts || [];
const shouldDerive = edge.autoDerivePorts !== false;
if (!shouldDerive) return { manual, derived: [] };
const manualSet = new Set(manual);
const allDerived = this.derivePortsForEdge(edge.id, edge.tags);
const derived = allDerived.filter((p) => !manualSet.has(p));
return { manual, derived };
}
/**
@@ -107,6 +127,7 @@ export class RemoteIngressManager {
name: string,
listenPorts: number[] = [],
tags?: string[],
autoDerivePorts: boolean = true,
): Promise<IRemoteIngress> {
const id = plugins.uuid.v4();
const secret = plugins.crypto.randomBytes(32).toString('hex');
@@ -118,6 +139,7 @@ export class RemoteIngressManager {
secret,
listenPorts,
enabled: true,
autoDerivePorts,
tags: tags || [],
createdAt: now,
updatedAt: now,
@@ -150,6 +172,7 @@ export class RemoteIngressManager {
updates: {
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
tags?: string[];
},
@@ -161,6 +184,7 @@ export class RemoteIngressManager {
if (updates.name !== undefined) edge.name = updates.name;
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();

View File

@@ -9,11 +9,17 @@ export interface IRemoteIngress {
secret: string;
listenPorts: number[];
enabled: boolean;
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
autoDerivePorts: boolean;
tags?: string[];
createdAt: number;
updatedAt: number;
/** Effective ports derived from route configs — only present in API responses. */
/** Effective ports (union of manual + derived) — only present in API responses. */
effectiveListenPorts?: number[];
/** Ports explicitly set by the user — only present in API responses. */
manualPorts?: number[];
/** Ports auto-derived from route configs — only present in API responses. */
derivedPorts?: number[];
}
/**

View File

@@ -74,3 +74,68 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
message?: string;
};
}
// Delete a certificate by domain
export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteCertificate
> {
method: 'deleteCertificate';
request: {
identity?: authInterfaces.IIdentity;
domain: string;
};
response: {
success: boolean;
message?: string;
};
}
// Export a certificate as ICert JSON
export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ExportCertificate
> {
method: 'exportCertificate';
request: {
identity?: authInterfaces.IIdentity;
domain: string;
};
response: {
success: boolean;
cert?: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
message?: string;
};
}
// Import a certificate from ICert JSON
export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ImportCertificate
> {
method: 'importCertificate';
request: {
identity?: authInterfaces.IIdentity;
cert: {
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
};
};
response: {
success: boolean;
message?: string;
};
}

View File

@@ -18,6 +18,7 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
identity?: authInterfaces.IIdentity;
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
tags?: string[];
};
response: {
@@ -57,6 +58,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
id: string;
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
tags?: string[];
};

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '6.7.0',
version: '6.9.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -780,6 +780,80 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
}
);
export const deleteCertificateAction = certificateStatePart.createAction<string>(
async (statePartArg, domain) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteCertificate
>('/typedrequest', 'deleteCertificate');
await request.fire({
identity: context.identity,
domain,
});
// Re-fetch overview after deletion
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete certificate',
};
}
}
);
export const importCertificateAction = certificateStatePart.createAction<{
id: string;
domainName: string;
created: number;
validUntil: number;
privateKey: string;
publicKey: string;
csr: string;
}>(
async (statePartArg, cert) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ImportCertificate
>('/typedrequest', 'importCertificate');
await request.fire({
identity: context.identity,
cert,
});
// Re-fetch overview after import
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to import certificate',
};
}
}
);
export async function fetchCertificateExport(domain: string) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ExportCertificate
>('/typedrequest', 'exportCertificate');
return request.fire({
identity: context.identity,
domain,
});
}
// ============================================================================
// Remote Ingress Actions
// ============================================================================
@@ -822,6 +896,7 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn
export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
tags?: string[];
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
@@ -836,6 +911,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
identity: context.identity,
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
tags: dataArg.tags,
});
@@ -883,6 +959,40 @@ export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<str
}
);
export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
id: string;
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
tags?: string[];
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateRemoteIngress
>('/typedrequest', 'updateRemoteIngress');
await request.fire({
identity: context.identity,
id: dataArg.id,
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
tags: dataArg.tags,
});
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to update edge',
};
}
});
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
async (statePartArg, edgeId) => {
const context = getActionContext();

View File

@@ -241,6 +241,61 @@ export class OpsViewCertificates extends DeesElement {
: '',
})}
.dataActions=${[
{
name: 'Import Certificate',
iconName: 'lucide:upload',
type: ['header'],
actionFunc: async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Import Certificate',
content: html`
<dees-form>
<dees-input-fileupload
key="certJsonFile"
label="Certificate JSON (.tsclass.cert.json)"
accept=".json"
.multiple=${false}
required
></dees-input-fileupload>
</dees-form>
`,
menuOptions: [
{
name: 'Import',
iconName: 'lucide:upload',
action: async (modal) => {
const { DeesToast } = await import('@design.estate/dees-catalog');
try {
const form = modal.shadowRoot.querySelector('dees-form') as any;
const formData = await form.collectFormData();
const files = formData.certJsonFile;
if (!files || files.length === 0) {
DeesToast.show({ message: 'Please select a JSON file.', type: 'warning', duration: 3000 });
return;
}
const file = files[0];
const text = await file.text();
const cert = JSON.parse(text);
if (!cert.domainName || !cert.publicKey || !cert.privateKey) {
DeesToast.show({ message: 'Invalid cert JSON: missing domainName, publicKey, or privateKey.', type: 'error', duration: 4000 });
return;
}
await appstate.certificateStatePart.dispatchAction(
appstate.importCertificateAction,
cert,
);
DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 });
modal.destroy();
} catch (err) {
DeesToast.show({ message: `Import failed: ${err.message}`, type: 'error', duration: 4000 });
}
},
},
],
});
},
},
{
name: 'Reprovision',
iconName: 'lucide:RefreshCw',
@@ -268,6 +323,63 @@ export class OpsViewCertificates extends DeesElement {
});
},
},
{
name: 'Export',
iconName: 'lucide:download',
type: ['contextmenu'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const { DeesToast } = await import('@design.estate/dees-catalog');
const cert = actionData.item;
try {
const response = await appstate.fetchCertificateExport(cert.domain);
if (response.success && response.cert) {
const safeDomain = cert.domain.replace(/\*/g, '_wildcard');
this.downloadJsonFile(`${safeDomain}.tsclass.cert.json`, response.cert);
DeesToast.show({ message: `Certificate exported for ${cert.domain}`, type: 'success', duration: 3000 });
} else {
DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 });
}
} catch (err) {
DeesToast.show({ message: `Export failed: ${err.message}`, type: 'error', duration: 4000 });
}
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
type: ['contextmenu'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const cert = actionData.item;
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Delete Certificate: ${cert.domain}`,
content: html`
<div style="padding: 20px; font-size: 14px;">
<p>Are you sure you want to delete the certificate data for <strong>${cert.domain}</strong>?</p>
<p style="color: #f59e0b; margin-top: 12px;">Note: The certificate may remain in proxy memory until the next restart or reprovisioning.</p>
</div>
`,
menuOptions: [
{
name: 'Delete',
iconName: 'lucide:trash-2',
action: async (modal) => {
try {
await appstate.certificateStatePart.dispatchAction(
appstate.deleteCertificateAction,
cert.domain,
);
DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 });
modal.destroy();
} catch (err) {
DeesToast.show({ message: `Delete failed: ${err.message}`, type: 'error', duration: 4000 });
}
},
},
],
});
},
},
{
name: 'View Details',
iconName: 'lucide:Search',
@@ -309,6 +421,19 @@ export class OpsViewCertificates extends DeesElement {
`;
}
private downloadJsonFile(filename: string, data: any): void {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
private renderRoutePills(routeNames: string[]): TemplateResult {
const maxShow = 3;
const visible = routeNames.slice(0, maxShow);

View File

@@ -114,6 +114,17 @@ export class OpsViewRemoteIngress extends DeesElement {
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')};
}
`,
];
@@ -203,7 +214,8 @@ export class OpsViewRemoteIngress extends DeesElement {
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, auto-derived if empty)'}></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>
`,
@@ -226,12 +238,13 @@ export class OpsViewRemoteIngress extends DeesElement {
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, tags },
{ name, listenPorts, autoDerivePorts, tags },
);
await modalArg.destroy();
},
@@ -266,6 +279,61 @@ export class OpsViewRemoteIngress extends DeesElement {
);
},
},
{
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',
@@ -317,13 +385,12 @@ export class OpsViewRemoteIngress extends DeesElement {
}
private getPortsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
const hasManualPorts = edge.listenPorts && edge.listenPorts.length > 0;
const ports = hasManualPorts ? edge.listenPorts : (edge.effectiveListenPorts || []);
const isAuto = !hasManualPorts && ports.length > 0;
if (ports.length === 0) {
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">${ports.map(p => html`<span class="portBadge">${p}</span>`)}${isAuto ? html`<span style="font-size: 11px; color: var(--text-muted, #6b7280); align-self: center;">(auto)</span>` : ''}</div>`;
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 {