diff --git a/Source/LensPopup.tsx b/Source/LensPopup.tsx index f169125..46baa4d 100644 --- a/Source/LensPopup.tsx +++ b/Source/LensPopup.tsx @@ -18,6 +18,7 @@ import { SettingsView } from './settings/SettingsView'; import { ContextView } from './context/ContextView'; import { CommandsView } from './commands/CommandsView'; import { QueriesView } from './queries/QueriesView'; +import { ObservableQueryDiagnosticsView } from './observable-query-diagnostics/ObservableQueryDiagnosticsView'; import { fetchArcDevelopmentSources, fetchArcTenants, @@ -292,6 +293,15 @@ export function LensPopup() { : 'Queries are available only when the active tab is an Arc application.', disabled: !hasArcContext, }, + { + id: 'observable-query-diagnostics', + label: 'Query Diagnostics', + iconClass: 'pi-chart-line', + tooltip: hasArcContext + ? 'Observable query diagnostics: live status of active observable queries.' + : 'Query diagnostics are available only when the active tab is an Arc application.', + disabled: !hasArcContext, + }, { id: 'settings', label: 'Settings', @@ -433,6 +443,10 @@ export function LensPopup() { }} /> )} + + {resolvedActiveTab === 'observable-query-diagnostics' && ( + + )} diff --git a/Source/lens-popup.css b/Source/lens-popup.css index 5b3be75..607b435 100644 --- a/Source/lens-popup.css +++ b/Source/lens-popup.css @@ -760,3 +760,259 @@ p { grid-template-columns: 1fr; } } + +/* Observable Query Diagnostics */ + +.oqd-layout { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + gap: 0.625rem; +} + +.oqd-toolbar { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; +} + +.oqd-timestamp { + font-size: 0.75rem; + color: var(--text-color-secondary); + margin-left: auto; +} + +.oqd-refresh-button.p-button { + width: 2rem; + height: 2rem; + flex: 0 0 auto; +} + +.oqd-health-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; +} + +.oqd-health-badge.is-healthy { + background: color-mix(in srgb, var(--green-500) 15%, transparent); + color: var(--green-600); +} + +.oqd-health-badge.is-unhealthy { + background: color-mix(in srgb, var(--yellow-400) 20%, transparent); + color: var(--yellow-700); +} + +.oqd-scroll-area { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.oqd-section-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.oqd-section { + min-width: 0; +} + +.oqd-section-wide { + grid-column: 1 / -1; +} + +.oqd-section-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-color-secondary); + font-weight: 700; + margin-bottom: 0.5rem; +} + +.oqd-kv-list { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.oqd-kv-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.82rem; +} + +.oqd-kv-label { + color: var(--text-color-secondary); + min-width: 0; + flex: 1 1 auto; +} + +.oqd-value { + font-weight: 500; + color: var(--text-color); + flex: 0 0 auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace; + font-size: 0.78rem; +} + +.oqd-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.45rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + flex: 0 0 auto; +} + +.oqd-badge.is-ok { + background: color-mix(in srgb, var(--green-500) 15%, transparent); + color: var(--green-600); +} + +.oqd-badge.is-warn { + background: color-mix(in srgb, var(--yellow-400) 20%, transparent); + color: var(--yellow-700); +} + +.oqd-badge.is-neutral { + background: var(--surface-hover); + color: var(--text-color-secondary); +} + +.oqd-badge-sm { + display: inline-flex; + align-items: center; + padding: 0.05rem 0.35rem; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; +} + +.oqd-badge-sm.is-ok { + background: color-mix(in srgb, var(--green-500) 15%, transparent); + color: var(--green-600); +} + +.oqd-badge-sm.is-neutral { + background: var(--surface-hover); + color: var(--text-color-secondary); +} + +.oqd-sub-list { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-top: 0.35rem; + padding-top: 0.35rem; + border-top: 1px solid var(--surface-border); +} + +.oqd-sub-row { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; +} + +.oqd-dot-ok { color: var(--green-500); font-size: 0.5rem; } +.oqd-dot-warn { color: var(--yellow-500); font-size: 0.5rem; } + +.oqd-sub-label { + color: var(--text-color-secondary); + margin-left: auto; +} + +.oqd-entry-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.625rem; + padding-top: 0.625rem; + border-top: 1px solid var(--surface-border); +} + +.oqd-entry-card { + background: var(--surface-ground); + border: 1px solid var(--surface-border); + border-radius: 6px; + padding: 0.4rem 0.6rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.oqd-entry-name { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.oqd-entry-meta { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.oqd-entry-stat { + font-size: 0.68rem; + color: var(--text-color-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace; +} + +.oqd-owner-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.oqd-owner-row { + display: flex; + align-items: flex-start; + gap: 0.625rem; +} + +.oqd-owner-name { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-color); + min-width: 8rem; + flex: 0 0 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.oqd-owner-queries { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.oqd-query-chip { + display: inline-block; + background: var(--surface-hover); + border: 1px solid var(--surface-border); + border-radius: 4px; + padding: 0.1rem 0.35rem; + font-size: 0.68rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace; + color: var(--text-color-secondary); +} diff --git a/Source/observable-query-diagnostics/ObservableQueryDiagnosticsView.tsx b/Source/observable-query-diagnostics/ObservableQueryDiagnosticsView.tsx new file mode 100644 index 0000000..4993808 --- /dev/null +++ b/Source/observable-query-diagnostics/ObservableQueryDiagnosticsView.tsx @@ -0,0 +1,253 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from 'primereact/button'; +import { captureObservableQueryDiagnosticsForActiveTab, ObservableQueryDiagnosticsSnapshot } from '../shared/arc-context'; + +const POLL_INTERVAL_MS = 2000; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +interface Props { + hasArcContext: boolean; +} + +export function ObservableQueryDiagnosticsView({ hasArcContext }: Props) { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(null); + const intervalRef = useRef | null>(null); + + const fetchSnapshot = async () => { + if (!hasArcContext) { + setLoading(false); + return; + } + + try { + const result = await captureObservableQueryDiagnosticsForActiveTab(); + setSnapshot(result); + setLastUpdated(new Date()); + } catch { + setSnapshot(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!hasArcContext) { + setLoading(false); + return; + } + + void fetchSnapshot(); + + intervalRef.current = setInterval(() => { + void fetchSnapshot(); + }, POLL_INTERVAL_MS); + + return () => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [hasArcContext]); + + if (loading) { + return Loading diagnostics…; + } + + if (!hasArcContext) { + return ( + + Observable query diagnostics are only available when the active tab is an Arc application. + + ); + } + + if (!snapshot) { + return ( + + + No diagnostics data available. The Arc application may not have any active observable queries. + + + void fetchSnapshot()} + /> + + + ); + } + + const { health, transport, multiplexer, cache, ownership } = snapshot; + const healthOk = health.allQueriesConnected; + const cacheOk = cache.healthy; + const allOk = healthOk && cacheOk && multiplexer.isConnected; + + return ( + + + + + {allOk ? 'Healthy' : 'Degraded'} + + {lastUpdated && ( + + Updated {lastUpdated.toLocaleTimeString()} + + )} + void fetchSnapshot()} + /> + + + + + + {/* Health */} + + Health + + + All queries connected + + {health.allQueriesConnected ? 'Yes' : 'No'} + + + + Disconnected queries + + {health.disconnectedQueryCount} + + + + + + {/* Transport */} + + Transport + + + Method + {transport.queryTransportMethod} + + + Direct mode + {transport.queryDirectMode ? 'Yes' : 'No'} + + + + + {/* Multiplexer */} + + Multiplexer + + + Connected + + {multiplexer.isConnected ? 'Yes' : 'No'} + + + + Connections (configured / active) + + {multiplexer.configuredConnectionCount} / {multiplexer.activeConnectionCount} + + + {multiplexer.connections.length > 0 && ( + + {multiplexer.connections.map(conn => ( + + + #{conn.index} + {conn.queryCount} {conn.queryCount === 1 ? 'query' : 'queries'} + + ))} + + )} + + + + {/* Cache */} + + Cache + + + Status + + {cache.healthy ? 'Healthy' : 'Degraded'} + + + + Entries + {cache.entryCount} + + + Estimated size + {formatBytes(cache.estimatedBytes)} + + + + {cache.entries.length > 0 && ( + + {cache.entries.map(entry => ( + + {entry.queryName} + + + {entry.subscribed ? 'subscribed' : 'idle'} + + + {entry.hasResult ? 'has result' : 'no result'} + + {entry.subscriberCount} subscribers + {entry.listenerCount} listeners + {formatBytes(entry.estimatedBytes)} + + + ))} + + )} + + + {/* Ownership */} + {Object.keys(ownership.queriesByOwner).length > 0 && ( + + Ownership + + {Object.entries(ownership.queriesByOwner).map(([owner, queries]) => ( + + {owner} + + {queries.map(q => ( + {q} + ))} + + + ))} + + + )} + + + + ); +} diff --git a/Source/package.json b/Source/package.json index 201e820..1e9edf6 100644 --- a/Source/package.json +++ b/Source/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@cratis/arc.react": "^20.26.18", + "@cratis/arc.react": "^20.28.0", "primeicons": "^7.0.0", "primereact": "^10.9.8", "react": "^18.3.1", diff --git a/Source/shared/arc-context.ts b/Source/shared/arc-context.ts index b742032..8c59ca6 100644 --- a/Source/shared/arc-context.ts +++ b/Source/shared/arc-context.ts @@ -1,3 +1,16 @@ +import type { ObservableQueryDiagnosticsSnapshot } from '@cratis/arc/queries'; + +export type { ObservableQueryDiagnosticsSnapshot }; +export type { + HealthDiagnostics, + TransportDiagnostics, + MultiplexerDiagnostics, + MultiplexerConnectionState, + CacheDiagnostics, + CacheEntryDiagnostics, + OwnershipDiagnostics, +} from '@cratis/arc/queries'; + export interface ArcContextSnapshot { isArcApplication: boolean; baseUrl: string | null; @@ -368,3 +381,86 @@ export async function getArcContextSnapshot(): Promise => + typeof value === 'object' && value !== null; + + const isArcContextValue = (value: unknown): boolean => { + if (!isRecord(value)) return false; + return arcContextMarkerProperty in value; + }; + + const root = document.getElementById('root'); + if (!root) return null; + + const fiberKey = Object.keys(root).find(key => key.startsWith('__reactFiber')); + const containerKey = Object.keys(root).find(key => key.startsWith('__reactContainer')); + if (!fiberKey && !containerKey) return null; + + const visited = new WeakSet(); + const rootObject = root as unknown as Record; + const fiberNode = fiberKey ? rootObject[fiberKey] : undefined; + const containerNode = containerKey ? rootObject[containerKey] : undefined; + const queue: object[] = []; + if (isRecord(fiberNode)) queue.push(fiberNode); + if (isRecord(containerNode)) { + queue.push(containerNode); + const current = (containerNode as Record).current; + if (isRecord(current)) queue.push(current); + } + + while (queue.length > 0) { + const node = queue.pop() as Record | undefined; + if (!node) continue; + if (visited.has(node)) continue; + visited.add(node); + + const memoizedProps = node.memoizedProps as Record | undefined; + const contextValue = memoizedProps?.value; + + if (isArcContextValue(contextValue)) { + const context = contextValue as Record; + const diag = context.observableQueryDiagnostics as Record | undefined; + if (diag && typeof diag.getSnapshot === 'function') { + try { + return (diag.getSnapshot as () => ObservableQueryDiagnosticsSnapshot)(); + } catch { + return null; + } + } + return null; + } + + const child = node.child; + const sibling = node.sibling; + const parent = node.return; + if (isRecord(child) && !visited.has(child)) queue.push(child); + if (isRecord(sibling) && !visited.has(sibling)) queue.push(sibling); + if (isRecord(parent) && !visited.has(parent)) queue.push(parent); + } + + return null; +} + +export async function captureObservableQueryDiagnosticsForActiveTab(): Promise { + const resolution = await resolveBestTabForArcCapture(); + const activeTab = resolution.tab; + + if (!activeTab?.id || !isInspectablePageUrl(activeTab.url)) { + return null; + } + + try { + const [executionResult] = await chrome.scripting.executeScript({ + target: { tabId: activeTab.id }, + world: 'MAIN', + func: captureObservableQueryDiagnosticsFromPage, + }); + + return (executionResult?.result as ObservableQueryDiagnosticsSnapshot | null | undefined) ?? null; + } catch { + return null; + } +} diff --git a/Source/shared/storage.ts b/Source/shared/storage.ts index 2cd9b6a..6f0100d 100644 --- a/Source/shared/storage.ts +++ b/Source/shared/storage.ts @@ -1,6 +1,6 @@ import { ExtensionSettings, UserProfile, Tenant } from './types'; -export type PopupTab = 'settings' | 'context' | 'commands' | 'queries'; +export type PopupTab = 'settings' | 'context' | 'commands' | 'queries' | 'observable-query-diagnostics'; export interface ExtensionNavigationState { activeTab: PopupTab;
Observable query diagnostics are only available when the active tab is an Arc application.
No diagnostics data available. The Arc application may not have any active observable queries.