feat(diagnostics): add health engine
This commit is contained in:
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user