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';
|
2026-04-08 11:11:53 +00:00
|
|
|
import './dns-provider-form.js';
|
|
|
|
|
import type { DnsProviderForm } from './dns-provider-form.js';
|
2026-04-08 11:08:18 +00:00
|
|
|
|
|
|
|
|
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'}
|
2026-04-08 11:11:53 +00:00
|
|
|
.heading2=${'External DNS provider accounts'}
|
2026-04-08 11:08:18 +00:00
|
|
|
.data=${providers}
|
|
|
|
|
.showColumnFilters=${true}
|
|
|
|
|
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
|
|
|
|
|
Name: p.name,
|
2026-04-08 11:11:53 +00:00
|
|
|
Type: this.providerTypeLabel(p.type),
|
2026-04-08 11:08:18 +00:00
|
|
|
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>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 11:11:53 +00:00
|
|
|
private providerTypeLabel(type: interfaces.data.TDnsProviderType): string {
|
|
|
|
|
return interfaces.data.getDnsProviderTypeDescriptor(type)?.displayName ?? type;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 11:08:18 +00:00
|
|
|
private async showCreateDialog() {
|
2026-04-08 11:11:53 +00:00
|
|
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
|
|
|
|
const formEl = document.createElement('dns-provider-form') as DnsProviderForm;
|
2026-04-08 11:08:18 +00:00
|
|
|
DeesModal.createAndShow({
|
|
|
|
|
heading: 'Add DNS Provider',
|
2026-04-08 11:11:53 +00:00
|
|
|
content: html`${formEl}`,
|
2026-04-08 11:08:18 +00:00
|
|
|
menuOptions: [
|
|
|
|
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
|
|
|
{
|
|
|
|
|
name: 'Create',
|
|
|
|
|
action: async (modalArg: any) => {
|
2026-04-08 11:11:53 +00:00
|
|
|
const data = await formEl.collectData();
|
|
|
|
|
if (!data) return;
|
|
|
|
|
if (!data.name) {
|
|
|
|
|
DeesToast.show({ message: 'Name is required', type: 'warning', duration: 2500 });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!data.credentialsTouched) {
|
|
|
|
|
DeesToast.show({
|
|
|
|
|
message: 'Fill in the provider credentials',
|
|
|
|
|
type: 'warning',
|
|
|
|
|
duration: 2500,
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-08 11:08:18 +00:00
|
|
|
await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, {
|
2026-04-08 11:11:53 +00:00
|
|
|
name: data.name,
|
|
|
|
|
type: data.type,
|
|
|
|
|
credentials: data.credentials,
|
2026-04-08 11:08:18 +00:00
|
|
|
});
|
|
|
|
|
modalArg.destroy();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) {
|
|
|
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
2026-04-08 11:11:53 +00:00
|
|
|
const formEl = document.createElement('dns-provider-form') as DnsProviderForm;
|
|
|
|
|
formEl.providerName = provider.name;
|
|
|
|
|
formEl.selectedType = provider.type;
|
|
|
|
|
formEl.lockType = true;
|
|
|
|
|
formEl.credentialsHint =
|
|
|
|
|
'Leave credential fields blank to keep the current values. Fill them to rotate.';
|
2026-04-08 11:08:18 +00:00
|
|
|
DeesModal.createAndShow({
|
|
|
|
|
heading: `Edit Provider: ${provider.name}`,
|
2026-04-08 11:11:53 +00:00
|
|
|
content: html`${formEl}`,
|
2026-04-08 11:08:18 +00:00
|
|
|
menuOptions: [
|
|
|
|
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
|
|
|
{
|
|
|
|
|
name: 'Save',
|
|
|
|
|
action: async (modalArg: any) => {
|
2026-04-08 11:11:53 +00:00
|
|
|
const data = await formEl.collectData();
|
|
|
|
|
if (!data) return;
|
2026-04-08 11:08:18 +00:00
|
|
|
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, {
|
|
|
|
|
id: provider.id,
|
2026-04-08 11:11:53 +00:00
|
|
|
name: data.name || provider.name,
|
|
|
|
|
// Only send credentials if the user actually entered something —
|
|
|
|
|
// otherwise we keep the current secret untouched.
|
|
|
|
|
credentials: data.credentialsTouched ? data.credentials : undefined,
|
2026-04-08 11:08:18 +00:00
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|