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/isolated-header.ts b/src/Elastic.Documentation.Site/Assets/isolated-header.ts index 42fba3eed..a33913bc4 100644 --- a/src/Elastic.Documentation.Site/Assets/isolated-header.ts +++ b/src/Elastic.Documentation.Site/Assets/isolated-header.ts @@ -2,6 +2,33 @@ * Handles the dynamic resizing of the isolated header when scrolling. * Expands only at scroll position 0, compacts after scrolling past threshold. */ + +const COMPACT_THRESHOLD = 80 // Scroll down past this to compact +const EXPANDED_OFFSET = '110px' +const COMPACT_OFFSET = '48px' + +/** + * Set the CSS variable immediately based on scroll position. + * Called early (before DOMContentLoaded) to prevent layout shift. + * Adds no-transitions class that will be removed by initIsolatedHeader. + */ +export function setInitialHeaderOffset() { + // Check if isolated header exists in the DOM + // Use a simple check that works before full DOM is ready + if (!document.getElementById('isolated-header')) return + + // Add class to disable all transitions during initial load + // This will be removed by initIsolatedHeader after layout is set + document.documentElement.classList.add('no-transitions') + + const offset = window.scrollY > 0 ? COMPACT_OFFSET : EXPANDED_OFFSET + document.documentElement.style.setProperty('--offset-top', offset) +} + +/** + * Full initialization - attaches scroll listener and updates header elements. + * Should be called on DOMContentLoaded. + */ export function initIsolatedHeader() { const header = document.getElementById('isolated-header') if (!header) return @@ -9,11 +36,7 @@ export function initIsolatedHeader() { // Add class to body for CSS scoping document.body.classList.add('has-isolated-header') - let isCompact = false - - const COMPACT_THRESHOLD = 80 // Scroll down past this to compact - const EXPANDED_OFFSET = '110px' - const COMPACT_OFFSET = '48px' + let isCompact = window.scrollY > 0 const updateLayout = (compact: boolean) => { const offset = compact ? COMPACT_OFFSET : EXPANDED_OFFSET @@ -49,11 +72,12 @@ export function initIsolatedHeader() { window.addEventListener('scroll', onScroll, { passive: true }) // Set initial state based on current scroll position - if (window.scrollY > 0) { - isCompact = true - updateLayout(true) - } else { - // Ensure initial expanded state is set - updateLayout(false) - } + updateLayout(isCompact) + + // Re-enable transitions after layout is set + requestAnimationFrame(() => { + requestAnimationFrame(() => { + document.documentElement.classList.remove('no-transitions') + }) + }) } diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 4452351cf..bffc67c00 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -3,7 +3,7 @@ import { initAppliesSwitch } from './applies-switch' import { initCopyButton } from './copybutton' import { initHighlight } from './hljs' import { initImageCarousel } from './image-carousel' -import { initIsolatedHeader } from './isolated-header' +import { initIsolatedHeader, setInitialHeaderOffset } from './isolated-header' import { openDetailsWithAnchor } from './open-details-with-anchor' import { initNav } from './pages-nav' import { initSmoothScroll } from './smooth-scroll' @@ -29,6 +29,10 @@ initializeOtel({ debug: false, }) +// Set header offset immediately to prevent layout shift on reload +// This runs before DOMContentLoaded to avoid visual jump +setInitialHeaderOffset() + // Dynamically import web components after telemetry is initialized // This ensures telemetry is available when the components execute // Parcel will automatically code-split this into a separate chunk @@ -37,6 +41,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..ef2d1548b 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -34,6 +34,12 @@ html { @apply font-body; } +/* Disable all transitions during initial page load to prevent layout shift */ +.no-transitions, +.no-transitions * { + transition: none !important; +} + body { /* This is still needed because of some usages of ch units and to maintain the previous behavior */ font-size: 16px; @@ -208,6 +214,171 @@ 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; + cursor: pointer !important; +} + +.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); + cursor: pointer !important; +} + +.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/telemetry/logging.ts b/src/Elastic.Documentation.Site/Assets/telemetry/logging.ts index 4d88f630a..3a8084cda 100644 --- a/src/Elastic.Documentation.Site/Assets/telemetry/logging.ts +++ b/src/Elastic.Documentation.Site/Assets/telemetry/logging.ts @@ -6,7 +6,11 @@ */ import { logs, SeverityNumber, type AnyValueMap } from '@opentelemetry/api-logs' -const logger = logs.getLogger('docs-frontend-logger') +// Lazy-initialize the logger to avoid errors when this module is imported +// before the OpenTelemetry LoggerProvider is set up +function getLogger() { + return logs.getLogger('docs-frontend-logger') +} /** * Log an informational message. @@ -23,7 +27,7 @@ const logger = logs.getLogger('docs-frontend-logger') * ``` */ export function logInfo(body: string, attrs: AnyValueMap = {}) { - logger.emit({ + getLogger().emit({ body, severityNumber: SeverityNumber.INFO, severityText: 'INFO', @@ -46,7 +50,7 @@ export function logInfo(body: string, attrs: AnyValueMap = {}) { * ``` */ export function logWarn(body: string, attrs: AnyValueMap = {}) { - logger.emit({ + getLogger().emit({ body, severityNumber: SeverityNumber.WARN, severityText: 'WARN', @@ -70,7 +74,7 @@ export function logWarn(body: string, attrs: AnyValueMap = {}) { * ``` */ export function logError(body: string, attrs: AnyValueMap = {}) { - logger.emit({ + getLogger().emit({ body, severityNumber: SeverityNumber.ERROR, severityText: 'ERROR', @@ -93,7 +97,7 @@ export function logError(body: string, attrs: AnyValueMap = {}) { * ``` */ export function logDebug(body: string, attrs: AnyValueMap = {}) { - logger.emit({ + getLogger().emit({ body, severityNumber: SeverityNumber.DEBUG, severityText: 'DEBUG', 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..ba27ea469 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsButton.tsx @@ -0,0 +1,208 @@ +import { useDiagnosticsStore } from './diagnostics.store' +import * as React from 'react' + +// 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 cursor-pointer' + + // 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..0ad30d276 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsComponent.tsx @@ -0,0 +1,31 @@ +import { DiagnosticsButton } from './DiagnosticsButton' +import { DiagnosticsHud } from './DiagnosticsHud' +import { + connectToDiagnosticsStream, + disconnectFromDiagnosticsStream, +} from './diagnosticsStreamClient' +import r2wc from '@r2wc/react-to-web-component' +import * as React from 'react' +import { useEffect } from 'react' + +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..1c787d26d --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/DiagnosticsHud.tsx @@ -0,0 +1,394 @@ +import { useDiagnosticsStore, DiagnosticItem } from './diagnostics.store' +import * as React from 'react' +import { useEffect, useRef, useMemo, useCallback } from 'react' + +// 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 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..8ec6f90a9 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnostics.store.ts @@ -0,0 +1,120 @@ +import { create } from 'zustand' + +export type BuildStatus = 'idle' | 'building' | 'complete' + +// SessionStorage key for persisting HUD open state +const HUD_OPEN_KEY = 'diagnostics-hud-open' + +// Helper to get persisted HUD state +function getPersistedHudOpen(): boolean { + try { + return sessionStorage.getItem(HUD_OPEN_KEY) === 'true' + } catch { + return false + } +} + +// Helper to persist HUD state +function persistHudOpen(open: boolean): void { + try { + sessionStorage.setItem(HUD_OPEN_KEY, String(open)) + } catch { + // Ignore storage errors + } +} + +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: getPersistedHudOpen(), + 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) => { + const newOpen = !state.isHudOpen + persistHudOpen(newOpen) + return { isHudOpen: newOpen } + }), + + setHudOpen: (open) => { + persistHudOpen(open) + return 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..db43aaa5f --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/Diagnostics/diagnosticsStreamClient.ts @@ -0,0 +1,189 @@ +import { + useDiagnosticsStore, + DiagnosticItem, + BuildStatus, +} from './diagnostics.store' +import { fetchEventSource } from '@microsoft/fetch-event-source' + +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) + } 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: () => { + 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.warn('[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
- +