feat(gateway-clients): add managed gateway client administration and token-bound route ownership

This commit is contained in:
2026-05-09 22:35:07 +00:00
parent d73b250382
commit 8dd0c3def9
22 changed files with 1287 additions and 48 deletions
+166 -11
View File
@@ -11,6 +11,7 @@ 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';
import { appRouter } from '../../router.js';
declare global {
interface HTMLElementTagNameMap {
@@ -26,6 +27,9 @@ export class OpsViewCertificates extends DeesElement {
@state()
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
@state()
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
constructor() {
super();
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
@@ -36,12 +40,19 @@ export class OpsViewCertificates extends DeesElement {
this.acmeState = newState;
});
this.rxSubscriptions.push(acmeSub);
const domainsSub = appstate.domainsStatePart.select().subscribe((newState) => {
this.domainsState = newState;
});
this.rxSubscriptions.push(domainsSub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
await Promise.all([
appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null),
appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null),
appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null),
]);
}
public static styles = [
@@ -127,10 +138,16 @@ export class OpsViewCertificates extends DeesElement {
.errorText {
font-size: 12px;
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 420px;
line-height: 1.35;
white-space: normal;
}
.errorStack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.backoffIndicator {
@@ -160,6 +177,39 @@ export class OpsViewCertificates extends DeesElement {
.expiryInfo .daysLeft.danger {
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.dnsWarningPanel {
border: 1px solid ${cssManager.bdTheme('#fed7aa', '#7c2d12')};
border-radius: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#fff7ed', '#1c1917')};
color: ${cssManager.bdTheme('#7c2d12', '#fdba74')};
}
.dnsWarningTitle {
font-weight: 700;
margin-bottom: 6px;
}
.dnsWarningText {
font-size: 13px;
line-height: 1.45;
color: ${cssManager.bdTheme('#9a3412', '#fed7aa')};
}
.dnsWarningList {
margin: 12px 0 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.5;
}
.dnsWarningActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
`,
];
@@ -172,11 +222,102 @@ export class OpsViewCertificates extends DeesElement {
<div class="certificatesContainer">
${this.renderStatsTiles(summary)}
${this.renderAcmeSettingsTile()}
${this.renderManagedDomainWarnings()}
${this.renderCertificateTable()}
</div>
`;
}
private renderManagedDomainWarnings(): TemplateResult {
const issues = this.getMissingManagedDomainIssues();
if (issues.length === 0) {
return html``;
}
const shownIssues = issues.slice(0, 6);
const remaining = issues.length - shownIssues.length;
return html`
<div class="dnsWarningPanel">
<div class="dnsWarningTitle">DNS-01 certificate provisioning needs managed DNS domains</div>
<div class="dnsWarningText">
DcRouter can only create ACME TXT records for domains listed under Domains > Domains.
Add the zone directly or import it from a DNS provider before reprovisioning certificates.
</div>
<ul class="dnsWarningList">
${shownIssues.map((issue) => html`
<li>
<strong>${issue.domain}</strong>: no managed DNS domain covers
<code>${issue.challengeHost}</code>. Add/import <code>${issue.requiredDomain}</code>
or a parent zone.
</li>
`)}
${remaining > 0 ? html`<li>${remaining} more domain${remaining === 1 ? '' : 's'} need managed DNS.</li>` : ''}
</ul>
<div class="dnsWarningActions">
<dees-button @click=${() => appRouter.navigateToView('domains', 'domains')}>Manage Domains</dees-button>
<dees-button @click=${() => appRouter.navigateToView('domains', 'providers')}>DNS Providers</dees-button>
</div>
</div>
`;
}
private getMissingManagedDomainIssues(): Array<{
domain: string;
challengeHost: string;
requiredDomain: string;
}> {
const managedDomains = this.domainsState.domains
.map((domain) => this.normalizeDomain(domain.name))
.filter(Boolean);
const issues: Array<{ domain: string; challengeHost: string; requiredDomain: string }> = [];
const seen = new Set<string>();
for (const cert of this.certState.certificates) {
if (!cert.canReprovision || (cert.source !== 'acme' && cert.source !== 'provision-function')) {
continue;
}
const requiredDomain = this.getAcmeChallengeDomain(cert.domain);
if (!requiredDomain) {
continue;
}
const covered = managedDomains.some((managedDomain) =>
requiredDomain === managedDomain || requiredDomain.endsWith(`.${managedDomain}`),
);
if (covered) {
continue;
}
const key = `${cert.domain}:${requiredDomain}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
issues.push({
domain: cert.domain,
challengeHost: `_acme-challenge.${requiredDomain}`,
requiredDomain,
});
}
return issues;
}
private getAcmeChallengeDomain(domain: string): string {
const normalized = this.normalizeDomain(domain).replace(/^\*\.?/, '');
const parts = normalized.split('.').filter(Boolean);
if (parts.length >= 2 && parts.length <= 3) {
return parts.slice(-2).join('.');
}
return normalized;
}
private normalizeDomain(domain: string): string {
return domain.trim().toLowerCase().replace(/^\*\.?/, '').replace(/\.$/, '');
}
private renderAcmeSettingsTile(): TemplateResult {
const config = this.acmeState.config;
@@ -349,11 +490,7 @@ export class OpsViewCertificates extends DeesElement {
Status: this.renderStatusBadge(cert.status),
Source: this.renderSourceBadge(cert.source),
Expires: this.renderExpiry(cert.expiryDate),
Error: cert.backoffInfo
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
: cert.error
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
: '',
Error: this.renderError(cert),
})}
.dataActions=${[
{
@@ -632,6 +769,24 @@ export class OpsViewCertificates extends DeesElement {
`;
}
private renderError(cert: interfaces.requests.ICertificateInfo): TemplateResult | string {
if (cert.backoffInfo) {
const message = cert.backoffInfo.lastError || cert.error;
return html`
<span class="errorStack">
${message ? html`<span class="errorText" title=${message}>${message}</span>` : ''}
<span class="backoffIndicator">
${cert.backoffInfo.failures} failure${cert.backoffInfo.failures === 1 ? '' : 's'}, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}
</span>
</span>
`;
}
if (cert.error) {
return html`<span class="errorText" title=${cert.error}>${cert.error}</span>`;
}
return '';
}
private formatRetryTime(retryAfter?: string): string {
if (!retryAfter) return 'soon';
const retryDate = new Date(retryAfter);