feat(dns): add db-backed DNS provider, domain, and record management with ops UI support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
ts_web/elements/domains/index.ts
Normal file
4
ts_web/elements/domains/index.ts
Normal 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';
|
||||
@@ -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)}
|
||||
273
ts_web/elements/domains/ops-view-dns.ts
Normal file
273
ts_web/elements/domains/ops-view-dns.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
335
ts_web/elements/domains/ops-view-domains.ts
Normal file
335
ts_web/elements/domains/ops-view-domains.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
283
ts_web/elements/domains/ops-view-providers.ts
Normal file
283
ts_web/elements/domains/ops-view-providers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user