2026-04-08 11:08:18 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 14:54:49 +00:00
|
|
|
.sourceBadge.dcrouter {
|
2026-04-08 11:08:18 +00:00
|
|
|
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'}
|
2026-04-08 14:54:49 +00:00
|
|
|
.heading2=${'Domains under management — served by dcrouter (authoritative) or imported from a provider'}
|
2026-04-08 11:08:18 +00:00
|
|
|
.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=${[
|
|
|
|
|
{
|
2026-04-08 14:54:49 +00:00
|
|
|
name: 'Add DcRouter Domain',
|
2026-04-08 11:08:18 +00:00
|
|
|
iconName: 'lucide:plus',
|
|
|
|
|
type: ['header' as const],
|
|
|
|
|
actionFunc: async () => {
|
2026-04-08 14:54:49 +00:00
|
|
|
await this.showCreateDcrouterDialog();
|
2026-04-08 11:08:18 +00:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-13 09:47:19 +00:00
|
|
|
{
|
|
|
|
|
name: 'Migrate',
|
|
|
|
|
iconName: 'lucide:arrow-right-left',
|
|
|
|
|
type: ['inRow', 'contextmenu'] as any,
|
|
|
|
|
actionFunc: async (actionData: any) => {
|
|
|
|
|
const domain = actionData.item as interfaces.data.IDomain;
|
|
|
|
|
await this.showMigrateDialog(domain);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-08 11:08:18 +00:00
|
|
|
{
|
|
|
|
|
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 {
|
2026-04-08 14:54:49 +00:00
|
|
|
if (d.source === 'dcrouter') {
|
|
|
|
|
return html`<span class="sourceBadge dcrouter">DcRouter</span>`;
|
2026-04-08 11:08:18 +00:00
|
|
|
}
|
|
|
|
|
const provider = d.providerId ? providersById.get(d.providerId) : undefined;
|
|
|
|
|
return html`<span class="sourceBadge provider">${provider?.name || 'Provider'}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 14:54:49 +00:00
|
|
|
private async showCreateDcrouterDialog() {
|
2026-04-08 11:08:18 +00:00
|
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
|
DeesModal.createAndShow({
|
2026-04-08 14:54:49 +00:00
|
|
|
heading: 'Add DcRouter Domain',
|
2026-04-08 11:08:18 +00:00
|
|
|
content: html`
|
|
|
|
|
<dees-form>
|
2026-04-12 19:42:07 +00:00
|
|
|
<dees-input-text .key=${'name'} .label=${'FQDN'} .description=${'e.g. example.com'} .required=${true}></dees-input-text>
|
|
|
|
|
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
2026-04-08 11:08:18 +00:00
|
|
|
</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();
|
2026-04-08 14:54:49 +00:00
|
|
|
await appstate.domainsStatePart.dispatchAction(appstate.createDcrouterDomainAction, {
|
2026-04-08 11:08:18 +00:00
|
|
|
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'}
|
2026-04-12 19:42:07 +00:00
|
|
|
.label=${'Domain Names'}
|
|
|
|
|
.description=${'Comma-separated FQDNs, e.g. example.com, foo.com'}
|
2026-04-08 11:08:18 +00:00
|
|
|
.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();
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-13 09:47:19 +00:00
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async showMigrateDialog(domain: interfaces.data.IDomain) {
|
|
|
|
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
|
|
|
|
const providers = this.domainsState.providers;
|
|
|
|
|
|
|
|
|
|
// Build target options based on current source
|
|
|
|
|
const targetOptions: { option: string; key: string }[] = [];
|
|
|
|
|
if (domain.source === 'provider') {
|
|
|
|
|
targetOptions.push({ option: 'DcRouter (authoritative)', key: 'dcrouter' });
|
|
|
|
|
}
|
|
|
|
|
// Add all providers (except the current one if already provider-managed)
|
|
|
|
|
for (const p of providers) {
|
|
|
|
|
if (domain.source === 'provider' && domain.providerId === p.id) continue;
|
|
|
|
|
targetOptions.push({ option: `${p.name} (${p.type})`, key: `provider:${p.id}` });
|
|
|
|
|
}
|
|
|
|
|
if (domain.source === 'dcrouter') {
|
|
|
|
|
targetOptions.unshift({ option: 'DcRouter (authoritative)', key: 'dcrouter' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (targetOptions.length === 0) {
|
|
|
|
|
DeesToast.show({
|
|
|
|
|
message: 'No migration targets available. Add a DNS provider first.',
|
|
|
|
|
type: 'warning',
|
|
|
|
|
duration: 3000,
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentLabel = domain.source === 'dcrouter'
|
|
|
|
|
? 'DcRouter (authoritative)'
|
|
|
|
|
: providers.find((p) => p.id === domain.providerId)?.name || 'Provider';
|
|
|
|
|
|
|
|
|
|
DeesModal.createAndShow({
|
|
|
|
|
heading: `Migrate: ${domain.name}`,
|
|
|
|
|
content: html`
|
|
|
|
|
<dees-form>
|
|
|
|
|
<dees-input-text
|
|
|
|
|
.key=${'currentSource'}
|
|
|
|
|
.label=${'Current source'}
|
|
|
|
|
.value=${currentLabel}
|
|
|
|
|
.disabled=${true}
|
|
|
|
|
></dees-input-text>
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.key=${'target'}
|
|
|
|
|
.label=${'Migrate to'}
|
|
|
|
|
.description=${'Select the target DNS management'}
|
|
|
|
|
.options=${targetOptions}
|
|
|
|
|
.required=${true}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
<dees-input-checkbox
|
|
|
|
|
.key=${'deleteExisting'}
|
|
|
|
|
.label=${'Delete existing records at provider first'}
|
|
|
|
|
.description=${'Removes all records at the provider before pushing migrated records'}
|
|
|
|
|
.value=${true}
|
|
|
|
|
></dees-input-checkbox>
|
|
|
|
|
</dees-form>
|
|
|
|
|
`,
|
|
|
|
|
menuOptions: [
|
|
|
|
|
{ name: 'Cancel', action: async (m: any) => m.destroy() },
|
|
|
|
|
{
|
|
|
|
|
name: 'Migrate',
|
|
|
|
|
action: async (m: any) => {
|
|
|
|
|
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
|
|
|
|
if (!form) return;
|
|
|
|
|
const data = await form.collectFormData();
|
|
|
|
|
const targetKey = typeof data.target === 'object' ? data.target.key : data.target;
|
|
|
|
|
if (!targetKey) return;
|
|
|
|
|
|
|
|
|
|
let targetSource: interfaces.data.TDomainSource;
|
|
|
|
|
let targetProviderId: string | undefined;
|
|
|
|
|
if (targetKey === 'dcrouter') {
|
|
|
|
|
targetSource = 'dcrouter';
|
|
|
|
|
} else {
|
|
|
|
|
targetSource = 'provider';
|
|
|
|
|
targetProviderId = targetKey.replace('provider:', '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await appstate.domainsStatePart.dispatchAction(appstate.migrateDomainAction, {
|
|
|
|
|
id: domain.id,
|
|
|
|
|
targetSource,
|
|
|
|
|
targetProviderId,
|
|
|
|
|
deleteExistingProviderRecords: targetSource === 'provider' ? Boolean(data.deleteExisting) : false,
|
|
|
|
|
});
|
|
|
|
|
DeesToast.show({ message: `Domain ${domain.name} migrated successfully`, type: 'success', duration: 3000 });
|
|
|
|
|
m.destroy();
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-08 11:08:18 +00:00
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|