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

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

View File

@@ -117,7 +117,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', 'email', 'logs', 'access', 'security', 'certificates'];
const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'domains'];
const segments = path.split('/').filter(Boolean);
const view = segments[0];
return validViews.includes(view) ? view : 'overview';
@@ -465,8 +465,9 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
}, 100);
}
// If switching to certificates view, ensure we fetch certificate data
if (viewName === 'certificates' && currentState.activeView !== 'certificates') {
// If switching to the Domains group, ensure we fetch certificate data
// (Certificates is a subview of Domains).
if (viewName === 'domains' && currentState.activeView !== 'domains') {
setTimeout(() => {
certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
}, 100);
@@ -1555,6 +1556,403 @@ export const deleteTargetAction = profilesTargetsStatePart.createAction<{
}
});
// ============================================================================
// Domains State (DNS providers + domains + records)
// ============================================================================
export interface IDomainsState {
providers: interfaces.data.IDnsProviderPublic[];
domains: interfaces.data.IDomain[];
records: interfaces.data.IDnsRecord[];
/** id of the currently-selected domain in the DNS records subview. */
selectedDomainId: string | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const domainsStatePart = await appState.getStatePart<IDomainsState>(
'domains',
{
providers: [],
domains: [],
records: [],
selectedDomainId: null,
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft',
);
export const fetchDomainsAndProvidersAction = domainsStatePart.createAction(
async (statePartArg): Promise<IDomainsState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const providersRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDnsProviders
>('/typedrequest', 'getDnsProviders');
const domainsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDomains
>('/typedrequest', 'getDomains');
const [providersResponse, domainsResponse] = await Promise.all([
providersRequest.fire({ identity: context.identity }),
domainsRequest.fire({ identity: context.identity }),
]);
return {
...currentState,
providers: providersResponse.providers,
domains: domainsResponse.domains,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error: unknown) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch domains/providers',
};
}
},
);
export const fetchDnsRecordsForDomainAction = domainsStatePart.createAction<{ domainId: string }>(
async (statePartArg, dataArg): Promise<IDomainsState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDnsRecords
>('/typedrequest', 'getDnsRecords');
const response = await request.fire({
identity: context.identity,
domainId: dataArg.domainId,
});
return {
...currentState,
records: response.records,
selectedDomainId: dataArg.domainId,
error: null,
};
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch DNS records',
};
}
},
);
export const createDnsProviderAction = domainsStatePart.createAction<{
name: string;
type: interfaces.data.TDnsProviderType;
credentials: interfaces.data.TDnsProviderCredentials;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateDnsProvider
>('/typedrequest', 'createDnsProvider');
const response = await request.fire({
identity: context.identity!,
name: dataArg.name,
type: dataArg.type,
credentials: dataArg.credentials,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to create provider',
};
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create provider',
};
}
});
export const updateDnsProviderAction = domainsStatePart.createAction<{
id: string;
name?: string;
credentials?: interfaces.data.TDnsProviderCredentials;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateDnsProvider
>('/typedrequest', 'updateDnsProvider');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
credentials: dataArg.credentials,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to update provider',
};
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update provider',
};
}
});
export const deleteDnsProviderAction = domainsStatePart.createAction<{ id: string; force?: boolean }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteDnsProvider
>('/typedrequest', 'deleteDnsProvider');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
force: dataArg.force,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to delete provider',
};
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete provider',
};
}
},
);
export const testDnsProviderAction = domainsStatePart.createAction<{ id: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_TestDnsProvider
>('/typedrequest', 'testDnsProvider');
await request.fire({ identity: context.identity!, id: dataArg.id });
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to test provider',
};
}
},
);
/** One-shot fetch for the import-domain modal. Does NOT modify state. */
export async function fetchProviderDomains(
providerId: string,
): Promise<{ success: boolean; domains?: interfaces.data.IProviderDomainListing[]; message?: string }> {
const context = getActionContext();
if (!context.identity) return { success: false, message: 'Not authenticated' };
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListProviderDomains
>('/typedrequest', 'listProviderDomains');
return await request.fire({ identity: context.identity, providerId });
}
export const createManualDomainAction = domainsStatePart.createAction<{
name: string;
description?: string;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateDomain
>('/typedrequest', 'createDomain');
const response = await request.fire({
identity: context.identity!,
name: dataArg.name,
description: dataArg.description,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to create domain' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create domain',
};
}
});
export const importDomainsFromProviderAction = domainsStatePart.createAction<{
providerId: string;
domainNames: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ImportDomain
>('/typedrequest', 'importDomain');
const response = await request.fire({
identity: context.identity!,
providerId: dataArg.providerId,
domainNames: dataArg.domainNames,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to import domains' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to import domains',
};
}
});
export const deleteDomainAction = domainsStatePart.createAction<{ id: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteDomain
>('/typedrequest', 'deleteDomain');
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete domain' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete domain',
};
}
},
);
export const syncDomainAction = domainsStatePart.createAction<{ id: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SyncDomain
>('/typedrequest', 'syncDomain');
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to sync domain' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to sync domain',
};
}
},
);
export const createDnsRecordAction = domainsStatePart.createAction<{
domainId: string;
name: string;
type: interfaces.data.TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateDnsRecord
>('/typedrequest', 'createDnsRecord');
const response = await request.fire({
identity: context.identity!,
domainId: dataArg.domainId,
name: dataArg.name,
type: dataArg.type,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to create record' };
}
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create record',
};
}
});
export const updateDnsRecordAction = domainsStatePart.createAction<{
id: string;
domainId: string;
name?: string;
value?: string;
ttl?: number;
proxied?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateDnsRecord
>('/typedrequest', 'updateDnsRecord');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to update record' };
}
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update record',
};
}
});
export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string; domainId: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteDnsRecord
>('/typedrequest', 'deleteDnsRecord');
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete record' };
}
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete record',
};
}
},
);
// ============================================================================
// Route Management Actions
// ============================================================================
@@ -2076,8 +2474,8 @@ async function dispatchCombinedRefreshActionInner() {
}
}
// Refresh certificate data if on certificates view
if (currentView === 'certificates') {
// Refresh certificate data if on Domains > Certificates subview
if (currentView === 'domains' && currentSubview === 'certificates') {
try {
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
} catch (error) {

View File

@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
const { apiTokens } = this.routeState;
return html`
<dees-heading level="hr">API Tokens</dees-heading>
<dees-heading level="3">API Tokens</dees-heading>
<div class="apiTokensContainer">
<dees-table

View File

@@ -104,7 +104,7 @@ export class OpsViewUsers extends DeesElement {
const currentUserId = this.loginState.identity?.userId;
return html`
<dees-heading level="2">Users</dees-heading>
<dees-heading level="3">Users</dees-heading>
<div class="usersContainer">
<dees-table

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

@@ -7,9 +7,9 @@ import {
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 * 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 {
@@ -159,7 +159,7 @@ export class OpsViewCertificates extends DeesElement {
const { summary } = this.certState;
return html`
<dees-heading level="hr">Certificates</dees-heading>
<dees-heading level="3">Certificates</dees-heading>
<div class="certificatesContainer">
${this.renderStatsTiles(summary)}

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);
}
}
}

View File

@@ -111,7 +111,7 @@ export class OpsViewEmailSecurity extends DeesElement {
];
return html`
<dees-heading level="hr">Email Security</dees-heading>
<dees-heading level="3">Email Security</dees-heading>
<dees-statsgrid
.tiles=${tiles}

View File

@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
public render() {
return html`
<dees-heading level="hr">Email Log</dees-heading>
<dees-heading level="3">Email Log</dees-heading>
<div class="viewContainer">
${this.currentView === 'detail' && this.selectedEmail
? html`

View File

@@ -5,5 +5,5 @@ export * from './email/index.js';
export * from './ops-view-logs.js';
export * from './access/index.js';
export * from './security/index.js';
export * from './ops-view-certificates.js';
export * from './domains/index.js';
export * from './shared/index.js';

View File

@@ -285,7 +285,7 @@ export class OpsViewNetworkActivity extends DeesElement {
public render() {
return html`
<dees-heading level="hr">Network Activity</dees-heading>
<dees-heading level="3">Network Activity</dees-heading>
<div class="networkContainer">
<!-- Stats Grid -->

View File

@@ -64,7 +64,7 @@ export class OpsViewNetworkTargets extends DeesElement {
];
return html`
<dees-heading level="hr">Network Targets</dees-heading>
<dees-heading level="3">Network Targets</dees-heading>
<div class="targetsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table

View File

@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
];
return html`
<dees-heading level="hr">Remote Ingress</dees-heading>
<dees-heading level="3">Remote Ingress</dees-heading>
${this.riState.newEdgeId ? html`
<div class="secretDialog">

View File

@@ -200,7 +200,7 @@ export class OpsViewRoutes extends DeesElement {
});
return html`
<dees-heading level="hr">Route Management</dees-heading>
<dees-heading level="3">Route Management</dees-heading>
<div class="routesContainer">
<dees-statsgrid

View File

@@ -64,7 +64,7 @@ export class OpsViewSourceProfiles extends DeesElement {
];
return html`
<dees-heading level="hr">Source Profiles</dees-heading>
<dees-heading level="3">Source Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table

View File

@@ -77,7 +77,7 @@ export class OpsViewTargetProfiles extends DeesElement {
];
return html`
<dees-heading level="hr">Target Profiles</dees-heading>
<dees-heading level="3">Target Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table

View File

@@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
];
return html`
<dees-heading level="hr">VPN</dees-heading>
<dees-heading level="3">VPN</dees-heading>
<div class="vpnContainer">
${this.vpnState.newClientConfig ? html`

View File

@@ -15,7 +15,6 @@ import type { IView } from '@design.estate/dees-catalog';
// Top-level / flat views
import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
// Overview group
import { OpsViewOverview } from './overview/ops-view-overview.js';
@@ -43,6 +42,12 @@ import { OpsViewSecurityOverview } from './security/ops-view-security-overview.j
import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js';
import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js';
// Domains group
import { OpsViewProviders } from './domains/ops-view-providers.js';
import { OpsViewDomains } from './domains/ops-view-domains.js';
import { OpsViewDns } from './domains/ops-view-dns.js';
import { OpsViewCertificates } from './domains/ops-view-certificates.js';
/**
* Extended IView with explicit URL slug. Without an explicit `slug`, the URL
* slug is derived from `name.toLowerCase().replace(/\s+/g, '')`.
@@ -128,9 +133,14 @@ export class OpsDashboard extends DeesElement {
],
},
{
name: 'Certificates',
iconName: 'lucide:badgeCheck',
element: OpsViewCertificates,
name: 'Domains',
iconName: 'lucide:globe',
subViews: [
{ slug: 'providers', name: 'Providers', iconName: 'lucide:plug', element: OpsViewProviders },
{ slug: 'domains', name: 'Domains', iconName: 'lucide:globe', element: OpsViewDomains },
{ slug: 'dns', name: 'DNS', iconName: 'lucide:list', element: OpsViewDns },
{ slug: 'certificates', name: 'Certificates', iconName: 'lucide:badgeCheck', element: OpsViewCertificates },
],
},
];

View File

@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
public render() {
return html`
<dees-heading level="hr">Logs</dees-heading>
<dees-heading level="3">Logs</dees-heading>
<dees-chart-log
.label=${'Application Logs'}

View File

@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
public render() {
return html`
<dees-heading level="hr">Configuration</dees-heading>
<dees-heading level="3">Configuration</dees-heading>
${this.configState.isLoading
? html`
@@ -227,7 +227,7 @@ export class OpsViewConfig extends DeesElement {
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
const actions: IConfigSectionAction[] = [
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'domains', subview: 'certificates' } },
];
return html`

View File

@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
public render() {
return html`
<dees-heading level="hr">Stats</dees-heading>
<dees-heading level="3">Stats</dees-heading>
${this.statsState.isLoading ? html`
<div class="loadingMessage">

View File

@@ -96,7 +96,7 @@ export class OpsViewSecurityAuthentication extends DeesElement {
}));
return html`
<dees-heading level="hr">Authentication</dees-heading>
<dees-heading level="3">Authentication</dees-heading>
<dees-statsgrid
.tiles=${tiles}

View File

@@ -69,7 +69,7 @@ export class OpsViewSecurityBlocked extends DeesElement {
];
return html`
<dees-heading level="hr">Blocked IPs</dees-heading>
<dees-heading level="3">Blocked IPs</dees-heading>
<dees-statsgrid
.tiles=${tiles}

View File

@@ -118,7 +118,7 @@ export class OpsViewSecurityOverview extends DeesElement {
];
return html`
<dees-heading level="hr">Overview</dees-heading>
<dees-heading level="3">Overview</dees-heading>
<dees-statsgrid
.tiles=${tiles}

View File

@@ -4,7 +4,7 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
// Flat top-level views (no subviews)
const flatViews = ['logs', 'certificates'] as const;
const flatViews = ['logs'] as const;
// Tabbed views and their valid subviews
const subviewMap: Record<string, readonly string[]> = {
@@ -13,6 +13,7 @@ const subviewMap: Record<string, readonly string[]> = {
email: ['log', 'security'] as const,
access: ['apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const,
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
};
// Default subview when user visits the bare parent URL
@@ -22,6 +23,7 @@ const defaultSubview: Record<string, string> = {
email: 'log',
access: 'apitokens',
security: 'overview',
domains: 'domains',
};
export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;