From 7493945c76cf3e78692a3bc36796eaf64c89ef11 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 23 Jan 2026 19:32:16 +0100 Subject: [PATCH 01/14] Update header for isolated builds --- .../Assets/styles.css | 4 +--- .../Layout/_Header.cshtml | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 43f6ab17d..f47a5540c 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -122,9 +122,7 @@ body { @apply sticky top-(--offset-top) z-30 overflow-y-auto; max-height: calc(100vh - var(--offset-top)); scrollbar-gutter: stable; - transition: - top 0.3s ease, - max-height 0.3s ease; + transition: top 0.3s ease, max-height 0.3s ease; } .sidebar-link { diff --git a/src/Elastic.Documentation.Site/Layout/_Header.cshtml b/src/Elastic.Documentation.Site/Layout/_Header.cshtml index 2449bc5b0..3fff21244 100644 --- a/src/Elastic.Documentation.Site/Layout/_Header.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Header.cshtml @@ -71,4 +71,28 @@ else + } From 83dd29107b081adeaaa908d253c077056fd51d61 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 23 Jan 2026 19:39:13 +0100 Subject: [PATCH 02/14] format css js --- src/Elastic.Documentation.Site/Assets/styles.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index f47a5540c..43f6ab17d 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -122,7 +122,9 @@ body { @apply sticky top-(--offset-top) z-30 overflow-y-auto; max-height: calc(100vh - var(--offset-top)); scrollbar-gutter: stable; - transition: top 0.3s ease, max-height 0.3s ease; + transition: + top 0.3s ease, + max-height 0.3s ease; } .sidebar-link { From 6657c7008b427009057495a1ce0a7c943b2b2e20 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 26 Jan 2026 12:42:38 +0100 Subject: [PATCH 03/14] Refactor: Move isolated header logic to a dedicated module and update initialization flow --- .../Layout/_Header.cshtml | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/Elastic.Documentation.Site/Layout/_Header.cshtml b/src/Elastic.Documentation.Site/Layout/_Header.cshtml index 3fff21244..2449bc5b0 100644 --- a/src/Elastic.Documentation.Site/Layout/_Header.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Header.cshtml @@ -71,28 +71,4 @@ else - } From 44d137882b457f904f58de6cc3988e8d23f3f573 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 26 Jan 2026 18:46:31 +0100 Subject: [PATCH 04/14] Add diagnostics HUD with real-time SSE streaming for serve mode Features: - Add segmented diagnostics pill in header showing [icon] [count] [name] per severity - Each segment has animated gradient background matching severity color - Segments with count 0 are automatically hidden - Add diagnostics HUD panel with filter toggle buttons for errors/warnings/hints - Add "All" button when any filter is inactive - Sort diagnostics by severity: errors > warnings > hints - Add copy button for each diagnostic row - Stream diagnostics via SSE with broadcast pattern for multiple clients - Run in-memory validation build in parallel with live reload - Optimize builds by reusing MockFileSystem and only regenerating OpenAPI when specs change - Add --skip-api flag to docs-builder command for faster builds --- .../Builder/FeatureFlags.cs | 6 + src/Elastic.Documentation.Site/Assets/main.ts | 1 + .../Assets/styles.css | 99 +++++ .../Diagnostics/DiagnosticsButton.tsx | 206 +++++++++ .../Diagnostics/DiagnosticsComponent.tsx | 31 ++ .../Diagnostics/DiagnosticsHud.tsx | 360 ++++++++++++++++ .../Diagnostics/diagnostics.store.ts | 90 ++++ .../Diagnostics/diagnosticsStreamClient.ts | 171 ++++++++ .../Layout/_Header.cshtml | 6 +- .../tailwind.config.js | 6 +- .../DocumentationGenerator.cs | 30 +- .../IsolatedBuildService.cs | 27 +- .../Commands/IsolatedBuildCommand.cs | 11 +- .../Http/DiagnosticsJsonContext.cs | 13 + .../docs-builder/Http/DocumentationWebHost.cs | 63 ++- .../docs-builder/Http/InMemoryBuildState.cs | 407 ++++++++++++++++++ .../Http/ReloadGeneratorService.cs | 43 +- .../Http/ReloadableGeneratorState.cs | 45 +- 18 files changed, 1586 insertions(+), 29 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsButton.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsComponent.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsHud.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnostics.store.ts create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnosticsStreamClient.ts create mode 100644 src/tooling/docs-builder/Http/DiagnosticsJsonContext.cs create mode 100644 src/tooling/docs-builder/Http/InMemoryBuildState.cs diff --git a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs index 6bff6349a..9da3dee24 100644 --- a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs +++ b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs @@ -38,6 +38,12 @@ public bool StagingElasticNavEnabled set => _featureFlags["staging-elastic-nav"] = value; } + public bool DiagnosticsPanelEnabled + { + get => IsEnabled("diagnostics-panel"); + set => _featureFlags["diagnostics-panel"] = value; + } + private bool IsEnabled(string key) { var envKey = $"FEATURE_{key.ToUpperInvariant().Replace('-', '_')}"; diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 4452351cf..df75ef8ca 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -37,6 +37,7 @@ import('./web-components/AskAi/AskAi') import('./web-components/VersionDropdown') import('./web-components/AppliesToPopover') import('./web-components/FullPageSearch/FullPageSearchComponent') +import('./web-components/Diagnostics/DiagnosticsComponent') const { getOS } = new UAParser() diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 43f6ab17d..eece23871 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -208,6 +208,105 @@ body { } } +/* Wobble animation for diagnostics button */ +@keyframes wobble { + 0%, 100% { + transform: rotate(0deg); + } + 15% { + transform: rotate(-3deg); + } + 30% { + transform: rotate(3deg); + } + 45% { + transform: rotate(-2deg); + } + 60% { + transform: rotate(2deg); + } + 75% { + transform: rotate(-1deg); + } +} + +.animate-wobble { + animation: wobble 0.8s ease-in-out; + animation-delay: 2s; + animation-iteration-count: 3; +} + +/* Animated gradient for diagnostics pill */ +@keyframes gradient-shift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +/* Diagnostics pill base styling */ +.diagnostics-pill { + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.3), + 0 1px 0 rgba(255, 255, 255, 0.1); + background-size: 200% 200%; + animation: gradient-shift 3s ease infinite; +} + +.diagnostics-pill-error { + background: linear-gradient(135deg, #dc2626, #b91c1c, #991b1b, #b91c1c, #dc2626); +} + +.diagnostics-pill-warning { + background: linear-gradient(135deg, #d97706, #b45309, #92400e, #b45309, #d97706); +} + +.diagnostics-pill-hint { + background: linear-gradient(135deg, #0077cc, #005fa3, #004d85, #005fa3, #0077cc); +} + +.diagnostics-pill-success { + background: linear-gradient(135deg, #16a34a, #15803d, #166534, #15803d, #16a34a); +} + +.diagnostics-pill-building { + background: linear-gradient(135deg, #3b82f6, #2563eb, #1d4ed8, #2563eb, #3b82f6); +} + +/* Segmented diagnostics pill */ +.diagnostics-segmented-pill { + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.2), + 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.diagnostics-segment { + background-size: 200% 200%; + animation: gradient-shift 3s ease infinite; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.diagnostics-segment-error { + background: linear-gradient(135deg, #dc2626, #b91c1c, #991b1b, #b91c1c, #dc2626); +} + +.diagnostics-segment-warning { + background: linear-gradient(135deg, #d97706, #b45309, #92400e, #b45309, #d97706); +} + +.diagnostics-segment-hint { + background: linear-gradient(135deg, #0077cc, #005fa3, #004d85, #005fa3, #0077cc); +} + +.diagnostics-segment-building { + background: linear-gradient(135deg, #3b82f6, #2563eb, #1d4ed8, #2563eb, #3b82f6); +} + #pages-nav .current { @apply text-blue-elastic! font-semibold; } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsButton.tsx new file mode 100644 index 000000000..4a2d7482f --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsButton.tsx @@ -0,0 +1,206 @@ +import * as React from 'react' +import { useDiagnosticsStore } from './diagnostics.store' + +// Animated spinner for building state +const BuildingSpinner: React.FC<{ className?: string }> = ({ className = '' }) => ( + + + + +) + +// Checkmark icon +const CheckIcon: React.FC = () => ( + + + +) + +// Error icon (X in circle) +const ErrorIcon: React.FC = () => ( + + + +) + +// Warning icon (exclamation triangle) +const WarningIcon: React.FC = () => ( + + + +) + +// Hint icon (info circle) +const HintIcon: React.FC = () => ( + + + +) + +interface SegmentProps { + icon: React.ReactNode + count: number + label: string + gradientClass: string + isFirst: boolean + isLast: boolean +} + +const Segment: React.FC = ({ + icon, + count, + label, + gradientClass, + isFirst, + isLast, +}) => ( +
+ {icon} + {count} + {label} +
+) + +export const DiagnosticsButton: React.FC = () => { + const { status, errors, warnings, hints, toggleHud, isConnected } = + useDiagnosticsStore() + + const hasIssues = errors > 0 || warnings > 0 || hints > 0 + const isBuilding = status === 'building' + + const handleClick = () => { + toggleHud() + } + + // Base pill styling for special states + const pillBase = + 'diagnostics-pill rounded-full transition-all duration-200 text-white font-medium' + + // Not connected yet, show connecting state + if (!isConnected && status === 'idle') { + return ( + + ) + } + + // Building state with no prior issues - spinner only + if (isBuilding && !hasIssues) { + return ( + + ) + } + + // Has issues - show segmented pill + if (hasIssues) { + // Build segments array for items with count > 0 + const segments: { + key: string + icon: React.ReactNode + count: number + label: string + gradientClass: string + }[] = [] + + if (errors > 0) { + segments.push({ + key: 'errors', + icon: , + count: errors, + label: 'errors', + gradientClass: 'diagnostics-segment-error', + }) + } + if (warnings > 0) { + segments.push({ + key: 'warnings', + icon: , + count: warnings, + label: 'warnings', + gradientClass: 'diagnostics-segment-warning', + }) + } + if (hints > 0) { + segments.push({ + key: 'hints', + icon: , + count: hints, + label: 'hints', + gradientClass: 'diagnostics-segment-hint', + }) + } + + return ( + + ) + } + + // All good state - green gradient with checkmark + return ( + + ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsComponent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsComponent.tsx new file mode 100644 index 000000000..3e82b8553 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsComponent.tsx @@ -0,0 +1,31 @@ +import * as React from 'react' +import { useEffect } from 'react' +import r2wc from '@r2wc/react-to-web-component' +import { DiagnosticsButton } from './DiagnosticsButton' +import { DiagnosticsHud } from './DiagnosticsHud' +import { + connectToDiagnosticsStream, + disconnectFromDiagnosticsStream, +} from './diagnosticsStreamClient' + +const DiagnosticsPanel: React.FC = () => { + useEffect(() => { + // Connect to SSE stream on mount + connectToDiagnosticsStream() + + // Disconnect on unmount + return () => { + disconnectFromDiagnosticsStream() + } + }, []) + + return ( + <> + + + + ) +} + +// Register as web component +customElements.define('diagnostics-panel', r2wc(DiagnosticsPanel)) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsHud.tsx b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsHud.tsx new file mode 100644 index 000000000..8d8873a38 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsHud.tsx @@ -0,0 +1,360 @@ +import * as React from 'react' +import { useEffect, useRef, useMemo, useCallback } from 'react' +import { useDiagnosticsStore, DiagnosticItem } from './diagnostics.store' + +// Close icon +const CloseIcon: React.FC = () => ( + + + +) + +// Copy icon +const CopyIcon: React.FC = () => ( + + + +) + +// Check icon for copy confirmation +const CheckIcon: React.FC = () => ( + + + +) + +// Error icon +const ErrorIcon: React.FC = () => ( + + + +) + +// Warning icon +const WarningIcon: React.FC = () => ( + + + +) + +// Hint icon +const HintIcon: React.FC = () => ( + + + +) + +const getSeverityStyles = (severity: DiagnosticItem['severity']) => { + switch (severity) { + case 'error': + return { + border: 'border-l-red', + bg: 'bg-red/10', + icon: 'text-red', + } + case 'warning': + return { + border: 'border-l-yellow', + bg: 'bg-yellow/10', + icon: 'text-yellow', + } + case 'hint': + return { + border: 'border-l-blue-elastic', + bg: 'bg-blue-elastic/10', + icon: 'text-blue-elastic', + } + } +} + +const getSeverityIcon = (severity: DiagnosticItem['severity']) => { + switch (severity) { + case 'error': + return + case 'warning': + return + case 'hint': + return + } +} + +const DiagnosticRow: React.FC<{ diagnostic: DiagnosticItem }> = ({ diagnostic }) => { + const styles = getSeverityStyles(diagnostic.severity) + const [copied, setCopied] = React.useState(false) + + // Extract just the filename from the full path + const fileName = diagnostic.file.split('/').pop() || diagnostic.file + + const handleCopy = useCallback(async () => { + const location = diagnostic.line + ? `${diagnostic.file}:${diagnostic.line}${diagnostic.column ? `:${diagnostic.column}` : ''}` + : diagnostic.file + const text = `[${diagnostic.severity.toUpperCase()}] ${location}: ${diagnostic.message}` + + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Fallback for older browsers + console.error('Failed to copy to clipboard') + } + }, [diagnostic]) + + return ( +
+
+
+
+ {getSeverityIcon(diagnostic.severity)} + + {diagnostic.severity} + + + {fileName} + {diagnostic.line && `:${diagnostic.line}`} + {diagnostic.column && `:${diagnostic.column}`} + +
+

+ {diagnostic.message} +

+
+ +
+
+ ) +} + +interface FilterButtonProps { + active: boolean + onClick: () => void + icon: React.ReactNode + count: number + label: string + colorClass: string +} + +const FilterButton: React.FC = ({ + active, + onClick, + icon, + count, + label, + colorClass, +}) => ( + +) + +// Severity order for sorting: errors first, then warnings, then hints +const severityOrder: Record = { + error: 0, + warning: 1, + hint: 2, +} + +export const DiagnosticsHud: React.FC = () => { + const { + isHudOpen, + setHudOpen, + diagnostics, + status, + errors, + warnings, + hints, + filters, + toggleFilter, + showAllFilters, + } = useDiagnosticsStore() + const listRef = useRef(null) + const prevDiagnosticsLength = useRef(diagnostics.length) + + // Filter and sort diagnostics + const filteredDiagnostics = useMemo(() => { + return diagnostics + .filter((d) => { + if (d.severity === 'error' && !filters.errors) return false + if (d.severity === 'warning' && !filters.warnings) return false + if (d.severity === 'hint' && !filters.hints) return false + return true + }) + .sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]) + }, [diagnostics, filters]) + + // Check if all filters are active + const allFiltersActive = filters.errors && filters.warnings && filters.hints + const anyFilterInactive = !filters.errors || !filters.warnings || !filters.hints + + // Auto-scroll to bottom when new diagnostics are added + useEffect(() => { + if (diagnostics.length > prevDiagnosticsLength.current && listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight + } + prevDiagnosticsLength.current = diagnostics.length + }, [diagnostics.length]) + + // Handle escape key to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isHudOpen) { + setHudOpen(false) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isHudOpen, setHudOpen]) + + if (!isHudOpen) { + return null + } + + return ( +
+ {/* Header */} +
+
+

Build Diagnostics

+ {status === 'building' && ( + + Building... + + )} +
+ + {/* Filter toggles */} +
+ {anyFilterInactive && ( + + )} + {errors > 0 && ( + toggleFilter('errors')} + icon={} + count={errors} + label="errors" + colorClass="bg-red" + /> + )} + {warnings > 0 && ( + toggleFilter('warnings')} + icon={} + count={warnings} + label="warnings" + colorClass="bg-yellow" + /> + )} + {hints > 0 && ( + toggleFilter('hints')} + icon={} + count={hints} + label="hints" + colorClass="bg-blue-elastic" + /> + )} + {errors === 0 && warnings === 0 && hints === 0 && status === 'complete' && ( + No issues found + )} + +
+ + +
+
+ + {/* Diagnostics list */} +
+ {filteredDiagnostics.length === 0 ? ( +
+ {status === 'building' + ? 'Waiting for diagnostics...' + : diagnostics.length === 0 + ? 'No diagnostics to display' + : 'No diagnostics match the current filters'} +
+ ) : ( + filteredDiagnostics.map((diagnostic) => ( + + )) + )} +
+
+ ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnostics.store.ts b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnostics.store.ts new file mode 100644 index 000000000..6ce38cb88 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnostics.store.ts @@ -0,0 +1,90 @@ +import { create } from 'zustand' + +export type BuildStatus = 'idle' | 'building' | 'complete' + +export type DiagnosticSeverity = 'error' | 'warning' | 'hint' + +export interface DiagnosticItem { + id: string + severity: DiagnosticSeverity + file: string + message: string + line?: number + column?: number + timestamp: number +} + +export interface FilterState { + errors: boolean + warnings: boolean + hints: boolean +} + +export interface DiagnosticsState { + status: BuildStatus + errors: number + warnings: number + hints: number + diagnostics: DiagnosticItem[] + isHudOpen: boolean + isConnected: boolean + filters: FilterState + + // Actions + setStatus: (status: BuildStatus) => void + setCounts: (errors: number, warnings: number, hints: number) => void + addDiagnostic: (diagnostic: DiagnosticItem) => void + clearDiagnostics: () => void + toggleHud: () => void + setHudOpen: (open: boolean) => void + setConnected: (connected: boolean) => void + toggleFilter: (filter: keyof FilterState) => void + showAllFilters: () => void + reset: () => void +} + +export const useDiagnosticsStore = create((set) => ({ + status: 'idle', + errors: 0, + warnings: 0, + hints: 0, + diagnostics: [], + isHudOpen: false, + isConnected: false, + filters: { errors: true, warnings: true, hints: true }, + + setStatus: (status) => set({ status }), + + setCounts: (errors, warnings, hints) => set({ errors, warnings, hints }), + + addDiagnostic: (diagnostic) => + set((state) => ({ + diagnostics: [...state.diagnostics, diagnostic], + })), + + clearDiagnostics: () => set({ diagnostics: [], errors: 0, warnings: 0, hints: 0 }), + + toggleHud: () => set((state) => ({ isHudOpen: !state.isHudOpen })), + + setHudOpen: (open) => set({ isHudOpen: open }), + + setConnected: (connected) => set({ isConnected: connected }), + + toggleFilter: (filter) => + set((state) => ({ + filters: { ...state.filters, [filter]: !state.filters[filter] }, + })), + + showAllFilters: () => + set({ filters: { errors: true, warnings: true, hints: true } }), + + reset: () => + set({ + status: 'idle', + errors: 0, + warnings: 0, + hints: 0, + diagnostics: [], + filters: { errors: true, warnings: true, hints: true }, + }), +})) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnosticsStreamClient.ts b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnosticsStreamClient.ts new file mode 100644 index 000000000..4ec068cf8 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnosticsStreamClient.ts @@ -0,0 +1,171 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { useDiagnosticsStore, DiagnosticItem, BuildStatus } from './diagnostics.store' + +interface DiagnosticData { + severity: string + file: string + message: string + line?: number + column?: number +} + +interface BuildEvent { + type: string + timestamp: number + diagnostic?: DiagnosticData + diagnostics?: DiagnosticData[] + errors?: number + warnings?: number + hints?: number + status?: string +} + +let abortController: AbortController | null = null +let diagnosticIdCounter = 0 + +export function connectToDiagnosticsStream(): void { + // Disconnect any existing connection + disconnectFromDiagnosticsStream() + + abortController = new AbortController() + const store = useDiagnosticsStore.getState() + + fetchEventSource('/_api/diagnostics/stream', { + signal: abortController.signal, + + onopen: async (response) => { + if (response.ok) { + store.setConnected(true) + console.log('[Diagnostics] SSE connection established') + } else { + console.error('[Diagnostics] SSE connection failed:', response.status) + store.setConnected(false) + } + }, + + onmessage: (event) => { + try { + const data: BuildEvent = JSON.parse(event.data) + handleBuildEvent(data) + } catch (e) { + console.error('[Diagnostics] Failed to parse event:', e) + } + }, + + onerror: (err) => { + console.error('[Diagnostics] SSE error:', err) + store.setConnected(false) + + // Retry connection after a delay + setTimeout(() => { + if (abortController && !abortController.signal.aborted) { + connectToDiagnosticsStream() + } + }, 3000) + }, + + onclose: () => { + console.log('[Diagnostics] SSE connection closed') + store.setConnected(false) + }, + }).catch((err) => { + if (err.name !== 'AbortError') { + console.error('[Diagnostics] SSE fetch error:', err) + } + }) +} + +export function disconnectFromDiagnosticsStream(): void { + if (abortController) { + abortController.abort() + abortController = null + } + useDiagnosticsStore.getState().setConnected(false) +} + +function handleBuildEvent(event: BuildEvent): void { + const store = useDiagnosticsStore.getState() + + switch (event.type) { + case 'state': + // Initial state from server - includes status, counts, and historical diagnostics + store.setCounts(event.errors ?? 0, event.warnings ?? 0, event.hints ?? 0) + if (event.status) { + store.setStatus(event.status as BuildStatus) + } + // Load any stored diagnostics from previous builds + if (event.diagnostics && event.diagnostics.length > 0) { + store.clearDiagnostics() + // Restore counts since clearDiagnostics resets them + store.setCounts(event.errors ?? 0, event.warnings ?? 0, event.hints ?? 0) + event.diagnostics.forEach((diag) => { + const diagnostic: DiagnosticItem = { + id: `diag-${++diagnosticIdCounter}`, + severity: diag.severity as DiagnosticItem['severity'], + file: diag.file, + message: diag.message, + line: diag.line, + column: diag.column, + timestamp: event.timestamp, + } + store.addDiagnostic(diagnostic) + }) + } + break + + case 'build_start': + store.setStatus('building') + store.clearDiagnostics() + diagnosticIdCounter = 0 + break + + case 'build_complete': + store.setStatus('complete') + store.setCounts(event.errors ?? 0, event.warnings ?? 0, event.hints ?? 0) + break + + case 'build_cancelled': + store.setStatus('idle') + break + + case 'diagnostic': + if (event.diagnostic) { + const diagnostic: DiagnosticItem = { + id: `diag-${++diagnosticIdCounter}`, + severity: event.diagnostic.severity as DiagnosticItem['severity'], + file: event.diagnostic.file, + message: event.diagnostic.message, + line: event.diagnostic.line, + column: event.diagnostic.column, + timestamp: event.timestamp, + } + store.addDiagnostic(diagnostic) + + // Update counts based on severity + const currentState = useDiagnosticsStore.getState() + if (diagnostic.severity === 'error') { + store.setCounts( + currentState.errors + 1, + currentState.warnings, + currentState.hints + ) + } else if (diagnostic.severity === 'warning') { + store.setCounts( + currentState.errors, + currentState.warnings + 1, + currentState.hints + ) + } else if (diagnostic.severity === 'hint') { + store.setCounts( + currentState.errors, + currentState.warnings, + currentState.hints + 1 + ) + } + } + break + + default: + console.log('[Diagnostics] Unknown event type:', event.type) + } +} diff --git a/src/Elastic.Documentation.Site/Layout/_Header.cshtml b/src/Elastic.Documentation.Site/Layout/_Header.cshtml index 2449bc5b0..e67a90cb6 100644 --- a/src/Elastic.Documentation.Site/Layout/_Header.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Header.cshtml @@ -41,8 +41,12 @@ else
- +