better ui
This commit is contained in:
1 parent
a0f558b43c
commit
cb3266bf33
4 files changed
+193
-2
No files matched your search
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef } from "react"
|
||||||
|
import { AlertTriangle, ArrowRight, Search, Server, X } from "lucide-react"
|
||||||
|
import { useObservabilityData } from "../../hooks/useObservabilityData"
|
||||||
|
|
||||||
|
interface GlobalSearchDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command-palette style dialog opened from the top bar search box. Leads
|
||||||
|
* with matching OTel services (the common "something's wrong with X,
|
||||||
|
* let me debug it" entry point) and falls back to a plain telemetry
|
||||||
|
* search over the current signals.
|
||||||
|
*/
|
||||||
|
export const GlobalSearchDialog: React.FC<GlobalSearchDialogProps> = ({ isOpen, onClose }) => {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
uniqueServices,
|
||||||
|
serviceHealth,
|
||||||
|
setSelectedServices,
|
||||||
|
setActiveTab,
|
||||||
|
unfocusedTraces,
|
||||||
|
unfocusedLogs,
|
||||||
|
} = useObservabilityData()
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) inputRef.current?.focus()
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
const query = searchQuery.trim().toLowerCase()
|
||||||
|
|
||||||
|
const matchingServices = useMemo(() => {
|
||||||
|
const services = query ? uniqueServices.filter((s) => s.toLowerCase().includes(query)) : uniqueServices
|
||||||
|
return [...services]
|
||||||
|
.sort((a, b) => (serviceHealth[b]?.totalSignals ?? 0) - (serviceHealth[a]?.totalSignals ?? 0))
|
||||||
|
.slice(0, 8)
|
||||||
|
}, [uniqueServices, serviceHealth, query])
|
||||||
|
|
||||||
|
const matchCounts = useMemo(() => {
|
||||||
|
if (!query) return { traces: 0, logs: 0 }
|
||||||
|
let traces = 0
|
||||||
|
unfocusedTraces.forEach((t) => {
|
||||||
|
t.resourceSpans?.forEach((rs) => rs.scopeSpans?.forEach((ss) => (traces += ss.spans?.length ?? 0)))
|
||||||
|
})
|
||||||
|
let logs = 0
|
||||||
|
unfocusedLogs.forEach((l) => {
|
||||||
|
l.scopeLogs?.forEach((sl) => (logs += sl.logRecords?.length ?? 0))
|
||||||
|
})
|
||||||
|
return { traces, logs }
|
||||||
|
}, [query, unfocusedTraces, unfocusedLogs])
|
||||||
|
|
||||||
|
const debugService = (service: string) => {
|
||||||
|
setSelectedServices(new Set([service]))
|
||||||
|
setActiveTab("tab0")
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewAllResults = () => {
|
||||||
|
setActiveTab("tab0")
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-start justify-center bg-black/50 pt-[12vh]"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-xl rounded-[14px] border border-ds-line bg-ds-surface shadow-2xl">
|
||||||
|
<div className="relative flex items-center border-b border-ds-line px-4">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-ds-text-3" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") viewAllResults()
|
||||||
|
}}
|
||||||
|
placeholder="Search trace id, service, route, user…"
|
||||||
|
className="h-12 w-full bg-transparent px-3 font-[family-name:var(--font-data)] text-sm text-ds-text placeholder:text-ds-text-3 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close search"
|
||||||
|
className="shrink-0 rounded-[6px] p-1 text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto p-2">
|
||||||
|
<div className="px-2 pb-1 pt-2 font-[family-name:var(--font-data)] text-[10px] font-semibold uppercase tracking-wider text-ds-text-3">
|
||||||
|
OTel Services{query ? ` matching "${searchQuery.trim()}"` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matchingServices.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-sm text-ds-text-3">No services found.</div>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{matchingServices.map((service) => {
|
||||||
|
const health = serviceHealth[service]
|
||||||
|
return (
|
||||||
|
<li key={service}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => debugService(service)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-[9px] px-3 py-2 text-left hover:bg-ds-surface-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
health?.hasErrors ? "bg-ds-critical-bg text-ds-critical" : "bg-ds-accent-bg text-ds-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Server className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-ds-text">{service}</span>
|
||||||
|
<span className="block truncate font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||||||
|
{health ? `${health.totalSignals} signals` : "no recent signals"}
|
||||||
|
{health?.hasErrors ? ` · ${health.errorCount} errors` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{health?.hasErrors && (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-ds-critical" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-accent">
|
||||||
|
Debug <ArrowRight className="ml-0.5 inline h-3 w-3" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query && (
|
||||||
|
<>
|
||||||
|
<div className="px-2 pb-1 pt-3 font-[family-name:var(--font-data)] text-[10px] font-semibold uppercase tracking-wider text-ds-text-3">
|
||||||
|
Telemetry
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={viewAllResults}
|
||||||
|
className="flex w-full items-center justify-between rounded-[9px] px-3 py-2 text-left hover:bg-ds-surface-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-ds-text">
|
||||||
|
Search all telemetry for <span className="font-semibold">"{searchQuery.trim()}"</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||||||
|
{matchCounts.traces} spans · {matchCounts.logs} logs
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalSearchDialog
|
||||||
@@ -4,6 +4,7 @@ import { useObservabilityData } from "../../hooks/useObservabilityData"
|
|||||||
import { currentUser, login, logout } from "../../lib/auth"
|
import { currentUser, login, logout } from "../../lib/auth"
|
||||||
import { getTheme, setTheme, type ThemeMode } from "../../lib/theme"
|
import { getTheme, setTheme, type ThemeMode } from "../../lib/theme"
|
||||||
import type { TimeWindow } from "../../contexts/ObservabilityContextValue"
|
import type { TimeWindow } from "../../contexts/ObservabilityContextValue"
|
||||||
|
import { GlobalSearchDialog } from "./GlobalSearchDialog"
|
||||||
|
|
||||||
const TIME_WINDOWS: { value: TimeWindow; label: string }[] = [
|
const TIME_WINDOWS: { value: TimeWindow; label: string }[] = [
|
||||||
{ value: "15m", label: "15m" },
|
{ value: "15m", label: "15m" },
|
||||||
@@ -23,6 +24,7 @@ export const TopBar: React.FC = () => {
|
|||||||
useObservabilityData()
|
useObservabilityData()
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [theme, setThemeState] = useState<ThemeMode>(() => getTheme())
|
const [theme, setThemeState] = useState<ThemeMode>(() => getTheme())
|
||||||
|
const [searchDialogOpen, setSearchDialogOpen] = useState(false)
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const next: ThemeMode = theme === "dark" ? "light" : "dark"
|
const next: ThemeMode = theme === "dark" ? "light" : "dark"
|
||||||
@@ -34,7 +36,7 @@ export const TopBar: React.FC = () => {
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
searchInputRef.current?.focus()
|
setSearchDialogOpen(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
@@ -42,7 +44,8 @@ export const TopBar: React.FC = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed left-[72px] right-0 top-0 z-40 flex h-[58px] items-center gap-4 border-b border-ds-line bg-ds-chrome px-6 font-[family-name:var(--font-ui)]">
|
<>
|
||||||
|
<header className="fixed left-[72px] right-0 top-0 z-40 flex h-[58px] items-center gap-4 border-b border-ds-line bg-ds-chrome px-6 font-[family-name:var(--font-ui)]">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="font-[family-name:var(--font-display)] text-base font-bold tracking-[-0.01em] text-ds-text">
|
<span className="font-[family-name:var(--font-display)] text-base font-bold tracking-[-0.01em] text-ds-text">
|
||||||
OhhTel
|
OhhTel
|
||||||
@@ -76,6 +79,10 @@ export const TopBar: React.FC = () => {
|
|||||||
type="search"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.blur()
|
||||||
|
setSearchDialogOpen(true)
|
||||||
|
}}
|
||||||
placeholder="Search trace id, service, route, user…"
|
placeholder="Search trace id, service, route, user…"
|
||||||
className="h-9 w-full rounded-[9px] border border-ds-line bg-ds-surface-2 pl-9 pr-14 font-[family-name:var(--font-data)] text-[13px] text-ds-text placeholder:text-ds-text-3 focus:outline-none focus:ring-1 focus:ring-ds-accent"
|
className="h-9 w-full rounded-[9px] border border-ds-line bg-ds-surface-2 pl-9 pr-14 font-[family-name:var(--font-data)] text-[13px] text-ds-text placeholder:text-ds-text-3 focus:outline-none focus:ring-1 focus:ring-ds-accent"
|
||||||
/>
|
/>
|
||||||
@@ -157,6 +164,8 @@ export const TopBar: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<GlobalSearchDialog isOpen={searchDialogOpen} onClose={() => setSearchDialogOpen(false)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -790,6 +790,7 @@ export function ObservabilityProvider({
|
|||||||
|
|
||||||
selectedServices,
|
selectedServices,
|
||||||
toggleService,
|
toggleService,
|
||||||
|
setSelectedServices,
|
||||||
excludedAttributes,
|
excludedAttributes,
|
||||||
toggleExcludedValue,
|
toggleExcludedValue,
|
||||||
removeExcludedValue,
|
removeExcludedValue,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ interface ObservabilityContextType {
|
|||||||
// Global filters
|
// Global filters
|
||||||
selectedServices: Set<string>;
|
selectedServices: Set<string>;
|
||||||
toggleService: (service: string) => void;
|
toggleService: (service: string) => void;
|
||||||
|
/** Replaces the whole selection, e.g. "focus on exactly this one service". */
|
||||||
|
setSelectedServices: (services: Set<string>) => void;
|
||||||
excludedAttributes: Record<string, Set<string>>;
|
excludedAttributes: Record<string, Set<string>>;
|
||||||
toggleExcludedValue: (key: string, value: string) => void;
|
toggleExcludedValue: (key: string, value: string) => void;
|
||||||
removeExcludedValue: (key: string, value: string) => void;
|
removeExcludedValue: (key: string, value: string) => void;
|
||||||
|
|||||||
Reference in new issue
Block a user