feat(dns): add db-backed DNS provider, domain, and record management with ops UI support
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user