feat(diagnostics): add health engine

This commit is contained in:
sdarbinyan
2026-07-10 13:34:25 +04:00
parent 7ecb19cb1a
commit 7161a81068
16 changed files with 1274 additions and 1 deletions

View File

@@ -0,0 +1,69 @@
@if (!devMode) {
<main class="diagnostics-page">
<section class="diagnostics-card">
<h1>{{ 'diagnostics.disabledTitle' | translate }}</h1>
<p>{{ 'diagnostics.disabledDescription' | translate }}</p>
</section>
</main>
} @else {
<main class="diagnostics-page">
@if (report$ | async; as report) {
<section class="diagnostics-hero diagnostics-card">
<div>
<h1>{{ 'diagnostics.title' | translate }}</h1>
<p>{{ 'diagnostics.generatedAt' | translate:{ value: report.generatedAt } }}</p>
</div>
<div class="health-score">
<strong>{{ report.summary.score }}%</strong>
<span>{{ 'diagnostics.healthScore' | translate }}</span>
</div>
</section>
<section class="diagnostics-grid">
<article class="diagnostics-card summary-card">
<h2>{{ 'diagnostics.summary' | translate }}</h2>
<ul>
<li>{{ 'diagnostics.passedChecks' | translate:{ count: report.summary.passedChecks } }}</li>
<li>{{ 'diagnostics.warnings' | translate:{ count: report.summary.warningCount } }}</li>
<li>{{ 'diagnostics.errors' | translate:{ count: report.summary.errorCount } }}</li>
<li>{{ 'diagnostics.critical' | translate:{ count: report.summary.criticalCount } }}</li>
</ul>
</article>
<article class="diagnostics-card">
<h2>{{ 'diagnostics.healthChecks' | translate }}</h2>
<ul class="check-list">
@for (check of report.checks; track check.key) {
<li [class.failed]="!check.passed">
<strong>{{ ('diagnostics.checks.' + check.key) | translate }}</strong>
<span>{{ check.passed ? ('diagnostics.ok' | translate) : ('diagnostics.failed' | translate) }}</span>
</li>
}
</ul>
</article>
</section>
<section class="diagnostics-card">
<h2>{{ 'diagnostics.results' | translate }}</h2>
@if (report.entries.length === 0) {
<p>{{ 'diagnostics.noIssues' | translate }}</p>
} @else {
<div class="entries">
@for (entry of report.entries; track entry.code + entry.affectedComponent + entry.description) {
<article class="entry" [attr.data-severity]="entry.severity">
<div class="entry-head">
<span class="severity">{{ ('diagnostics.severity.' + entry.severity) | translate }}</span>
<code>{{ entry.code }}</code>
</div>
<h3>{{ tDiagnostic(entry.title) }}</h3>
<p>{{ tDiagnostic(entry.description) }}</p>
<p><strong>{{ 'diagnostics.affectedComponent' | translate }}:</strong> {{ entry.affectedComponent }}</p>
<p><strong>{{ 'diagnostics.suggestedResolution' | translate }}:</strong> {{ tDiagnostic(entry.suggestedResolution) }}</p>
</article>
}
</div>
}
</section>
}
</main>
}

View File

@@ -0,0 +1,104 @@
.diagnostics-page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
display: grid;
gap: 16px;
}
.diagnostics-card {
background: #fff;
border: 1px solid #d9e2e1;
border-radius: 16px;
padding: 18px;
}
.diagnostics-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.health-score {
display: grid;
justify-items: end;
}
.health-score strong {
font-size: 2rem;
color: #1e3c38;
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.summary-card ul,
.check-list {
margin: 0;
padding-left: 18px;
}
.check-list li {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.check-list li.failed {
color: #991b1b;
}
.entries {
display: grid;
gap: 12px;
}
.entry {
border: 1px solid #d9e2e1;
border-radius: 12px;
padding: 14px;
}
.entry[data-severity='critical'] {
border-color: #991b1b;
}
.entry[data-severity='error'] {
border-color: #c2410c;
}
.entry[data-severity='warning'] {
border-color: #ca8a04;
}
.entry-head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.severity {
font-weight: 800;
text-transform: uppercase;
font-size: 0.8rem;
}
@media (max-width: 800px) {
.diagnostics-grid {
grid-template-columns: 1fr;
}
.diagnostics-hero {
flex-direction: column;
align-items: flex-start;
}
.health-score {
justify-items: start;
}
}

View File

@@ -0,0 +1,28 @@
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { TranslateService } from '../../../i18n/translate.service';
import { DiagnosticsFacade } from '../facade/diagnostics.facade';
@Component({
selector: 'app-diagnostics-page',
standalone: true,
imports: [AsyncPipe, TranslatePipe],
templateUrl: './diagnostics-page.component.html',
styleUrls: ['./diagnostics-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DiagnosticsPageComponent {
private readonly diagnosticsFacade = inject(DiagnosticsFacade);
private readonly translate = inject(TranslateService);
readonly report$ = this.diagnosticsFacade.loadReport();
readonly devMode = this.diagnosticsFacade.devMode;
tDiagnostic(value: string): string {
const [key, param] = value.split(':');
return key.startsWith('diagnostics.')
? this.translate.t(key, param ? { value: param } : undefined)
: value;
}
}

View File

@@ -0,0 +1,105 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { forkJoin, map, of } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { ConfigService } from '../../../core/config/config.service';
import { TenantResolverService } from '../../../core/config/tenant-resolver.service';
import { RuntimeProviderStrategyService } from '../../../core/providers/runtime-provider-strategy.service';
import { PlatformRuntimeStateService } from '../../../core/runtime/platform-runtime-state.service';
import { RuntimeDiagnosticsService } from '../../../core/runtime/runtime-diagnostics.service';
import { TranslateService } from '../../../i18n/translate.service';
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
import { WidgetRegistryService } from '../../../widgets/registry/widget-registry.service';
import { DiagnosticsReport } from '../models/diagnostics.model';
import { DiagnosticsLoggerService } from '../services/diagnostics-logger.service';
import { BootstrapDiagnosticsValidator } from '../validators/bootstrap-diagnostics.validator';
import { calculateHealthScore } from '../validators/diagnostics-health-score.util';
import { RuntimeDiagnosticsValidator } from '../validators/runtime-diagnostics.validator';
@Injectable({ providedIn: 'root' })
export class DiagnosticsFacade {
private readonly configService = inject(ConfigService);
private readonly tenantResolver = inject(TenantResolverService);
private readonly runtimeState = inject(PlatformRuntimeStateService);
private readonly runtimeDiagnostics = inject(RuntimeDiagnosticsService);
private readonly translate = inject(TranslateService);
private readonly widgetManifest = inject(WidgetManifestService);
private readonly widgetRegistry = inject(WidgetRegistryService);
private readonly router = inject(Router);
private readonly strategy = inject(RuntimeProviderStrategyService);
private readonly logger = inject(DiagnosticsLoggerService);
private readonly document = inject(DOCUMENT);
readonly devMode = !environment.production;
readonly logs = this.logger.logs;
readonly availableRoutes = computed(() => this.collectAvailableRoutes());
loadReport() {
const bootstrapValidator = new BootstrapDiagnosticsValidator();
const runtimeValidator = new RuntimeDiagnosticsValidator(
this.runtimeState,
this.runtimeDiagnostics,
this.strategy,
this.router,
this.document
);
return forkJoin({
bootstrap: this.configService.loadBootstrap().pipe(map(value => value), map(value => value)),
manifest: this.widgetManifest.getManifest().pipe(map(value => value)),
}).pipe(
map(({ bootstrap, manifest }) => {
const entries = [
...bootstrapValidator.validate(bootstrap, manifest, this.collectAvailableRoutes(), this.translate),
...runtimeValidator.validate(bootstrap, manifest),
];
for (const entry of entries) {
this.logger.log(entry);
}
const checks = [
{ key: 'bootstrapLoaded', passed: !!bootstrap, severity: 'critical' as const },
{ key: 'tenantResolved', passed: this.tenantResolver.getTenantKey().trim().length > 0, severity: 'error' as const },
{ key: 'runtimeInitialized', passed: this.runtimeState.initialized(), severity: 'critical' as const },
{ key: 'themeLoaded', passed: !!bootstrap?.theme?.themeId && this.document.documentElement.getAttribute('data-theme-id')?.length! > 0, severity: 'warning' as const },
{ key: 'widgetManifestLoaded', passed: (manifest?.widgets?.length ?? 0) > 0, severity: 'warning' as const },
{ key: 'sectionEngineInitialized', passed: true, severity: 'info' as const },
{ key: 'configurationEngineInitialized', passed: !!this.configService.getBootstrapSnapshot(), severity: 'error' as const },
{ key: 'translationResourcesAvailable', passed: this.translate.t('app.pageTitle') !== 'app.pageTitle', severity: 'warning' as const },
{ key: 'requiredAssetsAvailable', passed: Array.from(this.document.images).every(image => !image.complete || image.naturalWidth > 0), severity: 'warning' as const },
];
const summary = {
score: calculateHealthScore(entries),
passedChecks: checks.filter(check => check.passed).length,
infoCount: entries.filter(entry => entry.severity === 'info').length,
warningCount: entries.filter(entry => entry.severity === 'warning').length,
errorCount: entries.filter(entry => entry.severity === 'error').length,
criticalCount: entries.filter(entry => entry.severity === 'critical').length,
};
return {
generatedAt: new Date().toISOString(),
summary,
checks,
entries,
} satisfies DiagnosticsReport;
})
);
}
private collectAvailableRoutes(): string[] {
const visit = (routes: any[], prefix = ''): string[] => routes.flatMap(route => {
const current = route.path ? `${prefix}/${route.path}`.replace(/\/+/g, '/').replace(/\/$/, '') || '/' : prefix || '/';
const children = route.children ? visit(route.children, current === '/' ? '' : current) : [];
return [current || '/', ...children];
});
return visit(this.router.config)
.map(route => route.startsWith('/:lang') ? route.slice(6) || '/' : route)
.map(route => route === '' ? '/' : route)
.filter((route, index, source) => source.indexOf(route) === index);
}
}

View File

@@ -0,0 +1,26 @@
export type DiagnosticSeverity = 'info' | 'warning' | 'error' | 'critical';
export interface DiagnosticEntry {
code: string;
severity: DiagnosticSeverity;
title: string;
description: string;
affectedComponent: string;
suggestedResolution: string;
}
export interface DiagnosticsHealthSummary {
score: number;
passedChecks: number;
infoCount: number;
warningCount: number;
errorCount: number;
criticalCount: number;
}
export interface DiagnosticsReport {
generatedAt: string;
summary: DiagnosticsHealthSummary;
checks: Array<{ key: string; passed: boolean; severity: DiagnosticSeverity }>;
entries: DiagnosticEntry[];
}

View File

@@ -0,0 +1,17 @@
import { Injectable, signal } from '@angular/core';
import { DiagnosticEntry } from '../models/diagnostics.model';
@Injectable({ providedIn: 'root' })
export class DiagnosticsLoggerService {
private readonly logsState = signal<DiagnosticEntry[]>([]);
readonly logs = this.logsState.asReadonly();
log(entry: DiagnosticEntry): void {
this.logsState.update(current => [entry, ...current].slice(0, 300));
}
clear(): void {
this.logsState.set([]);
}
}

View File

@@ -0,0 +1,261 @@
import { TranslateService } from '../../../i18n/translate.service';
import { BootstrapConfig } from '../../../shared/models/config';
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
import { DiagnosticEntry } from '../models/diagnostics.model';
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
export class BootstrapDiagnosticsValidator {
validate(
bootstrap: BootstrapConfig | null,
manifest: WidgetManifestFile | null,
availableRoutes: string[],
translate: TranslateService
): DiagnosticEntry[] {
const entries: DiagnosticEntry[] = [];
if (!bootstrap) {
entries.push({
code: 'BOOTSTRAP_MISSING',
severity: 'critical',
title: 'diagnostics.bootstrapMissingTitle',
description: 'diagnostics.bootstrapMissingDescription',
affectedComponent: 'ConfigService',
suggestedResolution: 'diagnostics.bootstrapMissingResolution'
});
return entries;
}
this.validateRequiredProperties(bootstrap, entries);
this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
this.validateDuplicateIds(bootstrap, entries);
this.validateLayouts(bootstrap, entries);
this.validateFeatureFlags(bootstrap, entries);
this.validatePages(bootstrap, entries);
this.validateNavigationTargets(bootstrap, entries, availableRoutes);
this.validateMissingTranslations(bootstrap, entries, translate);
this.validateMissingMedia(bootstrap, entries);
return entries;
}
private validateRequiredProperties(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
const requiredPaths: Array<[string, unknown]> = [
['schemaVersion', bootstrap.schemaVersion],
['tenant.id', bootstrap.tenant?.id],
['tenant.slug', bootstrap.tenant?.slug],
['theme.themeId', bootstrap.theme?.themeId],
['branding.logoUrl', bootstrap.branding?.logoUrl],
['navigation.header', bootstrap.navigation?.header],
['pages', bootstrap.pages],
];
for (const [path, value] of requiredPaths) {
const missing = value == null || (Array.isArray(value) && value.length === 0) || (typeof value === 'string' && value.trim().length === 0);
if (missing) {
entries.push({
code: 'BOOTSTRAP_REQUIRED_PROPERTY_MISSING',
severity: 'error',
title: 'diagnostics.requiredPropertyMissingTitle',
description: `diagnostics.requiredPropertyMissingDescription:${path}`,
affectedComponent: 'BootstrapConfig',
suggestedResolution: 'diagnostics.requiredPropertyMissingResolution'
});
}
}
}
private validateUnknownWidgetTypes(bootstrap: BootstrapConfig, manifest: WidgetManifestFile | null, entries: DiagnosticEntry[]): void {
const known = new Set((manifest?.widgets ?? []).map(widget => widget.type));
for (const page of bootstrap.pages ?? []) {
for (const section of page.sections ?? []) {
for (const widget of section.widgets ?? []) {
if (known.size > 0 && !known.has(widget.type)) {
entries.push({
code: 'BOOTSTRAP_UNKNOWN_WIDGET_TYPE',
severity: 'error',
title: 'diagnostics.unknownWidgetTypeTitle',
description: `diagnostics.unknownWidgetTypeDescription:${widget.type}`,
affectedComponent: `page:${page.key}/section:${section.id}`,
suggestedResolution: 'diagnostics.unknownWidgetTypeResolution'
});
}
}
}
}
}
private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
const pageIds = new Set<string>();
for (const page of bootstrap.pages ?? []) {
if (pageIds.has(page.id)) {
entries.push({
code: 'BOOTSTRAP_DUPLICATE_PAGE_ID',
severity: 'error',
title: 'diagnostics.duplicateIdTitle',
description: `diagnostics.duplicatePageIdDescription:${page.id}`,
affectedComponent: `page:${page.key}`,
suggestedResolution: 'diagnostics.duplicateIdResolution'
});
}
pageIds.add(page.id);
const sectionIds = new Set<string>();
for (const section of page.sections ?? []) {
if (sectionIds.has(section.id)) {
entries.push({
code: 'BOOTSTRAP_DUPLICATE_SECTION_ID',
severity: 'error',
title: 'diagnostics.duplicateIdTitle',
description: `diagnostics.duplicateSectionIdDescription:${section.id}`,
affectedComponent: `page:${page.key}`,
suggestedResolution: 'diagnostics.duplicateIdResolution'
});
}
sectionIds.add(section.id);
const widgetIds = new Set<string>();
for (const widget of section.widgets ?? []) {
if (widgetIds.has(widget.id)) {
entries.push({
code: 'BOOTSTRAP_DUPLICATE_WIDGET_ID',
severity: 'error',
title: 'diagnostics.duplicateIdTitle',
description: `diagnostics.duplicateWidgetIdDescription:${widget.id}`,
affectedComponent: `page:${page.key}/section:${section.id}`,
suggestedResolution: 'diagnostics.duplicateIdResolution'
});
}
widgetIds.add(widget.id);
}
}
}
}
private validateLayouts(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
for (const page of bootstrap.pages ?? []) {
const layout = typeof page.layout === 'string' ? page.layout : page.layout?.type;
if (!layout || !KNOWN_LAYOUTS.has(layout)) {
entries.push({
code: 'BOOTSTRAP_UNKNOWN_LAYOUT',
severity: 'warning',
title: 'diagnostics.unknownLayoutTitle',
description: `diagnostics.unknownLayoutDescription:${layout ?? 'undefined'}`,
affectedComponent: `page:${page.key}`,
suggestedResolution: 'diagnostics.unknownLayoutResolution'
});
}
}
}
private validateFeatureFlags(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
const featureFlags = bootstrap.featureFlags ?? {};
for (const [key, value] of Object.entries(featureFlags)) {
if (typeof value !== 'boolean') {
entries.push({
code: 'BOOTSTRAP_INVALID_FEATURE_FLAG',
severity: 'error',
title: 'diagnostics.invalidFeatureFlagTitle',
description: `diagnostics.invalidFeatureFlagDescription:${key}`,
affectedComponent: 'featureFlags',
suggestedResolution: 'diagnostics.invalidFeatureFlagResolution'
});
}
}
}
private validatePages(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
for (const page of bootstrap.pages ?? []) {
const broken = !page.key || !page.title || !page.route?.path || !Array.isArray(page.sections);
if (broken) {
entries.push({
code: 'BOOTSTRAP_BROKEN_PAGE_DEFINITION',
severity: 'error',
title: 'diagnostics.brokenPageDefinitionTitle',
description: `diagnostics.brokenPageDefinitionDescription:${page.id}`,
affectedComponent: `page:${page.id}`,
suggestedResolution: 'diagnostics.brokenPageDefinitionResolution'
});
}
}
}
private validateNavigationTargets(bootstrap: BootstrapConfig, entries: DiagnosticEntry[], availableRoutes: string[]): void {
const allowed = new Set(availableRoutes);
const validateItem = (route: string | undefined, location: string) => {
if (!route) {
return;
}
const normalized = route.startsWith('/') ? route : `/${route}`;
if (!allowed.has(normalized)) {
entries.push({
code: 'BOOTSTRAP_INVALID_NAVIGATION_TARGET',
severity: 'warning',
title: 'diagnostics.invalidNavigationTargetTitle',
description: `diagnostics.invalidNavigationTargetDescription:${normalized}`,
affectedComponent: location,
suggestedResolution: 'diagnostics.invalidNavigationTargetResolution'
});
}
};
for (const item of bootstrap.navigation?.header ?? []) {
validateItem(item.route, `navigation.header:${item.id}`);
for (const child of item.children ?? []) {
validateItem(child.route, `navigation.header:${item.id}/${child.id}`);
}
}
for (const item of bootstrap.navigation?.footer ?? []) {
if ('items' in item) {
for (const child of item.items ?? []) {
validateItem(child.route, `navigation.footer.group`);
}
} else {
validateItem(item.route, `navigation.footer:${item.id}`);
}
}
}
private validateMissingTranslations(bootstrap: BootstrapConfig, entries: DiagnosticEntry[], translate: TranslateService): void {
for (const item of bootstrap.navigation?.header ?? []) {
if (item.labelKey && translate.t(item.labelKey) === item.labelKey) {
entries.push({
code: 'BOOTSTRAP_MISSING_TRANSLATION',
severity: 'warning',
title: 'diagnostics.missingTranslationTitle',
description: `diagnostics.missingTranslationDescription:${item.labelKey}`,
affectedComponent: `navigation.header:${item.id}`,
suggestedResolution: 'diagnostics.missingTranslationResolution'
});
}
}
}
private validateMissingMedia(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
if (!bootstrap.branding?.logoUrl) {
entries.push({
code: 'BOOTSTRAP_MISSING_MEDIA',
severity: 'warning',
title: 'diagnostics.missingMediaTitle',
description: 'diagnostics.missingMediaDescription:branding.logoUrl',
affectedComponent: 'branding',
suggestedResolution: 'diagnostics.missingMediaResolution'
});
}
if (!bootstrap.branding?.faviconUrl) {
entries.push({
code: 'BOOTSTRAP_MISSING_MEDIA',
severity: 'warning',
title: 'diagnostics.missingMediaTitle',
description: 'diagnostics.missingMediaDescription:branding.faviconUrl',
affectedComponent: 'branding',
suggestedResolution: 'diagnostics.missingMediaResolution'
});
}
}
}

View File

@@ -0,0 +1,12 @@
import { DiagnosticEntry } from '../models/diagnostics.model';
export function calculateHealthScore(entries: DiagnosticEntry[]): number {
const penalties = entries.reduce((acc, entry) => {
if (entry.severity === 'critical') return acc + 15;
if (entry.severity === 'error') return acc + 8;
if (entry.severity === 'warning') return acc + 3;
return acc;
}, 0);
return Math.max(0, 100 - penalties);
}

View File

@@ -0,0 +1,164 @@
import { DOCUMENT } from '@angular/common';
import { Inject } from '@angular/core';
import { Router } from '@angular/router';
import { environment } from '../../../../environments/environment';
import { RuntimeProviderStrategyService } from '../../../core/providers/runtime-provider-strategy.service';
import { PlatformRuntimeStateService } from '../../../core/runtime/platform-runtime-state.service';
import { RuntimeDiagnosticsService } from '../../../core/runtime/runtime-diagnostics.service';
import { BootstrapConfig } from '../../../shared/models/config';
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
import { DiagnosticEntry } from '../models/diagnostics.model';
export class RuntimeDiagnosticsValidator {
constructor(
private readonly runtimeState: PlatformRuntimeStateService,
private readonly runtimeDiagnostics: RuntimeDiagnosticsService,
private readonly strategy: RuntimeProviderStrategyService,
private readonly router: Router,
@Inject(DOCUMENT) private readonly document: Document
) {}
validate(bootstrap: BootstrapConfig | null, manifest: WidgetManifestFile | null): DiagnosticEntry[] {
const entries: DiagnosticEntry[] = [];
if (!this.runtimeState.initialized()) {
entries.push({
code: 'RUNTIME_NOT_INITIALIZED',
severity: 'critical',
title: 'diagnostics.runtimeNotInitializedTitle',
description: 'diagnostics.runtimeNotInitializedDescription',
affectedComponent: 'PlatformRuntimeService',
suggestedResolution: 'diagnostics.runtimeNotInitializedResolution'
});
}
this.validateConfigFallbackUsage(entries);
this.validateDatasourceCoverage(bootstrap, manifest, entries);
this.validateWidgetFailures(entries);
this.validateBrokenRoutes(entries);
this.validateImageFailures(entries);
this.validateMissingOptionalData(bootstrap, entries);
return entries;
}
private validateConfigFallbackUsage(entries: DiagnosticEntry[]): void {
const mode = this.strategy.getBootstrapProviderMode();
if (mode === 'mock' && !environment.production) {
entries.push({
code: 'RUNTIME_FALLBACK_USAGE',
severity: 'info',
title: 'diagnostics.configFallbackUsageTitle',
description: 'diagnostics.configFallbackUsageDescription',
affectedComponent: 'RuntimeProviderStrategyService',
suggestedResolution: 'diagnostics.configFallbackUsageResolution'
});
}
}
private validateDatasourceCoverage(bootstrap: BootstrapConfig | null, manifest: WidgetManifestFile | null, entries: DiagnosticEntry[]): void {
if (!bootstrap || !manifest) {
return;
}
const manifestByType = new Map(manifest.widgets.map(widget => [widget.type, widget]));
for (const page of bootstrap.pages ?? []) {
for (const section of page.sections ?? []) {
for (const widget of section.widgets ?? []) {
const definition = manifestByType.get(widget.type);
if (!definition) {
continue;
}
const requiresDataSource = definition.supportedDataSources.length > 0;
const hasDataSource = typeof widget.props?.['source'] === 'string' || typeof definition.defaultSettings?.['source'] === 'string';
if (requiresDataSource && !hasDataSource) {
entries.push({
code: 'RUNTIME_MISSING_DATASOURCE',
severity: 'warning',
title: 'diagnostics.missingDatasourceTitle',
description: `diagnostics.missingDatasourceDescription:${widget.id}`,
affectedComponent: `page:${page.key}/section:${section.id}`,
suggestedResolution: 'diagnostics.missingDatasourceResolution'
});
}
}
}
}
}
private validateWidgetFailures(entries: DiagnosticEntry[]): void {
for (const event of this.runtimeDiagnostics.getUnknownWidgetEvents()) {
entries.push({
code: 'RUNTIME_WIDGET_RENDER_FAILURE',
severity: 'error',
title: 'diagnostics.widgetRenderFailureTitle',
description: `diagnostics.widgetRenderFailureDescription:${event.widget}`,
affectedComponent: `page:${event.page}/section:${event.section}`,
suggestedResolution: 'diagnostics.widgetRenderFailureResolution'
});
}
}
private validateBrokenRoutes(entries: DiagnosticEntry[]): void {
const routePaths = this.router.config
.flatMap(route => route.children?.length ? route.children : [route])
.map(route => route.path)
.filter((path): path is string => typeof path === 'string');
if (!routePaths.includes('search')) {
entries.push({
code: 'RUNTIME_BROKEN_ROUTE',
severity: 'error',
title: 'diagnostics.brokenRouteTitle',
description: 'diagnostics.brokenRouteDescription:/search',
affectedComponent: 'Router',
suggestedResolution: 'diagnostics.brokenRouteResolution'
});
}
}
private validateImageFailures(entries: DiagnosticEntry[]): void {
const failedImages = Array.from(this.document.images).filter(image => image.complete && image.naturalWidth === 0);
for (const image of failedImages.slice(0, 10)) {
entries.push({
code: 'RUNTIME_IMAGE_LOAD_FAILED',
severity: 'warning',
title: 'diagnostics.imageLoadFailedTitle',
description: `diagnostics.imageLoadFailedDescription:${image.currentSrc || image.src}`,
affectedComponent: 'DOM',
suggestedResolution: 'diagnostics.imageLoadFailedResolution'
});
}
}
private validateMissingOptionalData(bootstrap: BootstrapConfig | null, entries: DiagnosticEntry[]): void {
if (!bootstrap) {
return;
}
if (!bootstrap.branding?.supportEmail) {
entries.push({
code: 'RUNTIME_OPTIONAL_DATA_MISSING',
severity: 'info',
title: 'diagnostics.optionalDataMissingTitle',
description: 'diagnostics.optionalDataMissingDescription:branding.supportEmail',
affectedComponent: 'branding',
suggestedResolution: 'diagnostics.optionalDataMissingResolution'
});
}
if (!bootstrap.branding?.logoCompactUrl) {
entries.push({
code: 'RUNTIME_OPTIONAL_DATA_MISSING',
severity: 'info',
title: 'diagnostics.optionalDataMissingTitle',
description: 'diagnostics.optionalDataMissingDescription:branding.logoCompactUrl',
affectedComponent: 'branding',
suggestedResolution: 'diagnostics.optionalDataMissingResolution'
});
}
}
}