feat(dns): add db-backed DNS provider, domain, and record management with ops UI support

This commit is contained in:
2026-04-08 11:08:18 +00:00
parent e77fe9451e
commit 21c80e173d
57 changed files with 3753 additions and 65 deletions

View File

@@ -0,0 +1,4 @@
export * from './ops-view-providers.js';
export * from './ops-view-domains.js';
export * from './ops-view-dns.js';
export * from './ops-view-certificates.js';

View File

@@ -0,0 +1,531 @@
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-certificates': OpsViewCertificates;
}
}
@customElement('ops-view-certificates')
export class OpsViewCertificates extends DeesElement {
@state()
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
constructor() {
super();
const sub = appstate.certificateStatePart.select().subscribe((newState) => {
this.certState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.certificatesContainer {
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.valid {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.expiring {
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
}
.statusBadge.expired,
.statusBadge.failed {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.provisioning {
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
}
.statusBadge.unknown {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
}
.sourceBadge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.routePills {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.routePill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
}
.moreCount {
font-size: 11px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
padding: 2px 6px;
}
.errorText {
font-size: 12px;
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.backoffIndicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
padding: 2px 6px;
border-radius: 4px;
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
}
.expiryInfo {
font-size: 12px;
}
.expiryInfo .daysLeft {
font-size: 11px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.expiryInfo .daysLeft.warn {
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
}
.expiryInfo .daysLeft.danger {
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
`,
];
public render(): TemplateResult {
const { summary } = this.certState;
return html`
<dees-heading level="3">Certificates</dees-heading>
<div class="certificatesContainer">
${this.renderStatsTiles(summary)}
${this.renderCertificateTable()}
</div>
`;
}
private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
const tiles: IStatsTile[] = [
{
id: 'total',
title: 'Total Certificates',
value: summary.total,
type: 'number',
icon: 'lucide:ShieldHalf',
color: '#3b82f6',
},
{
id: 'valid',
title: 'Valid',
value: summary.valid,
type: 'number',
icon: 'lucide:Check',
color: '#22c55e',
},
{
id: 'expiring',
title: 'Expiring Soon',
value: summary.expiring,
type: 'number',
icon: 'lucide:Clock',
color: '#f59e0b',
},
{
id: 'problems',
title: 'Failed / Expired',
value: summary.failed + summary.expired,
type: 'number',
icon: 'lucide:TriangleAlert',
color: '#ef4444',
},
];
return html`
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
.gridActions=${[
{
name: 'Refresh',
iconName: 'lucide:RefreshCw',
action: async () => {
await appstate.certificateStatePart.dispatchAction(
appstate.fetchCertificateOverviewAction,
null
);
},
},
]}
></dees-statsgrid>
`;
}
private renderCertificateTable(): TemplateResult {
return html`
<dees-table
.data=${this.certState.certificates}
.showColumnFilters=${true}
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
Domain: cert.domain,
Routes: this.renderRoutePills(cert.routeNames),
Status: this.renderStatusBadge(cert.status),
Source: this.renderSourceBadge(cert.source),
Expires: this.renderExpiry(cert.expiryDate),
Error: cert.backoffInfo
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
: cert.error
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
: '',
})}
.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: any) => {
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: unknown) {
DeesToast.show({ message: `Import failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
}
},
},
],
});
},
},
{
name: 'Reprovision',
iconName: 'lucide:RefreshCw',
type: ['inRow', 'contextmenu'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const cert = actionData.item;
if (!cert.canReprovision) {
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({
message: 'This certificate source does not support reprovisioning.',
type: 'warning',
duration: 3000,
});
return;
}
const doReprovision = async (forceRenew = false) => {
await appstate.certificateStatePart.dispatchAction(
appstate.reprovisionCertificateAction,
{ domain: cert.domain, forceRenew },
);
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({
message: forceRenew
? `Force renewal triggered for ${cert.domain}`
: `Reprovisioning triggered for ${cert.domain}`,
type: 'success',
duration: 3000,
});
};
if (cert.status === 'valid') {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Certificate Still Valid',
content: html`<p style="margin: 0; line-height: 1.5;">The certificate for <strong>${cert.domain}</strong> is still valid${cert.expiryDate ? ` until ${new Date(cert.expiryDate).toLocaleDateString()}` : ''}. Do you want to force renew it now?</p>`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Force Renew',
action: async (modalArg: any) => {
await modalArg.destroy();
await doReprovision(true);
},
},
],
});
} else {
await doReprovision();
}
},
},
{
name: 'Export',
iconName: 'lucide:download',
type: ['inRow', '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: unknown) {
DeesToast.show({ message: `Export failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
}
},
},
{
name: 'Delete',
iconName: 'lucide:trash-2',
type: ['inRow', '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: any) => {
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: unknown) {
DeesToast.show({ message: `Delete failed: ${(err as Error).message}`, type: 'error', duration: 4000 });
}
},
},
],
});
},
},
{
name: 'View Details',
iconName: 'fa:magnifyingGlass',
type: ['doubleClick', 'contextmenu'],
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
const cert = actionData.item;
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: `Certificate: ${cert.domain}`,
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Certificate Details'}
progLang="json"
.codeToDisplay=${JSON.stringify(cert, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Domain',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(cert.domain);
},
},
],
});
},
},
]}
heading1="Certificate Status"
heading2="TLS certificates by domain"
searchable
.pagination=${true}
.paginationSize=${50}
dataName="certificate"
></dees-table>
`;
}
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);
const remaining = routeNames.length - maxShow;
return html`
<span class="routePills">
${visible.map((r) => html`<span class="routePill">${r}</span>`)}
${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
</span>
`;
}
private renderStatusBadge(status: interfaces.requests.TCertificateStatus): TemplateResult {
return html`<span class="statusBadge ${status}">${status}</span>`;
}
private renderSourceBadge(source: interfaces.requests.TCertificateSource): TemplateResult {
const labels: Record<string, string> = {
acme: 'ACME',
'provision-function': 'Custom',
static: 'Static',
none: 'None',
};
return html`<span class="sourceBadge">${labels[source] || source}</span>`;
}
private renderExpiry(expiryDate?: string): TemplateResult {
if (!expiryDate) {
return html`<span style="color: ${cssManager.bdTheme('#9ca3af', '#4b5563')}">--</span>`;
}
const expiry = new Date(expiryDate);
const now = new Date();
const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
const dateStr = expiry.toLocaleDateString();
let daysClass = '';
let daysText = '';
if (daysLeft < 0) {
daysClass = 'danger';
daysText = `(expired)`;
} else if (daysLeft < 30) {
daysClass = 'warn';
daysText = `(${daysLeft}d left)`;
} else {
daysText = `(${daysLeft}d left)`;
}
return html`
<span class="expiryInfo">
${dateStr} <span class="daysLeft ${daysClass}">${daysText}</span>
</span>
`;
}
private formatRetryTime(retryAfter?: string): string {
if (!retryAfter) return 'soon';
const retryDate = new Date(retryAfter);
const now = new Date();
const diffMs = retryDate.getTime() - now.getTime();
if (diffMs <= 0) return 'now';
const diffMin = Math.ceil(diffMs / 60000);
if (diffMin < 60) return `in ${diffMin}m`;
const diffHours = Math.ceil(diffMin / 60);
return `in ${diffHours}h`;
}
}

View File

@@ -0,0 +1,273 @@
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';
declare global {
interface HTMLElementTagNameMap {
'ops-view-dns': OpsViewDns;
}
}
const RECORD_TYPES: interfaces.data.TDnsRecordType[] = [
'A',
'AAAA',
'CNAME',
'MX',
'TXT',
'NS',
'CAA',
];
@customElement('ops-view-dns')
export class OpsViewDns extends DeesElement {
@state()
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
constructor() {
super();
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
this.domainsState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
// If a domain is already selected (e.g. via "View Records" navigation), refresh its records
const selected = this.domainsState.selectedDomainId;
if (selected) {
await appstate.domainsStatePart.dispatchAction(appstate.fetchDnsRecordsForDomainAction, {
domainId: selected,
});
}
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.dnsContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.domainPicker {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
border-radius: 8px;
}
.sourceBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.sourceBadge.manual {
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
}
.sourceBadge.synced {
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
color: ${cssManager.bdTheme('#92400e', '#fde047')};
}
`,
];
public render(): TemplateResult {
const domains = this.domainsState.domains;
const selectedId = this.domainsState.selectedDomainId;
const records = this.domainsState.records;
return html`
<dees-heading level="3">DNS Records</dees-heading>
<div class="dnsContainer">
<div class="domainPicker">
<span>Domain:</span>
<dees-input-dropdown
.options=${domains.map((d) => ({ option: d.name, key: d.id }))}
.selectedOption=${selectedId
? { option: domains.find((d) => d.id === selectedId)?.name || '', key: selectedId }
: undefined}
@selectedOption=${async (e: CustomEvent) => {
const id = (e.detail as any)?.key;
if (!id) return;
await appstate.domainsStatePart.dispatchAction(
appstate.fetchDnsRecordsForDomainAction,
{ domainId: id },
);
}}
></dees-input-dropdown>
</div>
${selectedId
? html`
<dees-table
.heading1=${'DNS Records'}
.heading2=${this.domainHint(selectedId)}
.data=${records}
.showColumnFilters=${true}
.displayFunction=${(r: interfaces.data.IDnsRecord) => ({
Name: r.name,
Type: r.type,
Value: r.value,
TTL: r.ttl,
Source: html`<span class="sourceBadge ${r.source}">${r.source}</span>`,
})}
.dataActions=${[
{
name: 'Add Record',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateRecordDialog(selectedId);
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.domainsStatePart.dispatchAction(
appstate.fetchDnsRecordsForDomainAction,
{ domainId: selectedId },
);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const rec = actionData.item as interfaces.data.IDnsRecord;
await this.showEditRecordDialog(rec);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const rec = actionData.item as interfaces.data.IDnsRecord;
await appstate.domainsStatePart.dispatchAction(
appstate.deleteDnsRecordAction,
{ id: rec.id, domainId: rec.domainId },
);
},
},
]}
></dees-table>
`
: html`<p style="opacity: 0.7;">Pick a domain above to view its records.</p>`}
</div>
`;
}
private domainHint(domainId: string): string {
const domain = this.domainsState.domains.find((d) => d.id === domainId);
if (!domain) return '';
if (domain.source === 'manual') {
return 'Records are served by dcrouter (authoritative).';
}
return 'Records are stored at the provider — changes here are pushed via the provider API.';
}
private async showCreateRecordDialog(domainId: string) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Add DNS Record',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .required=${true}></dees-input-text>
<dees-input-dropdown
.key=${'type'}
.label=${'Type'}
.options=${RECORD_TYPES.map((t) => ({ option: t, key: t }))}
.required=${true}
></dees-input-dropdown>
<dees-input-text
.key=${'value'}
.label=${'Value (for MX use "10 mail.example.com")'}
.required=${true}
></dees-input-text>
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${'300'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const type = (data.type?.key ?? data.type) as interfaces.data.TDnsRecordType;
await appstate.domainsStatePart.dispatchAction(appstate.createDnsRecordAction, {
domainId,
name: String(data.name),
type,
value: String(data.value),
ttl: parseInt(String(data.ttl || '300'), 10),
});
modalArg.destroy();
},
},
],
});
}
private async showEditRecordDialog(rec: interfaces.data.IDnsRecord) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Edit ${rec.type} ${rec.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name (FQDN)'} .value=${rec.name}></dees-input-text>
<dees-input-text .key=${'value'} .label=${'Value'} .value=${rec.value}></dees-input-text>
<dees-input-text .key=${'ttl'} .label=${'TTL (seconds)'} .value=${String(rec.ttl)}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsRecordAction, {
id: rec.id,
domainId: rec.domainId,
name: String(data.name),
value: String(data.value),
ttl: parseInt(String(data.ttl || '300'), 10),
});
modalArg.destroy();
},
},
],
});
}
}

View File

@@ -0,0 +1,335 @@
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 { appRouter } from '../../router.js';
declare global {
interface HTMLElementTagNameMap {
'ops-view-domains': OpsViewDomains;
}
}
@customElement('ops-view-domains')
export class OpsViewDomains extends DeesElement {
@state()
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
constructor() {
super();
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
this.domainsState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.domainsContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.sourceBadge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.sourceBadge.manual {
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
}
.sourceBadge.provider {
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
color: ${cssManager.bdTheme('#92400e', '#fde047')};
}
`,
];
public render(): TemplateResult {
const domains = this.domainsState.domains;
const providersById = new Map(this.domainsState.providers.map((p) => [p.id, p]));
return html`
<dees-heading level="3">Domains</dees-heading>
<div class="domainsContainer">
<dees-table
.heading1=${'Domains'}
.heading2=${'Domains under management — manual (authoritative) or imported from a provider'}
.data=${domains}
.showColumnFilters=${true}
.displayFunction=${(d: interfaces.data.IDomain) => ({
Name: d.name,
Source: this.renderSourceBadge(d, providersById),
Authoritative: d.authoritative ? 'yes' : 'no',
Nameservers: d.nameservers?.join(', ') || '-',
'Last Synced': d.lastSyncedAt
? new Date(d.lastSyncedAt).toLocaleString()
: '-',
})}
.dataActions=${[
{
name: 'Add Manual Domain',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateManualDialog();
},
},
{
name: 'Import from Provider',
iconName: 'lucide:download',
type: ['header' as const],
actionFunc: async () => {
await this.showImportDialog();
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.domainsStatePart.dispatchAction(
appstate.fetchDomainsAndProvidersAction,
null,
);
},
},
{
name: 'View Records',
iconName: 'lucide:list',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const domain = actionData.item as interfaces.data.IDomain;
await appstate.domainsStatePart.dispatchAction(
appstate.fetchDnsRecordsForDomainAction,
{ domainId: domain.id },
);
appRouter.navigateToView('domains', 'dns');
},
},
{
name: 'Sync Now',
iconName: 'lucide:rotateCw',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const domain = actionData.item as interfaces.data.IDomain;
if (domain.source !== 'provider') {
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({
message: 'Sync only applies to provider-managed domains',
type: 'warning',
duration: 3000,
});
return;
}
await appstate.domainsStatePart.dispatchAction(appstate.syncDomainAction, {
id: domain.id,
});
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const domain = actionData.item as interfaces.data.IDomain;
await this.deleteDomain(domain);
},
},
]}
></dees-table>
</div>
`;
}
private renderSourceBadge(
d: interfaces.data.IDomain,
providersById: Map<string, interfaces.data.IDnsProviderPublic>,
): TemplateResult {
if (d.source === 'manual') {
return html`<span class="sourceBadge manual">Manual</span>`;
}
const provider = d.providerId ? providersById.get(d.providerId) : undefined;
return html`<span class="sourceBadge provider">${provider?.name || 'Provider'}</span>`;
}
private async showCreateManualDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Add Manual Domain',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'FQDN (e.g. example.com)'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description (optional)'}></dees-input-text>
</dees-form>
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
dcrouter will become the authoritative DNS server for this domain. You'll need to
delegate the domain's nameservers to dcrouter to make this effective.
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
await appstate.domainsStatePart.dispatchAction(appstate.createManualDomainAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
});
modalArg.destroy();
},
},
],
});
}
private async showImportDialog() {
const providers = this.domainsState.providers;
if (providers.length === 0) {
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({
message: 'Add a DNS provider first (Domains > Providers)',
type: 'warning',
duration: 3500,
});
return;
}
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Import Domains from Provider',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'providerId'}
.label=${'Provider'}
.options=${providers.map((p) => ({ option: p.name, key: p.id }))}
.required=${true}
></dees-input-dropdown>
<dees-input-text
.key=${'domainNames'}
.label=${'Comma-separated FQDNs to import (e.g. example.com, foo.com)'}
.required=${true}
></dees-input-text>
</dees-form>
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
Tip: use "List Provider Domains" to see what's available before typing.
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'List Provider Domains',
action: async (_modalArg: any) => {
const form = _modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const providerKey = data.providerId?.key ?? data.providerId;
if (!providerKey) {
DeesToast.show({ message: 'Pick a provider first', type: 'warning', duration: 2500 });
return;
}
const result = await appstate.fetchProviderDomains(String(providerKey));
if (!result.success) {
DeesToast.show({
message: result.message || 'Failed to fetch domains',
type: 'error',
duration: 4000,
});
return;
}
const list = (result.domains ?? []).map((d) => d.name).join(', ');
DeesToast.show({
message: `Provider has: ${list || '(none)'}`,
type: 'info',
duration: 8000,
});
},
},
{
name: 'Import',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const providerKey = data.providerId?.key ?? data.providerId;
if (!providerKey) {
DeesToast.show({ message: 'Pick a provider', type: 'warning', duration: 2500 });
return;
}
const names = String(data.domainNames || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (names.length === 0) {
DeesToast.show({ message: 'Enter at least one FQDN', type: 'warning', duration: 2500 });
return;
}
await appstate.domainsStatePart.dispatchAction(
appstate.importDomainsFromProviderAction,
{ providerId: String(providerKey), domainNames: names },
);
modalArg.destroy();
},
},
],
});
}
private async deleteDomain(domain: interfaces.data.IDomain) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Delete domain ${domain.name}?`,
content: html`
<p>
${domain.source === 'provider'
? 'This removes the domain and its cached records from dcrouter only. The zone at the provider is NOT touched.'
: 'This removes the domain and all of its DNS records from dcrouter. dcrouter will no longer answer queries for this domain after the next restart.'}
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Delete',
action: async (modalArg: any) => {
await appstate.domainsStatePart.dispatchAction(appstate.deleteDomainAction, {
id: domain.id,
});
modalArg.destroy();
},
},
],
});
}
}

View File

@@ -0,0 +1,283 @@
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';
declare global {
interface HTMLElementTagNameMap {
'ops-view-providers': OpsViewProviders;
}
}
@customElement('ops-view-providers')
export class OpsViewProviders extends DeesElement {
@state()
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
constructor() {
super();
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
this.domainsState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.providersContainer {
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;
text-transform: uppercase;
}
.statusBadge.ok {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.error {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.untested {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
}
`,
];
public render(): TemplateResult {
const providers = this.domainsState.providers;
return html`
<dees-heading level="3">DNS Providers</dees-heading>
<div class="providersContainer">
<dees-table
.heading1=${'Providers'}
.heading2=${'External DNS provider accounts (Cloudflare, etc.)'}
.data=${providers}
.showColumnFilters=${true}
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
Name: p.name,
Type: p.type,
Status: this.renderStatusBadge(p.status),
'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never',
Error: p.lastError || '-',
})}
.dataActions=${[
{
name: 'Add Provider',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateDialog();
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.domainsStatePart.dispatchAction(
appstate.fetchDomainsAndProvidersAction,
null,
);
},
},
{
name: 'Test Connection',
iconName: 'lucide:plug',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
await this.testProvider(provider);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
await this.showEditDialog(provider);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
await this.deleteProvider(provider);
},
},
]}
></dees-table>
</div>
`;
}
private renderStatusBadge(status: interfaces.data.TDnsProviderStatus): TemplateResult {
return html`<span class="statusBadge ${status}">${status}</span>`;
}
private async showCreateDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Add DNS Provider',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Provider name'} .required=${true}></dees-input-text>
<dees-input-text
.key=${'apiToken'}
.label=${'Cloudflare API token'}
.required=${true}
></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, {
name: String(data.name),
type: 'cloudflare',
credentials: { type: 'cloudflare', apiToken: String(data.apiToken) },
});
modalArg.destroy();
},
},
],
});
}
private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Edit Provider: ${provider.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Provider name'} .value=${provider.name}></dees-input-text>
<dees-input-text
.key=${'apiToken'}
.label=${'New API token (leave blank to keep current)'}
></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const apiToken = data.apiToken ? String(data.apiToken) : '';
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, {
id: provider.id,
name: String(data.name),
credentials: apiToken
? { type: 'cloudflare', apiToken }
: undefined,
});
modalArg.destroy();
},
},
],
});
}
private async testProvider(provider: interfaces.data.IDnsProviderPublic) {
const { DeesToast } = await import('@design.estate/dees-catalog');
await appstate.domainsStatePart.dispatchAction(appstate.testDnsProviderAction, {
id: provider.id,
});
const updated = appstate.domainsStatePart
.getState()!
.providers.find((p) => p.id === provider.id);
if (updated?.status === 'ok') {
DeesToast.show({
message: `${provider.name}: connection OK`,
type: 'success',
duration: 3000,
});
} else {
DeesToast.show({
message: `${provider.name}: ${updated?.lastError || 'connection failed'}`,
type: 'error',
duration: 4000,
});
}
}
private async deleteProvider(provider: interfaces.data.IDnsProviderPublic) {
const linkedDomains = this.domainsState.domains.filter((d) => d.providerId === provider.id);
const { DeesModal } = await import('@design.estate/dees-catalog');
const doDelete = async (force: boolean) => {
await appstate.domainsStatePart.dispatchAction(appstate.deleteDnsProviderAction, {
id: provider.id,
force,
});
};
if (linkedDomains.length > 0) {
DeesModal.createAndShow({
heading: `Provider in use`,
content: html`
<p>
Provider <strong>${provider.name}</strong> is referenced by ${linkedDomains.length}
domain(s). Deleting will also remove the imported domain(s) and their cached
records (the records at ${provider.type} are NOT touched).
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Force Delete',
action: async (modalArg: any) => {
await doDelete(true);
modalArg.destroy();
},
},
],
});
} else {
await doDelete(false);
}
}
}