fixes
This commit is contained in:
1 parent
c8cc0fb28a
commit
e790c02d59
16 files changed
+659
-15
No files matched your search
@@ -373,6 +373,15 @@ const IssueCard = ({
|
||||
<span className={`inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
|
||||
{toneLabel[tone]}
|
||||
</span>
|
||||
{issue.regressedAt && issueStatusOf(issue) === "open" ? (
|
||||
<span
|
||||
title={`Was resolved, fired again ${formatRelativeTime(issue.regressedAt / 1e6)}`}
|
||||
className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] bg-ds-warn-bg px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-warn"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden="true" />
|
||||
Regressed
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
|
||||
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{meta.label}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import * as Tabs from "@radix-ui/react-tabs"
|
||||
import {
|
||||
Activity,
|
||||
@@ -28,6 +28,7 @@ import { TelemetryFilters } from "./TelemetryFilters"
|
||||
import { PatternsVisualizer } from "./PatternsVisualizer"
|
||||
import { RolloutsVisualizer } from "./RolloutsVisualizer"
|
||||
import { SourceIpsVisualizer } from "./SourceIpsVisualizer"
|
||||
import { TodosVisualizer } from "./TodosVisualizer"
|
||||
|
||||
|
||||
type SeverityMeta = {
|
||||
@@ -269,15 +270,20 @@ export const TabsComponent = () => {
|
||||
const { traces, logs, activeTab, setActiveTab } = useObservabilityData()
|
||||
const issuesState = useIssues()
|
||||
const [statusBusy, setStatusBusy] = useState(false)
|
||||
const [statusNotice, setStatusNotice] = useState<{ text: string; undo?: () => void; error?: boolean } | null>(null)
|
||||
// Trends tab only exists when the backend has ClickHouse-backed stats;
|
||||
// null while the capability check is in flight.
|
||||
const statsAvailable = useStatsAvailability()
|
||||
|
||||
// Triage action: PATCH the issue status, then refresh so counts/badges
|
||||
// reflect the server state. Failures surface via the issues error banner.
|
||||
// Triage action: apply optimistically, PATCH, then refresh so counts and
|
||||
// badges match the server. A failure rolls back and says so; a success
|
||||
// offers Undo because the card moves into the collapsed resolved/ignored
|
||||
// group and would otherwise just vanish.
|
||||
const handleIssueStatusChange = useCallback(
|
||||
async (issue: Issue, status: string) => {
|
||||
const previous = issue.status ?? "open"
|
||||
setStatusBusy(true)
|
||||
void issuesState.patch(issue.id, { status, regressedAt: undefined })
|
||||
try {
|
||||
const res = await authFetch(`/api/issues/${issue.id}`, {
|
||||
method: "PATCH",
|
||||
@@ -285,8 +291,14 @@ export const TabsComponent = () => {
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const verb = status === "resolved" ? "Resolved" : status === "ignored" ? "Ignored" : "Reopened"
|
||||
setStatusNotice({
|
||||
text: `${verb}: ${issue.title}`,
|
||||
undo: status === "open" ? undefined : () => void handleIssueStatusChange({ ...issue, status }, previous),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to update issue status:", err)
|
||||
setStatusNotice({ text: `Couldn't update issue: ${err instanceof Error ? err.message : String(err)}`, error: true })
|
||||
} finally {
|
||||
await issuesState.refresh()
|
||||
setStatusBusy(false)
|
||||
@@ -295,6 +307,12 @@ export const TabsComponent = () => {
|
||||
[issuesState],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusNotice) return
|
||||
const t = setTimeout(() => setStatusNotice(null), statusNotice.error ? 8000 : 6000)
|
||||
return () => clearTimeout(t)
|
||||
}, [statusNotice])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<TelemetryFilters />
|
||||
@@ -335,6 +353,9 @@ export const TabsComponent = () => {
|
||||
<Tabs.Content className="outline-none py-2" value="tab10">
|
||||
{activeTab === "tab10" && <ViewErrorBoundary><SourceIpsVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content className="outline-none py-2" value="tab11">
|
||||
{activeTab === "tab11" && <ViewErrorBoundary><TodosVisualizer /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
value="tab2"
|
||||
@@ -374,6 +395,28 @@ export const TabsComponent = () => {
|
||||
{activeTab === "tab6" && <ViewErrorBoundary><SearchAndWatcher /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{statusNotice && (
|
||||
<div
|
||||
role="status"
|
||||
className={`fixed bottom-6 left-1/2 z-50 flex max-w-[min(560px,calc(100vw-120px))] -translate-x-1/2 items-center gap-3 rounded-[10px] border bg-ds-surface px-4 py-2.5 text-[13px] ${
|
||||
statusNotice.error ? "border-ds-critical/40 text-ds-critical" : "border-ds-line-strong text-ds-text"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{statusNotice.text}</span>
|
||||
{statusNotice.undo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
statusNotice.undo?.()
|
||||
setStatusNotice(null)
|
||||
}}
|
||||
className="shrink-0 font-semibold text-ds-accent hover:underline"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { CheckCircle2, ExternalLink, ListTodo, RefreshCw, RotateCcw } from "lucide-react"
|
||||
import { useTodos } from "../hooks/useTodos"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import type { Todo } from "../lib/types"
|
||||
import { Segmented } from "./common/Segmented"
|
||||
|
||||
type StatusFilter = "open" | "done" | "all"
|
||||
|
||||
const TodoCard = ({ todo, onDone }: { todo: Todo; onDone: (todo: Todo, done: boolean) => void }) => {
|
||||
const location = `${todo.path ?? ""}${todo.hash ?? ""}` || "/"
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col gap-2.5 rounded-[14px] border border-ds-line border-l-[3px] bg-ds-surface px-5 py-4 ${
|
||||
todo.done ? "border-l-ds-ok" : "border-l-ds-line-strong"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<p className={`min-w-0 flex-1 whitespace-pre-wrap break-words text-[14px] ${todo.done ? "text-ds-text-3 line-through" : "text-ds-text"}`}>
|
||||
{todo.text}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{todo.url && (
|
||||
<a
|
||||
href={todo.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 py-1.5 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Open page
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDone(todo, !todo.done)}
|
||||
className="inline-flex items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 py-1.5 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||
>
|
||||
{todo.done ? <RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> : <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{todo.done ? "Reopen" : "Done"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-ds-text-3">
|
||||
<span className="rounded-[6px] border border-ds-line-strong px-2 py-0.5 font-bold uppercase tracking-wider text-ds-text-2">{todo.project}</span>
|
||||
{todo.component && (
|
||||
<span className="rounded-[6px] border border-ds-accent/30 bg-ds-accent-bg px-2 py-0.5 font-bold uppercase tracking-wider text-ds-accent">
|
||||
{todo.component}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-[family-name:var(--font-data)]">{location}</span>
|
||||
<span>·</span>
|
||||
<span title={new Date(todo.createdAt).toLocaleString()}>{formatRelativeTime(new Date(todo.createdAt).getTime())}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export const TodosVisualizer = () => {
|
||||
const { todos, loading, error, refresh, setDone } = useTodos()
|
||||
const [status, setStatus] = useState<StatusFilter>("open")
|
||||
const [project, setProject] = useState("")
|
||||
|
||||
const projects = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const t of todos) if (!t.done) counts.set(t.project, (counts.get(t.project) ?? 0) + 1)
|
||||
for (const t of todos) if (!counts.has(t.project)) counts.set(t.project, 0)
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
}, [todos])
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
todos.filter(
|
||||
(t) => (!project || t.project === project) && (status === "all" || (status === "done" ? t.done : !t.done)),
|
||||
),
|
||||
[todos, project, status],
|
||||
)
|
||||
const openCount = todos.filter((t) => !t.done).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="flex items-center gap-2 font-[family-name:var(--font-display)] text-[17px] font-bold text-ds-text">
|
||||
<ListTodo className="h-5 w-5 text-ds-text-3" aria-hidden="true" />
|
||||
Todos from the site
|
||||
</h1>
|
||||
<span className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{openCount} open</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Segmented
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={[
|
||||
{ value: "open", label: "Open" },
|
||||
{ value: "done", label: "Done" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refresh()}
|
||||
aria-label="Refresh todos"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[9px] border border-ds-line text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length > 1 && (
|
||||
<div role="group" aria-label="Project" className="flex flex-wrap gap-2">
|
||||
{[["", openCount] as [string, number], ...projects].map(([name, count]) => (
|
||||
<button
|
||||
key={name || "all"}
|
||||
type="button"
|
||||
aria-pressed={project === name}
|
||||
onClick={() => setProject(name)}
|
||||
className={`rounded-[8px] border px-2.5 py-1 text-[12px] font-medium ${
|
||||
project === name
|
||||
? "border-ds-accent/40 bg-ds-accent-bg text-ds-accent"
|
||||
: "border-ds-line-strong text-ds-text-2 hover:bg-ds-surface-2"
|
||||
}`}
|
||||
>
|
||||
{name || "All projects"} <span className="text-ds-text-3">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="rounded-[10px] border border-ds-critical/40 bg-ds-critical-bg px-4 py-2.5 text-[13px] text-ds-critical">
|
||||
Couldn't load todos: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||||
<ListTodo className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">
|
||||
{loading ? "Loading…" : todos.length === 0 ? "No todos yet" : "Nothing matches"}
|
||||
</p>
|
||||
{todos.length === 0 && !loading && (
|
||||
<p className="max-w-sm text-[12.5px] text-ds-text-3">
|
||||
Press Alt+Shift+T on a page running otel-rum.js to file one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{visible.map((t) => (
|
||||
<TodoCard key={t.id} todo={t} onDone={(todo, done) => void setDone(todo, done)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TodosVisualizer
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GitBranch,
|
||||
Globe,
|
||||
LineChart,
|
||||
ListTodo,
|
||||
Radar,
|
||||
ScrollText,
|
||||
TrendingUp,
|
||||
@@ -64,6 +65,7 @@ export const IconRail: React.FC = () => {
|
||||
{ tab: "tab2", label: "Metrics & APM", Icon: LineChart },
|
||||
{ tab: "tab7", label: "RUM sessions", Icon: Globe },
|
||||
{ tab: "tab10", label: "Source IPs", Icon: Fingerprint },
|
||||
{ tab: "tab11", label: "Todos from the site", Icon: ListTodo },
|
||||
...(statsAvailable ? [{ tab: "tab5", label: "Historical trends", Icon: TrendingUp }] : []),
|
||||
{ tab: "tab6", label: "Archive & trap", Icon: Radar },
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user