Files
dcrouter/ts_web/elements/domains/ops-view-domains.ts

336 lines
12 KiB
TypeScript
Raw Normal View History

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.dcrouter {
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 — served by dcrouter (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 DcRouter Domain',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateDcrouterDialog();
},
},
{
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 === 'dcrouter') {
return html`<span class="sourceBadge dcrouter">DcRouter</span>`;
}
const provider = d.providerId ? providersById.get(d.providerId) : undefined;
return html`<span class="sourceBadge provider">${provider?.name || 'Provider'}</span>`;
}
private async showCreateDcrouterDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Add DcRouter 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.createDcrouterDomainAction, {
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();
},
},
],
});
}
}