Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/features/health/HealthHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
import i18n from "../../i18n";
import type { Connection } from "../../lib/tauri";
import { useConnections } from "../connections/store";
import { HealthHeader } from "./HealthHeader";
import { HealthHeader, jumpToTone } from "./HealthHeader";
import { useHealth } from "./store";

const fakeConn = (
Expand Down Expand Up @@ -177,4 +177,27 @@ describe("HealthHeader", () => {
});
expect(setRefreshInterval).toHaveBeenCalledWith("c1", null);
});

it("jumpToTone scrolls to and flashes the first card of the tone (#34)", () => {
const other = document.createElement("div");
other.setAttribute("data-card-tone", "warn");
const target = document.createElement("div");
target.setAttribute("data-card-tone", "danger");
const scrollIntoView = vi.fn();
target.scrollIntoView = scrollIntoView;
document.body.append(other, target);
try {
jumpToTone("danger");
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(target.classList.contains("health-card-flash")).toBe(true);
} finally {
other.remove();
target.remove();
}
});

it("jumpToTone is a no-op when no card of that tone exists", () => {
// No data-card-tone elements in the document → must not throw.
expect(() => jumpToTone("danger")).not.toThrow();
});
});
35 changes: 31 additions & 4 deletions src/features/health/HealthHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ function getEnvForConnection(
return found?.environment ?? "local";
}

/**
* Scroll to the first Health card of the given severity and briefly flash it,
* so the critical/warning pills act as jump-to navigation (#34). Card roots
* carry `data-card-tone` (set in CardShell) and the DOM order matches the
* dashboard display order, so the first match is the first such card.
*/
export function jumpToTone(tone: "danger" | "warn"): void {
const el = document.querySelector<HTMLElement>(`[data-card-tone="${tone}"]`);
if (!el) return;
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "nearest" });
el.classList.add("health-card-flash");
window.setTimeout(() => el.classList.remove("health-card-flash"), 1200);
}

function rtfFor(locale: string): Intl.RelativeTimeFormat | null {
if (typeof Intl === "undefined" || !Intl.RelativeTimeFormat) return null;
try {
Expand Down Expand Up @@ -151,14 +166,26 @@ export function HealthHeader({

<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
{counts.danger > 0 ? (
<span className="q-pill crit" data-testid="health-pill-critical">
<button
type="button"
className="q-pill crit"
data-testid="health-pill-critical"
onClick={() => jumpToTone("danger")}
title={t("health.header.pill_jump")}
>
{t("health.header.pill_critical", { count: counts.danger })}
</span>
</button>
) : null}
{counts.warn > 0 ? (
<span className="q-pill warn" data-testid="health-pill-warning">
<button
type="button"
className="q-pill warn"
data-testid="health-pill-warning"
onClick={() => jumpToTone("warn")}
title={t("health.header.pill_jump")}
>
{t("health.header.pill_warning", { count: counts.warn })}
</span>
</button>
) : null}
{counts.ok > 0 ? (
<span className="q-pill ok" data-testid="health-pill-ok">
Expand Down
37 changes: 36 additions & 1 deletion src/features/health/cards/BloatCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import i18n from "../../../i18n";
import { BloatCard } from "./BloatCard";

// jsdom in this runner doesn't expose window.localStorage; ActionButton's
// easy-mode check (isEasyMode) reads it, which otherwise crashes any card test
// that renders rows. Provide a minimal in-memory stub.
if (typeof window !== "undefined" && !window.localStorage) {
const store = new Map<string, string>();
Object.defineProperty(window, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => store.set(k, String(v)),
removeItem: (k: string) => store.delete(k),
clear: () => store.clear(),
},
});
}

beforeAll(async () => {
await i18n.changeLanguage("en");
});
Expand Down Expand Up @@ -55,4 +71,23 @@ describe("BloatCard", () => {
);
expect(screen.getByTestId("health-card-bloat-status").getAttribute("data-tone")).toBe("warn");
});

it("clicking +N more expands to all rows, then collapses (#34)", () => {
const rows = Array.from({ length: 5 }, (_, i) => ({
schema: "public",
table: `t${i}`,
bloatPct: 35 - i,
bloatBytes: (i + 1) * 1024 * 1024,
}));
render(
<BloatCard connId="c1" state={{ status: "ready", card: { id: "bloat", data: { rows } } }} />,
);
expect(screen.getAllByTestId("bloat-row")).toHaveLength(3);

fireEvent.click(screen.getByTestId("bloat-more"));
expect(screen.getAllByTestId("bloat-row")).toHaveLength(5);

fireEvent.click(screen.getByTestId("bloat-more"));
expect(screen.getAllByTestId("bloat-row")).toHaveLength(3);
});
});
32 changes: 26 additions & 6 deletions src/features/health/cards/BloatCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JSX } from "react";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionButton } from "../actions/ActionButton";
import type { CardState } from "../store";
Expand Down Expand Up @@ -30,14 +30,18 @@ export function BloatCard({ connId, state }: CardProps): JSX.Element {
);
}

const top3 = rows.slice(0, 3);
const more = rows.length - top3.length;
const TOP_N = 3;
const [expanded, setExpanded] = useState(false);
const visible = expanded ? rows : rows.slice(0, TOP_N);
const more = rows.length - TOP_N;
// tone is computed over ALL rows (not just the visible slice) so expanding
// the list never changes the card's severity.
const maxPct = rows.reduce((acc, r) => Math.max(acc, r.bloatPct), 0);
const tone = bloatTone(maxPct);

const body = (
<ul style={{ margin: 0, padding: 0, listStyle: "none", fontSize: 12 }}>
{top3.map((r) => (
{visible.map((r) => (
<li
key={`${r.schema}.${r.table}`}
data-testid="bloat-row"
Expand Down Expand Up @@ -74,8 +78,24 @@ export function BloatCard({ connId, state }: CardProps): JSX.Element {
</li>
))}
{more > 0 ? (
<li style={{ opacity: 0.65 }} data-testid="bloat-more">
{t("health.card.bloat.more", { count: more })}
<li>
<button
type="button"
data-testid="bloat-more"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
style={{
background: "none",
border: "none",
padding: 0,
font: "inherit",
color: "var(--ink-4)",
cursor: "pointer",
textDecoration: "underline",
}}
>
{expanded ? t("health.card.show_less") : t("health.card.bloat.more", { count: more })}
</button>
</li>
) : null}
</ul>
Expand Down
8 changes: 7 additions & 1 deletion src/features/health/cards/CardShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export function CardShell({ cardId, connId, state, body, status }: CardShellProp
}

return (
<div data-testid={`health-card-${cardId}`} style={SHELL_BASE}>
<div
data-testid={`health-card-${cardId}`}
// Severity marker so the header's critical/warning pills can jump to the
// first card of a given tone (#34). Absent until the card is `ready`.
data-card-tone={status?.tone}
style={SHELL_BASE}
>
{/* shimmer keyframes — scoped via plain <style>, idempotent enough for SPA */}
<style>{`
@keyframes health-card-shimmer {
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,8 @@
"refreshing": "Refreshing…",
"pill_critical": "{{count}} critical",
"pill_warning": "{{count}} warning",
"pill_ok": "{{count}} ok"
"pill_ok": "{{count}} ok",
"pill_jump": "Jump to the first card of this severity"
},
"refresh": {
"off": "Off",
Expand All @@ -1049,6 +1050,7 @@
"other": "Other"
},
"card": {
"show_less": "Show less",
"db_size": {
"title": "Database size",
"explanation": "Total on-disk size of the current database via pg_database_size(). The 7-day sparkline tracks growth from snapshots taken when you open this Health tab.",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,8 @@
"refreshing": "Обновляем…",
"pill_critical": "{{count}} критично",
"pill_warning": "{{count}} предупреждение",
"pill_ok": "{{count}} ок"
"pill_ok": "{{count}} ок",
"pill_jump": "Перейти к первой карточке этой важности"
},
"refresh": {
"off": "Выкл",
Expand All @@ -1049,6 +1050,7 @@
"other": "Другое"
},
"card": {
"show_less": "Свернуть",
"db_size": {
"title": "Размер базы",
"explanation": "Общий размер базы данных на диске через pg_database_size(). Sparkline за 7 дней показывает рост по снимкам, которые делаются при открытии этой вкладки Health.",
Expand Down
32 changes: 32 additions & 0 deletions src/styles/design.css
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,38 @@ body {
color: var(--ink-3);
letter-spacing: 0.01em;
}
/* A pill rendered as an interactive button (jump-to-severity, #34): strip the
* native button chrome so it matches the span pills, and show it's clickable. */
button.q-pill {
border: none;
margin: 0;
cursor: pointer;
}
button.q-pill:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Transient flash when a severity pill jumps to its first card (#34). */
@keyframes health-card-flash {
0% {
box-shadow: 0 0 0 0 transparent;
}
25% {
box-shadow: 0 0 0 3px var(--accent);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
.health-card-flash {
animation: health-card-flash 1.2s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.health-card-flash {
animation: none;
box-shadow: 0 0 0 2px var(--accent);
}
}
.q-pill.ok {
background: var(--accent-soft);
color: var(--accent-strong);
Expand Down
Loading