diff --git a/docs/Diagnostics.md b/docs/Diagnostics.md new file mode 100644 index 0000000..7fc750e --- /dev/null +++ b/docs/Diagnostics.md @@ -0,0 +1,127 @@ +# Marketplace Diagnostics & Health Engine - Sprint 14 + +## Scope + +Sprint 14 adds a development-only diagnostics feature for marketplace configuration and runtime health. + +Constraints respected: +- No runtime behavior changes +- No business logic changes +- No authentication changes +- No payment changes + +## Architecture + +```text +src/app/features/diagnostics/ + components/ + diagnostics-page.component.* + services/ + diagnostics-logger.service.ts + models/ + diagnostics.model.ts + validators/ + bootstrap-diagnostics.validator.ts + runtime-diagnostics.validator.ts + diagnostics-health-score.util.ts + facade/ + diagnostics.facade.ts +``` + +## Health Checks + +Current checks cover: +- bootstrap loaded +- tenant resolved +- runtime initialized +- theme loaded +- widget manifest loaded +- section engine reachable +- configuration engine initialized +- translations available +- required assets health + +## Bootstrap Validation + +Current validator detects: +- missing required properties +- unknown widget types +- duplicate page/section/widget ids +- unknown layout values +- invalid feature flags +- broken page definitions +- invalid navigation targets +- missing translations in navigation label keys +- missing branding media + +## Runtime Validation + +Current validator detects: +- missing datasource declarations for widgets that support them +- widget rendering failures from runtime diagnostics stream +- broken route expectations +- configuration fallback usage in local development +- failed image loading where DOM can observe it +- missing optional data + +## Severity Model + +Each entry includes: +- code +- severity +- title +- description +- affected component +- suggested resolution + +Levels: +- info +- warning +- error +- critical + +## Health Score + +Simple weighted score: +- critical: -15 +- error: -8 +- warning: -3 +- info: 0 + +Minimum 0, maximum 100. + +## Developer Page + +Development-only route: +- `/__diagnostics` + +Page shows: +- summary +- health score +- passed checks +- warnings/errors/critical counts +- detailed diagnostics entries + +## Logging Abstraction + +`DiagnosticsLoggerService` stores diagnostic entries in memory. + +`RuntimeDiagnosticsService` now keeps unknown widget events in memory for diagnostics consumption only. + +No external logging integration in this sprint. + +## Extension Points + +Future additions can plug into: +- new validators under `validators/` +- new runtime event collectors +- remote diagnostics export service +- monitoring integrations (Sentry, Datadog, Grafana, OpenTelemetry) +- asset/network checks +- widget render timing checks + +## Performance + +Production route excluded. + +Diagnostics work runs only on diagnostics page access in development mode. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 0c3b11d..4345f39 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; import { languageGuard } from './guards/language.guard'; +import { environment } from '../environments/environment'; // Core routes (same across all brands) const coreRoutes: Routes = [ @@ -67,6 +68,10 @@ const cmsContentRoutes: Routes = []; // All routes sit under a :lang prefix (e.g. /ru/cart, /en/product/5) export const routes: Routes = [ + ...(environment.production ? [] : [{ + path: '__diagnostics', + loadComponent: () => import('./features/diagnostics/components/diagnostics-page.component').then(m => m.DiagnosticsPageComponent) + }]), { path: ':lang', canActivate: [languageGuard], diff --git a/src/app/core/runtime/runtime-diagnostics.service.ts b/src/app/core/runtime/runtime-diagnostics.service.ts index 94fe83e..ce9c9e9 100644 --- a/src/app/core/runtime/runtime-diagnostics.service.ts +++ b/src/app/core/runtime/runtime-diagnostics.service.ts @@ -10,7 +10,16 @@ export interface UnknownWidgetDiagnostic { @Injectable({ providedIn: 'root' }) export class RuntimeDiagnosticsService { + private readonly unknownWidgetEvents: UnknownWidgetDiagnostic[] = []; + logUnknownWidget(event: UnknownWidgetDiagnostic): void { - void event; + this.unknownWidgetEvents.unshift(event); + if (this.unknownWidgetEvents.length > 100) { + this.unknownWidgetEvents.length = 100; + } + } + + getUnknownWidgetEvents(): UnknownWidgetDiagnostic[] { + return [...this.unknownWidgetEvents]; } } diff --git a/src/app/features/diagnostics/components/diagnostics-page.component.html b/src/app/features/diagnostics/components/diagnostics-page.component.html new file mode 100644 index 0000000..142d233 --- /dev/null +++ b/src/app/features/diagnostics/components/diagnostics-page.component.html @@ -0,0 +1,69 @@ +@if (!devMode) { +
+
+

{{ 'diagnostics.disabledTitle' | translate }}

+

{{ 'diagnostics.disabledDescription' | translate }}

+
+
+} @else { +
+ @if (report$ | async; as report) { +
+
+

{{ 'diagnostics.title' | translate }}

+

{{ 'diagnostics.generatedAt' | translate:{ value: report.generatedAt } }}

+
+
+ {{ report.summary.score }}% + {{ 'diagnostics.healthScore' | translate }} +
+
+ +
+
+

{{ 'diagnostics.summary' | translate }}

+
    +
  • {{ 'diagnostics.passedChecks' | translate:{ count: report.summary.passedChecks } }}
  • +
  • {{ 'diagnostics.warnings' | translate:{ count: report.summary.warningCount } }}
  • +
  • {{ 'diagnostics.errors' | translate:{ count: report.summary.errorCount } }}
  • +
  • {{ 'diagnostics.critical' | translate:{ count: report.summary.criticalCount } }}
  • +
+
+ +
+

{{ 'diagnostics.healthChecks' | translate }}

+
    + @for (check of report.checks; track check.key) { +
  • + {{ ('diagnostics.checks.' + check.key) | translate }} + {{ check.passed ? ('diagnostics.ok' | translate) : ('diagnostics.failed' | translate) }} +
  • + } +
+
+
+ +
+

{{ 'diagnostics.results' | translate }}

+ @if (report.entries.length === 0) { +

{{ 'diagnostics.noIssues' | translate }}

+ } @else { +
+ @for (entry of report.entries; track entry.code + entry.affectedComponent + entry.description) { +
+
+ {{ ('diagnostics.severity.' + entry.severity) | translate }} + {{ entry.code }} +
+

{{ tDiagnostic(entry.title) }}

+

{{ tDiagnostic(entry.description) }}

+

{{ 'diagnostics.affectedComponent' | translate }}: {{ entry.affectedComponent }}

+

{{ 'diagnostics.suggestedResolution' | translate }}: {{ tDiagnostic(entry.suggestedResolution) }}

+
+ } +
+ } +
+ } +
+} diff --git a/src/app/features/diagnostics/components/diagnostics-page.component.scss b/src/app/features/diagnostics/components/diagnostics-page.component.scss new file mode 100644 index 0000000..4bf9f88 --- /dev/null +++ b/src/app/features/diagnostics/components/diagnostics-page.component.scss @@ -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; + } +} diff --git a/src/app/features/diagnostics/components/diagnostics-page.component.ts b/src/app/features/diagnostics/components/diagnostics-page.component.ts new file mode 100644 index 0000000..ebb53d8 --- /dev/null +++ b/src/app/features/diagnostics/components/diagnostics-page.component.ts @@ -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; + } +} diff --git a/src/app/features/diagnostics/facade/diagnostics.facade.ts b/src/app/features/diagnostics/facade/diagnostics.facade.ts new file mode 100644 index 0000000..a869350 --- /dev/null +++ b/src/app/features/diagnostics/facade/diagnostics.facade.ts @@ -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); + } +} diff --git a/src/app/features/diagnostics/models/diagnostics.model.ts b/src/app/features/diagnostics/models/diagnostics.model.ts new file mode 100644 index 0000000..0009385 --- /dev/null +++ b/src/app/features/diagnostics/models/diagnostics.model.ts @@ -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[]; +} diff --git a/src/app/features/diagnostics/services/diagnostics-logger.service.ts b/src/app/features/diagnostics/services/diagnostics-logger.service.ts new file mode 100644 index 0000000..0889882 --- /dev/null +++ b/src/app/features/diagnostics/services/diagnostics-logger.service.ts @@ -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([]); + + readonly logs = this.logsState.asReadonly(); + + log(entry: DiagnosticEntry): void { + this.logsState.update(current => [entry, ...current].slice(0, 300)); + } + + clear(): void { + this.logsState.set([]); + } +} diff --git a/src/app/features/diagnostics/validators/bootstrap-diagnostics.validator.ts b/src/app/features/diagnostics/validators/bootstrap-diagnostics.validator.ts new file mode 100644 index 0000000..0e95c3d --- /dev/null +++ b/src/app/features/diagnostics/validators/bootstrap-diagnostics.validator.ts @@ -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(); + + 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(); + 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(); + 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' + }); + } + } +} diff --git a/src/app/features/diagnostics/validators/diagnostics-health-score.util.ts b/src/app/features/diagnostics/validators/diagnostics-health-score.util.ts new file mode 100644 index 0000000..65cc729 --- /dev/null +++ b/src/app/features/diagnostics/validators/diagnostics-health-score.util.ts @@ -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); +} diff --git a/src/app/features/diagnostics/validators/runtime-diagnostics.validator.ts b/src/app/features/diagnostics/validators/runtime-diagnostics.validator.ts new file mode 100644 index 0000000..e102519 --- /dev/null +++ b/src/app/features/diagnostics/validators/runtime-diagnostics.validator.ts @@ -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' + }); + } + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index e481f1c..a6c65ec 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -468,4 +468,93 @@ export const en: Translations = { compareSize: 'Size', compareUnknown: 'unknown', }, + diagnostics: { + title: 'Marketplace Diagnostics', + disabledTitle: 'Diagnostics unavailable', + disabledDescription: 'Diagnostics page is available only in development mode.', + generatedAt: 'Generated at {{value}}', + healthScore: 'Health score', + summary: 'Summary', + passedChecks: '{{count}} passed checks', + warnings: '{{count}} warnings', + errors: '{{count}} errors', + critical: '{{count}} critical', + healthChecks: 'Health checks', + results: 'Detailed results', + noIssues: 'No diagnostics issues found.', + ok: 'OK', + failed: 'Failed', + affectedComponent: 'Affected component', + suggestedResolution: 'Suggested resolution', + severity: { + info: 'Info', + warning: 'Warning', + error: 'Error', + critical: 'Critical', + }, + checks: { + bootstrapLoaded: 'Bootstrap loaded', + tenantResolved: 'Tenant resolved', + runtimeInitialized: 'Runtime initialized', + themeLoaded: 'Theme loaded', + widgetManifestLoaded: 'Widget manifest loaded', + sectionEngineInitialized: 'Section engine reachable', + configurationEngineInitialized: 'Configuration engine initialized', + translationResourcesAvailable: 'Translations available', + requiredAssetsAvailable: 'Required assets healthy', + }, + bootstrapMissingTitle: 'Bootstrap missing', + bootstrapMissingDescription: 'Bootstrap configuration could not be loaded.', + bootstrapMissingResolution: 'Verify bootstrap provider response and local mock availability.', + requiredPropertyMissingTitle: 'Required property missing', + requiredPropertyMissingDescription: 'Required bootstrap property is missing.', + requiredPropertyMissingResolution: 'Add missing property to bootstrap payload.', + unknownWidgetTypeTitle: 'Unknown widget type', + unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.', + unknownWidgetTypeResolution: 'Register widget in manifest or remove invalid widget config.', + duplicateIdTitle: 'Duplicate identifier', + duplicatePageIdDescription: 'Duplicate page id detected.', + duplicateSectionIdDescription: 'Duplicate section id detected.', + duplicateWidgetIdDescription: 'Duplicate widget id detected.', + duplicateIdResolution: 'Ensure ids are unique within bootstrap config.', + unknownLayoutTitle: 'Unknown layout', + unknownLayoutDescription: 'Page layout value is not recognized.', + unknownLayoutResolution: 'Use supported layout or extend diagnostics allow-list.', + invalidFeatureFlagTitle: 'Invalid feature flag', + invalidFeatureFlagDescription: 'Feature flag value must be boolean.', + invalidFeatureFlagResolution: 'Normalize feature flag values to true or false.', + brokenPageDefinitionTitle: 'Broken page definition', + brokenPageDefinitionDescription: 'Page definition is incomplete.', + brokenPageDefinitionResolution: 'Add missing route, title, key, or sections.', + invalidNavigationTargetTitle: 'Invalid navigation target', + invalidNavigationTargetDescription: 'Navigation route does not match known routes.', + invalidNavigationTargetResolution: 'Update route target or add matching route surface.', + missingTranslationTitle: 'Missing translation', + missingTranslationDescription: 'Translation key resolves to itself.', + missingTranslationResolution: 'Add missing key to translation resources.', + missingMediaTitle: 'Missing media', + missingMediaDescription: 'Expected branding media is missing.', + missingMediaResolution: 'Provide valid media URLs in bootstrap branding config.', + runtimeNotInitializedTitle: 'Runtime not initialized', + runtimeNotInitializedDescription: 'Platform runtime state has not been marked initialized.', + runtimeNotInitializedResolution: 'Verify PlatformRuntimeService initialize sequence.', + configFallbackUsageTitle: 'Configuration fallback in use', + configFallbackUsageDescription: 'Development runtime is using fallback or mock configuration.', + configFallbackUsageResolution: 'Switch provider mode if real bootstrap validation is needed.', + missingDatasourceTitle: 'Missing datasource', + missingDatasourceDescription: 'Widget supports datasource selection but none was declared.', + missingDatasourceResolution: 'Define source in widget props or manifest default settings.', + widgetRenderFailureTitle: 'Widget render failure', + widgetRenderFailureDescription: 'Widget fell back to unknown widget renderer.', + widgetRenderFailureResolution: 'Register widget component or fix manifest component key.', + brokenRouteTitle: 'Broken route expectation', + brokenRouteDescription: 'Expected route surface is missing.', + brokenRouteResolution: 'Restore route registration or update diagnostics expectation.', + imageLoadFailedTitle: 'Image load failed', + imageLoadFailedDescription: 'Rendered image failed to load.', + imageLoadFailedResolution: 'Verify asset path, CORS, and media availability.', + optionalDataMissingTitle: 'Optional data missing', + optionalDataMissingDescription: 'Optional configuration data is absent.', + optionalDataMissingResolution: 'Add optional value if UX depends on it.', + }, }; diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 554353a..92376ea 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -468,4 +468,88 @@ export const hy: Translations = { compareSize: 'Չափ', compareUnknown: 'անհայտ', }, + diagnostics: { + title: 'Մարքեթփլեյսի ախտորոշում', + disabledTitle: 'Ախտորոշումը հասանելի չէ', + disabledDescription: 'Ախտորոշման էջը հասանելի է միայն development ռեժիմում։', + generatedAt: 'Ստեղծվել է {{value}}', + healthScore: 'Առողջության գնահատական', + summary: 'Ամփոփում', + passedChecks: '{{count}} հաջող ստուգում', + warnings: '{{count}} նախազգուշացում', + errors: '{{count}} սխալ', + critical: '{{count}} կրիտիկական', + healthChecks: 'Առողջության ստուգումներ', + results: 'Մանրամասն արդյունքներ', + noIssues: 'Ախտորոշման խնդիրներ չեն գտնվել։', + ok: 'Լավ', + failed: 'Խափանվեց', + affectedComponent: 'Վնասված բաղադրիչ', + suggestedResolution: 'Առաջարկվող լուծում', + severity: { info: 'Տեղեկ.', warning: 'Զգուշ.', error: 'Սխալ', critical: 'Կրիտիկական' }, + checks: { + bootstrapLoaded: 'Bootstrap բեռնված է', + tenantResolved: 'Tenant որոշված է', + runtimeInitialized: 'Runtime-ը մեկնարկված է', + themeLoaded: 'Թեման բեռնված է', + widgetManifestLoaded: 'Widget manifest-ը բեռնված է', + sectionEngineInitialized: 'Section Engine-ը հասանելի է', + configurationEngineInitialized: 'Configuration Engine-ը մեկնարկված է', + translationResourcesAvailable: 'Թարգմանությունները հասանելի են', + requiredAssetsAvailable: 'Պարտադիր assets-ը հասանելի են', + }, + bootstrapMissingTitle: 'Bootstrap-ը բացակայում է', + bootstrapMissingDescription: 'Bootstrap կարգավորումը չի բեռնվել։', + bootstrapMissingResolution: 'Ստուգեք bootstrap provider-ը և local mock-ը։', + requiredPropertyMissingTitle: 'Պարտադիր հատկությունը բացակայում է', + requiredPropertyMissingDescription: 'Bootstrap-ում պարտադիր հատկություն չկա։', + requiredPropertyMissingResolution: 'Ավելացրեք հատկությունը bootstrap payload-ում։', + unknownWidgetTypeTitle: 'Անհայտ widget type', + unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։', + unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։', + duplicateIdTitle: 'Կրկնվող ID', + duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։', + duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։', + duplicateWidgetIdDescription: 'Գտնվել է կրկնվող widget id։', + duplicateIdResolution: 'Bootstrap config-ում ID-ները դարձրեք եզակի։', + unknownLayoutTitle: 'Անհայտ layout', + unknownLayoutDescription: 'Էջի layout արժեքը ճանաչված չէ։', + unknownLayoutResolution: 'Օգտագործեք աջակցվող layout կամ ընդլայնեք allow-list-ը։', + invalidFeatureFlagTitle: 'Սխալ feature flag', + invalidFeatureFlagDescription: 'Feature flag արժեքը պետք է boolean լինի։', + invalidFeatureFlagResolution: 'Նորմալացրեք արժեքը true կամ false։', + brokenPageDefinitionTitle: 'Կոտրված page definition', + brokenPageDefinitionDescription: 'Էջի նկարագրությունը թերի է։', + brokenPageDefinitionResolution: 'Ավելացրեք route, title, key կամ sections։', + invalidNavigationTargetTitle: 'Սխալ navigation target', + invalidNavigationTargetDescription: 'Navigation route-ը չի համընկնում հայտնի route-ների հետ։', + invalidNavigationTargetResolution: 'Թարմացրեք route target-ը կամ ավելացրեք route։', + missingTranslationTitle: 'Թարգմանությունը բացակայում է', + missingTranslationDescription: 'Թարգմանության key-ը չի թարգմանվում։', + missingTranslationResolution: 'Ավելացրեք key-ը translation resources-ում։', + missingMediaTitle: 'Media-ն բացակայում է', + missingMediaDescription: 'Սպասվող branding media-ն բացակայում է։', + missingMediaResolution: 'Տրամադրեք ճիշտ media URL-ներ branding config-ում։', + runtimeNotInitializedTitle: 'Runtime-ը մեկնարկված չէ', + runtimeNotInitializedDescription: 'Platform runtime state-ը initialized չէ։', + runtimeNotInitializedResolution: 'Ստուգեք PlatformRuntimeService initialize շղթան։', + configFallbackUsageTitle: 'Օգտագործվում է fallback config', + configFallbackUsageDescription: 'Development runtime-ը օգտագործում է fallback կամ mock config։', + configFallbackUsageResolution: 'Փոխեք provider mode-ը, եթե պետք է իրական bootstrap ստուգում։', + missingDatasourceTitle: 'Datasource-ը բացակայում է', + missingDatasourceDescription: 'Widget-ը աջակցում է datasource, բայց այն նշված չէ։', + missingDatasourceResolution: 'Նշեք source widget props-ում կամ manifest default settings-ում։', + widgetRenderFailureTitle: 'Widget render failure', + widgetRenderFailureDescription: 'Widget-ը անցել է unknown widget renderer-ի։', + widgetRenderFailureResolution: 'Գրանցեք widget component-ը կամ ուղղեք manifest component key-ը։', + brokenRouteTitle: 'Կոտրված route սպասում', + brokenRouteDescription: 'Սպասվող route-ը բացակայում է։', + brokenRouteResolution: 'Վերականգնեք route registration-ը կամ թարմացրեք diagnostics կանոնը։', + imageLoadFailedTitle: 'Նկարի բեռնումը ձախողվեց', + imageLoadFailedDescription: 'Արտապատկերվող նկարը չի բեռնվել։', + imageLoadFailedResolution: 'Ստուգեք asset path-ը, CORS-ը և media հասանելիությունը։', + optionalDataMissingTitle: 'Optional data բացակայում է', + optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։', + optionalDataMissingResolution: 'Ավելացրեք արժեքը, եթե UX-ը կախված է դրանից։', + }, }; diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 5e10485..15ce042 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -468,4 +468,88 @@ export const ru: Translations = { compareSize: 'Размер', compareUnknown: 'неизвестно', }, + diagnostics: { + title: 'Диагностика маркетплейса', + disabledTitle: 'Диагностика недоступна', + disabledDescription: 'Страница диагностики доступна только в режиме разработки.', + generatedAt: 'Сформировано: {{value}}', + healthScore: 'Оценка состояния', + summary: 'Сводка', + passedChecks: '{{count}} успешных проверок', + warnings: '{{count}} предупреждений', + errors: '{{count}} ошибок', + critical: '{{count}} критических', + healthChecks: 'Проверки состояния', + results: 'Подробные результаты', + noIssues: 'Проблем диагностики не найдено.', + ok: 'OK', + failed: 'Ошибка', + affectedComponent: 'Затронутый компонент', + suggestedResolution: 'Рекомендация', + severity: { info: 'Инфо', warning: 'Предупреждение', error: 'Ошибка', critical: 'Критично' }, + checks: { + bootstrapLoaded: 'Bootstrap загружен', + tenantResolved: 'Tenant определен', + runtimeInitialized: 'Runtime инициализирован', + themeLoaded: 'Тема загружена', + widgetManifestLoaded: 'Манифест виджетов загружен', + sectionEngineInitialized: 'Section Engine доступен', + configurationEngineInitialized: 'Configuration Engine инициализирован', + translationResourcesAvailable: 'Переводы доступны', + requiredAssetsAvailable: 'Обязательные assets доступны', + }, + bootstrapMissingTitle: 'Bootstrap отсутствует', + bootstrapMissingDescription: 'Конфигурация bootstrap не загружена.', + bootstrapMissingResolution: 'Проверьте provider bootstrap и локальный mock.', + requiredPropertyMissingTitle: 'Отсутствует обязательное свойство', + requiredPropertyMissingDescription: 'В bootstrap отсутствует обязательное свойство.', + requiredPropertyMissingResolution: 'Добавьте свойство в bootstrap payload.', + unknownWidgetTypeTitle: 'Неизвестный тип виджета', + unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.', + unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.', + duplicateIdTitle: 'Дублирующийся идентификатор', + duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.', + duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.', + duplicateWidgetIdDescription: 'Обнаружен дублирующийся id виджета.', + duplicateIdResolution: 'Сделайте id уникальными в bootstrap конфигурации.', + unknownLayoutTitle: 'Неизвестный layout', + unknownLayoutDescription: 'Значение layout страницы не распознано.', + unknownLayoutResolution: 'Используйте поддерживаемый layout или расширьте allow-list.', + invalidFeatureFlagTitle: 'Некорректный feature flag', + invalidFeatureFlagDescription: 'Значение feature flag должно быть boolean.', + invalidFeatureFlagResolution: 'Нормализуйте значение к true или false.', + brokenPageDefinitionTitle: 'Некорректное описание страницы', + brokenPageDefinitionDescription: 'Описание страницы неполное.', + brokenPageDefinitionResolution: 'Добавьте route, title, key или sections.', + invalidNavigationTargetTitle: 'Некорректная цель навигации', + invalidNavigationTargetDescription: 'Route навигации не совпадает с известными route.', + invalidNavigationTargetResolution: 'Исправьте target route или добавьте route.', + missingTranslationTitle: 'Отсутствует перевод', + missingTranslationDescription: 'Ключ перевода не разрешается.', + missingTranslationResolution: 'Добавьте ключ в translation resources.', + missingMediaTitle: 'Отсутствует media', + missingMediaDescription: 'Ожидаемый branding media ресурс отсутствует.', + missingMediaResolution: 'Укажите корректные media URL в branding конфиге.', + runtimeNotInitializedTitle: 'Runtime не инициализирован', + runtimeNotInitializedDescription: 'Platform runtime state не помечен как initialized.', + runtimeNotInitializedResolution: 'Проверьте цепочку initialize в PlatformRuntimeService.', + configFallbackUsageTitle: 'Используется fallback конфигурация', + configFallbackUsageDescription: 'Runtime разработки использует fallback или mock конфигурацию.', + configFallbackUsageResolution: 'Смените provider mode, если нужна проверка реального bootstrap.', + missingDatasourceTitle: 'Отсутствует datasource', + missingDatasourceDescription: 'Виджет поддерживает datasource, но он не задан.', + missingDatasourceResolution: 'Укажите source в props виджета или default settings manifest.', + widgetRenderFailureTitle: 'Сбой рендера виджета', + widgetRenderFailureDescription: 'Виджет откатился на unknown widget renderer.', + widgetRenderFailureResolution: 'Зарегистрируйте компонент виджета или исправьте component key.', + brokenRouteTitle: 'Нарушено ожидание route', + brokenRouteDescription: 'Ожидаемый route отсутствует.', + brokenRouteResolution: 'Верните регистрацию route или обновите правило диагностики.', + imageLoadFailedTitle: 'Ошибка загрузки изображения', + imageLoadFailedDescription: 'Отображаемое изображение не загрузилось.', + imageLoadFailedResolution: 'Проверьте asset path, CORS и доступность media.', + optionalDataMissingTitle: 'Отсутствуют необязательные данные', + optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.', + optionalDataMissingResolution: 'Добавьте значение, если оно нужно UX.', + }, }; diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 1fe51df..e66a789 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -466,4 +466,93 @@ export interface Translations { compareSize: string; compareUnknown: string; }; + diagnostics: { + title: string; + disabledTitle: string; + disabledDescription: string; + generatedAt: string; + healthScore: string; + summary: string; + passedChecks: string; + warnings: string; + errors: string; + critical: string; + healthChecks: string; + results: string; + noIssues: string; + ok: string; + failed: string; + affectedComponent: string; + suggestedResolution: string; + severity: { + info: string; + warning: string; + error: string; + critical: string; + }; + checks: { + bootstrapLoaded: string; + tenantResolved: string; + runtimeInitialized: string; + themeLoaded: string; + widgetManifestLoaded: string; + sectionEngineInitialized: string; + configurationEngineInitialized: string; + translationResourcesAvailable: string; + requiredAssetsAvailable: string; + }; + bootstrapMissingTitle: string; + bootstrapMissingDescription: string; + bootstrapMissingResolution: string; + requiredPropertyMissingTitle: string; + requiredPropertyMissingDescription: string; + requiredPropertyMissingResolution: string; + unknownWidgetTypeTitle: string; + unknownWidgetTypeDescription: string; + unknownWidgetTypeResolution: string; + duplicateIdTitle: string; + duplicatePageIdDescription: string; + duplicateSectionIdDescription: string; + duplicateWidgetIdDescription: string; + duplicateIdResolution: string; + unknownLayoutTitle: string; + unknownLayoutDescription: string; + unknownLayoutResolution: string; + invalidFeatureFlagTitle: string; + invalidFeatureFlagDescription: string; + invalidFeatureFlagResolution: string; + brokenPageDefinitionTitle: string; + brokenPageDefinitionDescription: string; + brokenPageDefinitionResolution: string; + invalidNavigationTargetTitle: string; + invalidNavigationTargetDescription: string; + invalidNavigationTargetResolution: string; + missingTranslationTitle: string; + missingTranslationDescription: string; + missingTranslationResolution: string; + missingMediaTitle: string; + missingMediaDescription: string; + missingMediaResolution: string; + runtimeNotInitializedTitle: string; + runtimeNotInitializedDescription: string; + runtimeNotInitializedResolution: string; + configFallbackUsageTitle: string; + configFallbackUsageDescription: string; + configFallbackUsageResolution: string; + missingDatasourceTitle: string; + missingDatasourceDescription: string; + missingDatasourceResolution: string; + widgetRenderFailureTitle: string; + widgetRenderFailureDescription: string; + widgetRenderFailureResolution: string; + brokenRouteTitle: string; + brokenRouteDescription: string; + brokenRouteResolution: string; + imageLoadFailedTitle: string; + imageLoadFailedDescription: string; + imageLoadFailedResolution: string; + optionalDataMissingTitle: string; + optionalDataMissingDescription: string; + optionalDataMissingResolution: string; + }; }