BREAKING CHANGE(opsserver): Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections

This commit is contained in:
2026-02-23 21:34:50 +00:00
parent 2ebe0de92d
commit 4d6ac81c59
18 changed files with 638 additions and 413 deletions

View File

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

View File

@@ -21,7 +21,7 @@ export interface IStatsState {
}
export interface IConfigState {
config: any | null;
config: interfaces.requests.IConfigData | null;
isLoading: boolean;
error: string | null;
}

View File

@@ -45,6 +45,10 @@ export class OpsDashboard extends DeesElement {
name: 'Overview',
element: OpsViewOverview,
},
{
name: 'Configuration',
element: OpsViewConfig,
},
{
name: 'Network',
element: OpsViewNetwork,
@@ -65,10 +69,6 @@ export class OpsDashboard extends DeesElement {
name: 'ApiTokens',
element: OpsViewApiTokens,
},
{
name: 'Configuration',
element: OpsViewConfig,
},
{
name: 'Security',
element: OpsViewSecurity,

View File

@@ -225,13 +225,17 @@ export class OpsViewApiTokens extends DeesElement {
name: 'Create',
iconName: 'lucide:key',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
const contentEl = modalArg.shadowRoot?.querySelector('.content');
const form = contentEl?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
if (!formData.name) return;
// dees-input-tags returns string[] directly
const scopes = (formData.scopes || [])
// dees-input-tags is not in dees-form's FORM_INPUT_TYPES, so collectFormData() won't
// include it. Query the tags input directly and call getValue().
const tagsInput = form.querySelector('dees-input-tags') as any;
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
const scopes = rawScopes
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
const expiresInDays = formData.expiresInDays

View File

@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js';
import * as shared from './shared/index.js';
import * as appstate from '../appstate.js';
import { appRouter } from '../router.js';
import {
DeesElement,
@@ -12,6 +13,8 @@ import {
type TemplateResult,
} from '@design.estate/dees-element';
import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog';
@customElement('ops-view-config')
export class OpsViewConfig extends DeesElement {
@state()
@@ -35,165 +38,19 @@ export class OpsViewConfig extends DeesElement {
cssManager.defaultStyles,
shared.viewHostCss,
css`
.configSection {
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
margin-bottom: 24px;
overflow: hidden;
}
.sectionHeader {
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
padding: 16px 24px;
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
display: flex;
justify-content: space-between;
align-items: center;
}
.sectionTitle {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
display: flex;
align-items: center;
gap: 12px;
}
.sectionTitle dees-icon {
font-size: 20px;
color: ${cssManager.bdTheme('#666', '#888')};
}
.sectionContent {
padding: 24px;
}
.configField {
margin-bottom: 20px;
}
.configField:last-child {
margin-bottom: 0;
}
.fieldLabel {
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#666', '#999')};
margin-bottom: 8px;
display: block;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fieldValue {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
color: ${cssManager.bdTheme('#333', '#ccc')};
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
padding: 10px 14px;
border-radius: 6px;
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.fieldValue.empty {
color: ${cssManager.bdTheme('#999', '#666')};
font-style: italic;
}
.nestedFields {
margin-left: 16px;
padding-left: 16px;
border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
/* Status badge styles */
.statusBadge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.statusBadge.enabled {
background: ${cssManager.bdTheme('#d4edda', '#1a3d1a')};
color: ${cssManager.bdTheme('#155724', '#66cc66')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#f8d7da', '#3d1a1a')};
color: ${cssManager.bdTheme('#721c24', '#cc6666')};
}
.statusBadge dees-icon {
font-size: 14px;
}
/* Array/list display */
.arrayItems {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.arrayItem {
display: inline-flex;
align-items: center;
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
padding: 4px 12px;
border-radius: 16px;
font-size: 13px;
font-family: 'Consolas', 'Monaco', monospace;
}
.arrayCount {
font-size: 12px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-bottom: 8px;
}
/* Numeric value formatting */
.numericValue {
font-weight: 600;
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
}
.errorMessage {
background: ${cssManager.bdTheme('#fee', '#4a1f1f')};
border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')};
border-radius: 4px;
padding: 16px;
color: ${cssManager.bdTheme('#c00', '#ff6666')};
margin: 16px 0;
}
.loadingMessage {
text-align: center;
padding: 40px;
color: ${cssManager.bdTheme('#666', '#999')};
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
.infoNote {
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')};
.errorMessage {
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.1)')};
border: 1px solid ${cssManager.bdTheme('#fecaca', 'rgba(239,68,68,0.3)')};
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
color: ${cssManager.bdTheme('#004085', '#88ccff')};
display: flex;
align-items: center;
gap: 12px;
}
.infoNote dees-icon {
font-size: 20px;
flex-shrink: 0;
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
margin: 16px 0;
}
`,
];
@@ -202,185 +59,266 @@ export class OpsViewConfig extends DeesElement {
return html`
<ops-sectionheading>Configuration</ops-sectionheading>
${this.configState.isLoading ? html`
<div class="loadingMessage">
<dees-spinner></dees-spinner>
<p>Loading configuration...</p>
</div>
` : this.configState.error ? html`
<div class="errorMessage">
Error loading configuration: ${this.configState.error}
</div>
` : this.configState.config ? html`
<div class="infoNote">
<dees-icon icon="lucide:info"></dees-icon>
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span>
</div>
${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)}
${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)}
${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)}
${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)}
` : html`
<div class="errorMessage">No configuration loaded</div>
`}
`;
}
private renderConfigSection(key: string, title: string, icon: string, config: any) {
const isEnabled = config?.enabled ?? false;
return html`
<div class="configSection">
<div class="sectionHeader">
<h3 class="sectionTitle">
<dees-icon icon="${icon}"></dees-icon>
${title}
</h3>
${this.renderStatusBadge(isEnabled)}
</div>
<div class="sectionContent">
${config ? this.renderConfigFields(config) : html`
<div class="fieldValue empty">Not configured</div>
`}
</div>
</div>
`;
}
private renderStatusBadge(enabled: boolean): TemplateResult {
return enabled
? html`<span class="statusBadge enabled"><dees-icon icon="lucide:check"></dees-icon>Enabled</span>`
: html`<span class="statusBadge disabled"><dees-icon icon="lucide:x"></dees-icon>Disabled</span>`;
}
private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
if (!config || typeof config !== 'object') {
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
}
return Object.entries(config).map(([key, value]) => {
const fieldName = prefix ? `${prefix}.${key}` : key;
const displayName = this.formatFieldName(key);
// Handle boolean values with badges
if (typeof value === 'boolean') {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
${this.renderStatusBadge(value)}
</div>
`;
}
// Handle arrays
if (Array.isArray(value)) {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
${this.renderArrayValue(value, key)}
</div>
`;
}
// Handle nested objects
if (typeof value === 'object' && value !== null) {
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
<div class="nestedFields">
${this.renderConfigFields(value, fieldName)}
${this.configState.isLoading
? html`
<div class="loadingMessage">
<dees-spinner></dees-spinner>
<p>Loading configuration...</p>
</div>
</div>
`;
}
// Handle primitive values
return html`
<div class="configField">
<label class="fieldLabel">${displayName}</label>
<div class="fieldValue">${this.formatValue(value, key)}</div>
</div>
`;
});
}
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult {
if (arr.length === 0) {
return html`<div class="fieldValue empty">None configured</div>`;
}
// Determine if we should show as pills/tags
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number');
if (showAsPills) {
const itemLabel = this.getArrayItemLabel(fieldKey, arr.length);
return html`
<div class="arrayCount">${arr.length} ${itemLabel}</div>
<div class="arrayItems">
${arr.map(item => html`<span class="arrayItem">${item}</span>`)}
</div>
`;
}
// For complex arrays, show as JSON
return html`
<div class="fieldValue">
${arr.length} items configured
</div>
`
: this.configState.error
? html`
<div class="errorMessage">
Error loading configuration: ${this.configState.error}
</div>
`
: this.configState.config
? this.renderConfig()
: html`<div class="errorMessage">No configuration loaded</div>`}
`;
}
private getArrayItemLabel(fieldKey: string, count: number): string {
const labels: Record<string, [string, string]> = {
ports: ['port', 'ports'],
domains: ['domain', 'domains'],
nameservers: ['nameserver', 'nameservers'],
blockList: ['IP', 'IPs'],
};
private renderConfig(): TemplateResult {
const cfg = this.configState.config!;
const label = labels[fieldKey] || ['item', 'items'];
return count === 1 ? label[0] : label[1];
return html`
<sz-config-overview
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
@navigate=${(e: CustomEvent) => {
if (e.detail?.view) {
appRouter.navigateToView(e.detail.view);
}
}}
>
${this.renderSystemSection(cfg.system)}
${this.renderSmartProxySection(cfg.smartProxy)}
${this.renderEmailSection(cfg.email)}
${this.renderDnsSection(cfg.dns)}
${this.renderTlsSection(cfg.tls)}
${this.renderCacheSection(cfg.cache)}
${this.renderRadiusSection(cfg.radius)}
${this.renderRemoteIngressSection(cfg.remoteIngress)}
</sz-config-overview>
`;
}
private formatFieldName(key: string): string {
// Convert camelCase to readable format
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Base Directory', value: sys.baseDir },
{ key: 'Data Directory', value: sys.dataDir },
{ key: 'Public IP', value: sys.publicIp },
{ key: 'Proxy IPs', value: sys.proxyIps.length > 0 ? sys.proxyIps : null, type: 'pills' },
{ key: 'Uptime', value: this.formatUptime(sys.uptime) },
{ key: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
{ key: 'Storage Path', value: sys.storagePath },
];
return html`
<sz-config-section
title="System"
subtitle="Base paths and infrastructure"
icon="lucide:server"
status="enabled"
.fields=${fields}
></sz-config-section>
`;
}
private formatValue(value: any, fieldKey?: string): string | TemplateResult {
if (value === null || value === undefined) {
return html`<span class="empty">Not set</span>`;
private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Route Count', value: proxy.routeCount },
];
if (proxy.acme) {
fields.push(
{ key: 'ACME Enabled', value: proxy.acme.enabled, type: 'boolean' },
{ key: 'Account Email', value: proxy.acme.accountEmail || null },
{ key: 'Use Production', value: proxy.acme.useProduction, type: 'boolean' },
{ key: 'Auto Renew', value: proxy.acme.autoRenew, type: 'boolean' },
{ key: 'Renew Threshold', value: `${proxy.acme.renewThresholdDays} days` },
);
}
if (typeof value === 'number') {
// Format bytes
if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) {
return html`<span class="numericValue">${this.formatBytes(value)}</span>`;
}
// Format time values
if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) {
return html`<span class="numericValue">${value} seconds</span>`;
}
// Format port numbers
if (fieldKey?.toLowerCase().includes('port')) {
return html`<span class="numericValue">${value}</span>`;
}
// Format counts with separators
return html`<span class="numericValue">${value.toLocaleString()}</span>`;
}
const actions: IConfigSectionAction[] = [
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
];
return String(value);
return html`
<sz-config-section
title="SmartProxy"
subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
icon="lucide:network"
.status=${proxy.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
{ key: 'Hostname', value: email.hostname },
{ key: 'Domains', value: email.domains.length > 0 ? email.domains : null, type: 'pills' },
{ key: 'Email Routes', value: email.emailRouteCount },
{ key: 'Received Emails Path', value: email.receivedEmailsPath },
];
if (email.portMapping) {
const mappingStr = Object.entries(email.portMapping)
.map(([ext, int]) => `${ext}${int}`)
.join(', ');
fields.splice(1, 0, { key: 'Port Mapping', value: mappingStr, type: 'code' });
}
const actions: IConfigSectionAction[] = [
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
];
return html`
<sz-config-section
title="Email Server"
subtitle="SMTP email handling with smartmta"
icon="lucide:mail"
.status=${email.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
}
private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Port', value: dns.port },
{ key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' },
{ key: 'Scopes', value: dns.scopes.length > 0 ? dns.scopes : null, type: 'pills' },
{ key: 'Record Count', value: dns.recordCount },
{ key: 'DNS Challenge', value: dns.dnsChallenge, type: 'boolean' },
];
return html`
<sz-config-section
title="DNS Server"
subtitle="Authoritative DNS with smartdns"
icon="lucide:globe"
.status=${dns.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
></sz-config-section>
`;
}
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Contact Email', value: tls.contactEmail },
{ key: 'Domain', value: tls.domain },
{ key: 'Source', value: tls.source, type: 'badge' },
{ key: 'Certificate Path', value: tls.certPath },
{ key: 'Key Path', value: tls.keyPath },
];
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
const actions: IConfigSectionAction[] = [
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
];
return html`
<sz-config-section
title="TLS / Certificates"
subtitle="Certificate management and ACME"
icon="lucide:shield-check"
.status=${status as any}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
}
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Storage Path', value: cache.storagePath },
{ key: 'DB Name', value: cache.dbName },
{ key: 'Default TTL', value: `${cache.defaultTTLDays} days` },
{ key: 'Cleanup Interval', value: `${cache.cleanupIntervalHours} hours` },
];
if (cache.ttlConfig && Object.keys(cache.ttlConfig).length > 0) {
for (const [key, val] of Object.entries(cache.ttlConfig)) {
fields.push({ key: `TTL: ${key}`, value: `${val} days` });
}
}
return html`
<sz-config-section
title="Cache Database"
subtitle="Persistent caching with smartdata"
icon="lucide:database"
.status=${cache.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
></sz-config-section>
`;
}
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Auth Port', value: radius.authPort },
{ key: 'Accounting Port', value: radius.acctPort },
{ key: 'Bind Address', value: radius.bindAddress },
{ key: 'Client Count', value: radius.clientCount },
];
if (radius.vlanDefaultVlan !== null) {
fields.push(
{ key: 'Default VLAN', value: radius.vlanDefaultVlan },
{ key: 'Allow Unknown MACs', value: radius.vlanAllowUnknownMacs, type: 'boolean' },
{ key: 'VLAN Mappings', value: radius.vlanMappingCount },
);
}
const status = radius.enabled ? 'enabled' : 'not-configured';
return html`
<sz-config-section
title="RADIUS Server"
subtitle="Network authentication and VLAN assignment"
icon="lucide:wifi"
.status=${status as any}
.fields=${fields}
></sz-config-section>
`;
}
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult {
const fields: IConfigField[] = [
{ key: 'Tunnel Port', value: ri.tunnelPort },
{ key: 'Hub Domain', value: ri.hubDomain },
{ key: 'TLS Configured', value: ri.tlsConfigured, type: 'boolean' },
];
const actions: IConfigSectionAction[] = [
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
];
return html`
<sz-config-section
title="Remote Ingress"
subtitle="Edge tunnel nodes"
icon="lucide:cloud"
.status=${ri.enabled ? 'enabled' : 'disabled'}
.fields=${fields}
.actions=${actions}
></sz-config-section>
`;
}
private formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
}
}