274 lines
9.1 KiB
TypeScript
274 lines
9.1 KiB
TypeScript
|
|
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();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|