From 22ed9761c5352b62e2a11281a68b35c2e258acca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 14:22:27 +0000 Subject: [PATCH 1/3] feat(admin,frontend): add Sentry React SDK + ErrorBoundary and admin /errors UI (#804) Wires up `@sentry/react` for the frontend (`src/`) and admin (`admin/`) SPAs with `Error Boundary` to forward unhandled exceptions, both gated on the `VITE_SENTRY_DSN_WEB` / `VITE_ADMIN_SENTRY_DSN` env vars (no-op when blank). Adds an admin `/errors` page (list + detail + status update) backed by the existing `/api/admin/errors` endpoints, plus a sidebar badge that polls for the count of active (`open` + `investigating`) errors. Phase 1 uses simple polling at a 30s interval; realtime sync is deferred to a later phase. --- .env.example | 8 + admin/.env.example | 4 + admin/e2e/errors-page.spec.ts | 71 +++++ admin/package.json | 4 +- admin/playwright.config.ts | 39 +++ admin/src/App.tsx | 2 + admin/src/api/admin.test.ts | 109 ++++++++ admin/src/api/admin.ts | 126 +++++++++ admin/src/components/ErrorBoundary.test.tsx | 72 +++++ admin/src/components/ErrorBoundary.tsx | 75 ++++++ admin/src/i18n/index.ts | 4 + admin/src/i18n/locales/en/errors.json | 46 ++++ admin/src/i18n/locales/en/nav.json | 4 +- admin/src/i18n/locales/ja/errors.json | 46 ++++ admin/src/i18n/locales/ja/nav.json | 4 +- admin/src/lib/sentry.ts | 50 ++++ admin/src/main.tsx | 10 +- admin/src/pages/Layout.tsx | 88 +++++-- admin/src/pages/errors/ErrorDetailDialog.tsx | 198 ++++++++++++++ admin/src/pages/errors/ErrorsContent.test.tsx | 117 ++++++++ admin/src/pages/errors/ErrorsContent.tsx | 249 ++++++++++++++++++ admin/src/pages/errors/index.tsx | 84 ++++++ .../pages/errors/useApiErrorActiveCount.ts | 61 +++++ admin/src/pages/errors/useApiErrors.ts | 130 +++++++++ bun.lock | 16 ++ package.json | 1 + src/components/ErrorBoundary.tsx | 75 ++++++ src/lib/sentry.ts | 58 ++++ src/main.tsx | 14 +- 29 files changed, 1743 insertions(+), 22 deletions(-) create mode 100644 admin/e2e/errors-page.spec.ts create mode 100644 admin/playwright.config.ts create mode 100644 admin/src/components/ErrorBoundary.test.tsx create mode 100644 admin/src/components/ErrorBoundary.tsx create mode 100644 admin/src/i18n/locales/en/errors.json create mode 100644 admin/src/i18n/locales/ja/errors.json create mode 100644 admin/src/lib/sentry.ts create mode 100644 admin/src/pages/errors/ErrorDetailDialog.tsx create mode 100644 admin/src/pages/errors/ErrorsContent.test.tsx create mode 100644 admin/src/pages/errors/ErrorsContent.tsx create mode 100644 admin/src/pages/errors/index.tsx create mode 100644 admin/src/pages/errors/useApiErrorActiveCount.ts create mode 100644 admin/src/pages/errors/useApiErrors.ts create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/lib/sentry.ts diff --git a/.env.example b/.env.example index a0bb1381..3226a932 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,14 @@ POLAR_PRO_YEARLY_PRODUCT_ID=YOUR_POLAR_YEARLY_PRODUCT_ID # NOTE: MCP JWT は `BETTER_AUTH_SECRET` を署名鍵として共有する (audience で分離)。 # MCP JWTs are signed with `BETTER_AUTH_SECRET` (audience-scoped to `zedi-mcp`). +# Sentry DSNs (Epic #616). Each surface uses its own DSN so events are routed to +# the correct project. Leave unset locally — the SDKs no-op when DSN is empty. +# 各サーフェス(Web / 管理画面 / API)はそれぞれ別の DSN を使用する。 +# 未設定でも SDK は no-op のため、ローカルでは空のままで良い。 +# VITE_SENTRY_DSN_WEB=https://@o0.ingest.sentry.io/ +# VITE_ADMIN_SENTRY_DSN=https://@o0.ingest.sentry.io/ +# SENTRY_DSN_API=https://@o0.ingest.sentry.io/ + # Docker Compose (docker-compose.dev.yml) # Override defaults for local dev; required in shared/production. Do not commit real secrets. # POSTGRES_USER=zedi diff --git a/admin/.env.example b/admin/.env.example index 1675692d..51e502cb 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -9,3 +9,7 @@ # Main app URL for "Sign in" link on login page (default: https://zedi-note.app) # VITE_MAIN_APP_URL=https://zedi-note.app + +# Sentry DSN for the admin SPA (Epic #616). Leave unset locally — SDK no-ops when blank. +# 管理画面用 Sentry DSN。未設定なら SDK は no-op となる。 +# VITE_ADMIN_SENTRY_DSN=https://@o0.ingest.sentry.io/ diff --git a/admin/e2e/errors-page.spec.ts b/admin/e2e/errors-page.spec.ts new file mode 100644 index 00000000..dd1c5f60 --- /dev/null +++ b/admin/e2e/errors-page.spec.ts @@ -0,0 +1,71 @@ +/** + * 管理画面 `/errors` の最小 E2E。`AdminGuard` と一覧 API を `page.route` で + * モックし、ネットワーク到達不要で UI のレンダリングのみを検証する。 + * + * Minimum E2E for the admin `/errors` page. Uses `page.route` to mock both the + * `AdminGuard` auth probe and the list API so the test does not depend on a + * running backend. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +import { test, expect } from "@playwright/test"; + +const MOCK_ERROR = { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "TypeError: cannot read properties of null", + route: "GET /api/users/:id", + statusCode: 500, + occurrences: 7, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "high", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", +}; + +test.describe("Admin /errors page", () => { + test.beforeEach(async ({ page }) => { + // AdminGuard が呼ぶ `getAdminMe` を満たすモック。 + // Mock the admin auth probe so AdminGuard renders its children. + await page.route("**/api/admin/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ id: "admin-1", email: "admin@example.com", role: "admin" }), + }); + }); + + // 一覧 API:ステータス指定の有無に関わらずモック行を返す。 + // Errors list API: serve the same mock row regardless of filter params. + await page.route("**/api/admin/errors**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + errors: [MOCK_ERROR], + total: 1, + limit: 50, + offset: 0, + }), + }); + }); + }); + + test("renders the errors list with the mocked row", async ({ page }) => { + await page.goto("/errors"); + + // ページ見出しと、モック行のタイトル・ルートが描画されることを確認。 + // Verify the page heading and the mocked row's title/route are rendered. + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await expect(page.getByText(MOCK_ERROR.title)).toBeVisible(); + await expect(page.getByText(MOCK_ERROR.route)).toBeVisible(); + }); +}); diff --git a/admin/package.json b/admin/package.json index 6e6fed36..08f12122 100644 --- a/admin/package.json +++ b/admin/package.json @@ -8,9 +8,11 @@ "build": "tsc -b && vite build", "preview": "vite preview", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config playwright.config.ts" }, "dependencies": { + "@sentry/react": "^10.51.0", "@zedi/ui": "workspace:*", "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", diff --git a/admin/playwright.config.ts b/admin/playwright.config.ts new file mode 100644 index 00000000..2afa6340 --- /dev/null +++ b/admin/playwright.config.ts @@ -0,0 +1,39 @@ +/** + * 管理画面 SPA 用の Playwright 設定。ルート (`playwright.config.ts`) はメインアプリ + * (ポート 5173)を起動するため、admin 用に別ポート (30001) を独立して立ち上げる。 + * + * Playwright config dedicated to the admin SPA. The root config boots the main + * app on port 5173, so the admin needs its own server on port 30001 with + * separate test selection so the two suites don't collide. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:30001", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "bun run dev -- --port 30001", + url: "http://localhost:30001", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 04be6ee6..f39dc1c4 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -7,6 +7,7 @@ import Users from "./pages/users"; import AuditLogs from "./pages/audit-logs"; import WikiHealth from "./pages/wiki-health"; import ActivityLog from "./pages/ActivityLog"; +import Errors from "./pages/errors"; /** * Root component for the admin SPA: sets up routing and the admin auth guard. @@ -33,6 +34,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/admin/src/api/admin.test.ts b/admin/src/api/admin.test.ts index 57ec2003..e7f7c61e 100644 --- a/admin/src/api/admin.test.ts +++ b/admin/src/api/admin.test.ts @@ -9,6 +9,10 @@ import { patchAiModelsBulk, previewSyncAiModels, syncAiModels, + getApiErrors, + getApiErrorById, + patchApiErrorStatus, + type ApiErrorRow, } from "./admin"; // `adminFetch` だけモックし、`getErrorMessage` は実装をそのまま使う @@ -215,3 +219,108 @@ describe("syncAiModels", () => { }); }); }); + +const sampleErrorRow: ApiErrorRow = { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "TypeError", + route: "GET /api/x", + statusCode: 500, + occurrences: 1, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "high", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", +}; + +describe("getApiErrors", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("status / severity / limit / offset をクエリ文字列に渡す", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [sampleErrorRow], total: 1, limit: 10, offset: 0 }), { + status: 200, + }), + ); + const out = await getApiErrors({ status: "open", severity: "high", limit: 10, offset: 0 }); + expect(out.errors).toHaveLength(1); + expect(out.total).toBe(1); + expect(adminFetch).toHaveBeenCalledWith( + "/api/admin/errors?status=open&severity=high&limit=10&offset=0", + ); + }); + + it("パラメータ無しのときはクエリ文字列を付けない", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [], total: 0, limit: 50, offset: 0 }), { + status: 200, + }), + ); + const out = await getApiErrors(); + expect(out.errors).toEqual([]); + expect(out.total).toBe(0); + expect(adminFetch).toHaveBeenCalledWith("/api/admin/errors"); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "boom" }), { status: 500 }), + ); + await expect(getApiErrors()).rejects.toThrow(/boom/); + }); +}); + +describe("getApiErrorById", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら row を返し、id を URL エンコードする", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: sampleErrorRow }), { status: 200 }), + ); + const out = await getApiErrorById(sampleErrorRow.id); + expect(out).toEqual(sampleErrorRow); + expect(adminFetch).toHaveBeenCalledWith(`/api/admin/errors/${sampleErrorRow.id}`); + }); +}); + +describe("patchApiErrorStatus", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("PATCH に status を載せ、更新後の row を返す", async () => { + const updated = { ...sampleErrorRow, status: "investigating" as const }; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: updated }), { status: 200 }), + ); + const out = await patchApiErrorStatus(sampleErrorRow.id, "investigating"); + expect(out.status).toBe("investigating"); + expect(adminFetch).toHaveBeenCalledWith(`/api/admin/errors/${sampleErrorRow.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "investigating" }), + }); + }); + + it("409 で throw する(並行更新競合)", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "status changed concurrently; refetch and retry" }), { + status: 409, + }), + ); + await expect(patchApiErrorStatus(sampleErrorRow.id, "resolved")).rejects.toThrow( + /status changed concurrently/, + ); + }); +}); diff --git a/admin/src/api/admin.ts b/admin/src/api/admin.ts index 8f712220..c3afb29b 100644 --- a/admin/src/api/admin.ts +++ b/admin/src/api/admin.ts @@ -338,6 +338,132 @@ export async function deleteUser(id: string): Promise<{ user: UserAdminBase }> { return res.json(); } +/** API エラーのワークフロー状態 / Workflow status for an API error */ +export type ApiErrorStatus = "open" | "investigating" | "resolved" | "ignored"; + +/** API エラーの重大度 / Severity assigned by AI analysis */ +export type ApiErrorSeverity = "high" | "medium" | "low" | "unknown"; + +/** + * 一覧でアクティブ(未対応扱い)として件数バッジに数えるステータス。 + * ナビバッジ表示やフィルター初期値の Single source of truth。 + * + * Statuses considered "active" (untriaged work) by the navigation badge. + * Single source of truth shared between Layout and the errors page. + */ +export const ACTIVE_API_ERROR_STATUSES: readonly ApiErrorStatus[] = ["open", "investigating"]; + +/** + * AI が推定した「関連しそうなファイル」のエントリ。 + * Suspected file entry produced by the AI analysis step. + */ +export interface ApiErrorSuspectedFile { + path: string; + reason?: string; + line?: number; +} + +/** + * `api_errors` 行のクライアント表現。タイムスタンプは ISO 文字列で扱う。 + * Client-side shape of an `api_errors` row. Timestamps are ISO strings. + */ +export interface ApiErrorRow { + id: string; + sentryIssueId: string; + fingerprint: string | null; + title: string; + route: string | null; + statusCode: number | null; + occurrences: number; + firstSeenAt: string; + lastSeenAt: string; + severity: ApiErrorSeverity; + status: ApiErrorStatus; + aiSummary: string | null; + aiSuspectedFiles: ApiErrorSuspectedFile[] | null; + aiRootCause: string | null; + aiSuggestedFix: string | null; + githubIssueNumber: number | null; + createdAt: string; + updatedAt: string; +} + +/** `GET /api/admin/errors` のクエリパラメータ / Query params for error list API */ +export interface GetApiErrorsParams { + status?: ApiErrorStatus; + severity?: ApiErrorSeverity; + limit?: number; + offset?: number; +} + +/** API エラー一覧のレスポンス / Response shape for error list API */ +export interface GetApiErrorsResponse { + errors: ApiErrorRow[]; + total: number; + limit: number; + offset: number; +} + +/** + * 管理画面用の API エラー一覧を取得する。 + * Fetches the `api_errors` list for the admin errors page. + * + * @param params - フィルタ・ページネーション / Filters and pagination + * @returns 行配列・総件数・適用された limit/offset / Rows, total count, and applied limit/offset + */ +export async function getApiErrors(params?: GetApiErrorsParams): Promise { + const sp = new URLSearchParams(); + if (params?.status) sp.set("status", params.status); + if (params?.severity) sp.set("severity", params.severity); + if (params?.limit != null) sp.set("limit", String(params.limit)); + if (params?.offset != null) sp.set("offset", String(params.offset)); + const qs = sp.toString(); + const res = await adminFetch(`/api/admin/errors${qs ? `?${qs}` : ""}`); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Failed to fetch API errors")); + } + return res.json(); +} + +/** + * 単一の API エラー詳細を取得する。 + * Fetches a single `api_errors` row by id. + * + * @param id - 行 ID / Row id (UUID) + * @returns 詳細行 / Detail row + */ +export async function getApiErrorById(id: string): Promise { + const res = await adminFetch(`/api/admin/errors/${encodeURIComponent(id)}`); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Failed to fetch API error")); + } + const data: { error: ApiErrorRow } = await res.json(); + return data.error; +} + +/** + * API エラーのワークフロー状態を更新する。 + * Updates the workflow `status` of an `api_errors` row. + * + * @param id - 行 ID / Row id + * @param status - 遷移先の状態 / Next status + * @returns 更新後の行 / Updated row + */ +export async function patchApiErrorStatus( + id: string, + status: ApiErrorStatus, +): Promise { + const res = await adminFetch(`/api/admin/errors/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); + if (!res.ok) { + throw new Error(await getErrorMessage(res, "Failed to update API error status")); + } + const data: { error: ApiErrorRow } = await res.json(); + return data.error; +} + /** * 監査ログ 1 行。 * A single admin audit log row as returned by `GET /api/admin/audit-logs`. diff --git a/admin/src/components/ErrorBoundary.test.tsx b/admin/src/components/ErrorBoundary.test.tsx new file mode 100644 index 00000000..fd8ba4bb --- /dev/null +++ b/admin/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "./ErrorBoundary"; + +const captureException = vi.fn(); +vi.mock("@/lib/sentry", () => ({ + captureException: (error: unknown) => captureException(error), +})); + +/** + * 任意の `Error` を render 時に投げるテスト用コンポーネント。 + * Helper that throws on render so the boundary's catch path executes. + */ +function Boom({ error }: { error: Error }): never { + throw error; +} + +describe("ErrorBoundary", () => { + beforeEach(() => { + captureException.mockReset(); + // React ボイラープレートのコンソール出力を抑制(テスト出力を読みやすく)。 + // Suppress React's expected error log so test output stays readable. + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("renders children when no error is thrown", () => { + render( + +
safe content
+
, + ); + expect(screen.getByText("safe content")).toBeInTheDocument(); + }); + + it("unmounts the failing subtree and renders the fallback when a child throws", () => { + const error = new Error("boom!"); + render( +
caught: {caught.message}
} + > + +
, + ); + + // フォールバックが描画され、子ツリーは unmount されている。 + // The fallback renders and the failing subtree is gone. + expect(screen.getByRole("alert")).toHaveTextContent("caught: boom!"); + }); + + it("forwards the caught exception to Sentry via captureException", () => { + const error = new Error("explode"); + render( +
fallback
}> + +
, + ); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(error); + }); + + it("renders a default fallback when no fallback prop is provided", () => { + render( + + + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent(/Something went wrong/); + expect(screen.getByText("default-fallback")).toBeInTheDocument(); + }); +}); diff --git a/admin/src/components/ErrorBoundary.tsx b/admin/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..690bc6ac --- /dev/null +++ b/admin/src/components/ErrorBoundary.tsx @@ -0,0 +1,75 @@ +import { Component } from "react"; +import type { ErrorInfo, ReactNode } from "react"; +import { captureException } from "@/lib/sentry"; + +interface ErrorBoundaryProps { + /** 通常時に描画する子ツリー / Children rendered while no error has been caught */ + children: ReactNode; + /** + * エラー時に描画するフォールバック。`reset` で内部状態をリセットする。 + * Optional render-prop for the fallback UI; `reset` clears the error so + * children can mount again. + */ + fallback?: (props: { error: Error; reset: () => void }) => ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * 子ツリーで発生した未捕捉例外をキャッチし Sentry に通知する境界。 + * フォールバック未指定時はミニマルなエラーメッセージを描画する。 + * + * Error boundary that catches unhandled exceptions from its subtree, forwards + * them to Sentry via `@/lib/sentry`, and renders a fallback UI in their place. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + /** React の標準 Error Boundary API。/ React's standard Error Boundary hook. */ + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + /** 例外を Sentry に転送する。/ Forward the caught exception to Sentry. */ + componentDidCatch(error: Error, info: ErrorInfo): void { + // Sentry が未初期化でも落ちないよう、helper 経由でガードする。 + // Route through the helper so an uninitialized Sentry SDK still no-ops cleanly. + try { + captureException(error); + } catch { + // 失敗時もフォールバック描画は継続する。 + // Continue rendering the fallback even if reporting fails. + } + if (import.meta.env.DEV) { + console.error("ErrorBoundary caught:", error, info.componentStack); + } + } + + /** フォールバックから内部状態をクリアする。/ Clear the captured error so children can mount. */ + reset = (): void => { + this.setState({ error: null }); + }; + + /** フォールバック / 子ツリーを切り替えて描画する。/ Render either the fallback or the children. */ + render(): ReactNode { + const { error } = this.state; + if (error) { + if (this.props.fallback) { + return this.props.fallback({ error, reset: this.reset }); + } + return ( +
+

Something went wrong.

+

{error.message}

+
+ ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/admin/src/i18n/index.ts b/admin/src/i18n/index.ts index 127bb8c1..821954b7 100644 --- a/admin/src/i18n/index.ts +++ b/admin/src/i18n/index.ts @@ -12,6 +12,7 @@ import jaAudit from "./locales/ja/audit.json"; import jaWikiHealth from "./locales/ja/wikiHealth.json"; import jaActivityLog from "./locales/ja/activityLog.json"; import jaAiModels from "./locales/ja/aiModels.json"; +import jaErrors from "./locales/ja/errors.json"; import enCommon from "./locales/en/common.json"; import enNav from "./locales/en/nav.json"; import enAuth from "./locales/en/auth.json"; @@ -20,6 +21,7 @@ import enAudit from "./locales/en/audit.json"; import enWikiHealth from "./locales/en/wikiHealth.json"; import enActivityLog from "./locales/en/activityLog.json"; import enAiModels from "./locales/en/aiModels.json"; +import enErrors from "./locales/en/errors.json"; const ja = { common: jaCommon, @@ -30,6 +32,7 @@ const ja = { wikiHealth: jaWikiHealth, activityLog: jaActivityLog, aiModels: jaAiModels, + errors: jaErrors, }; const en = { @@ -41,6 +44,7 @@ const en = { wikiHealth: enWikiHealth, activityLog: enActivityLog, aiModels: enAiModels, + errors: enErrors, }; /** diff --git a/admin/src/i18n/locales/en/errors.json b/admin/src/i18n/locales/en/errors.json new file mode 100644 index 00000000..8715e49a --- /dev/null +++ b/admin/src/i18n/locales/en/errors.json @@ -0,0 +1,46 @@ +{ + "title": "API Errors", + "empty": "No errors have been reported yet.", + "filters": { + "status": "Status", + "severity": "Severity" + }, + "columns": { + "status": "Status", + "severity": "Severity", + "title": "Title", + "route": "Route", + "occurrences": "Occurrences", + "lastSeen": "Last seen", + "actions": "Actions" + }, + "status": { + "open": "Open", + "investigating": "Investigating", + "resolved": "Resolved", + "ignored": "Ignored" + }, + "severity": { + "high": "High", + "medium": "Medium", + "low": "Low", + "unknown": "Unknown" + }, + "actions": { + "viewDetail": "View detail" + }, + "detail": { + "firstSeen": "First seen", + "lastSeen": "Last seen", + "severity": "Severity", + "statusCode": "HTTP status", + "occurrencesShort": "{{count}} occurrences", + "aiSummary": "AI summary", + "aiRootCause": "AI root cause", + "aiSuggestedFix": "AI suggested fix", + "suspectedFiles": "Suspected files", + "githubIssue": "Linked GitHub issue: #{{number}}", + "statusUpdate": "Update status", + "save": "Save" + } +} diff --git a/admin/src/i18n/locales/en/nav.json b/admin/src/i18n/locales/en/nav.json index 2c6fdaef..0e4cd0a9 100644 --- a/admin/src/i18n/locales/en/nav.json +++ b/admin/src/i18n/locales/en/nav.json @@ -2,11 +2,13 @@ "adminPanelTitle": "Zedi Admin", "adminShortTitle": "Admin", "menu": "Menu", + "unreadBadgeAriaLabel": "{{count}} unresolved", "items": { "aiModels": "AI Models", "users": "Users", "auditLogs": "Audit Logs", "wikiHealth": "Wiki Health", - "activityLog": "Activity Log" + "activityLog": "Activity Log", + "errors": "Errors" } } diff --git a/admin/src/i18n/locales/ja/errors.json b/admin/src/i18n/locales/ja/errors.json new file mode 100644 index 00000000..31375bf1 --- /dev/null +++ b/admin/src/i18n/locales/ja/errors.json @@ -0,0 +1,46 @@ +{ + "title": "API エラー", + "empty": "対象のエラーはまだ報告されていません。", + "filters": { + "status": "ステータス", + "severity": "重大度" + }, + "columns": { + "status": "状態", + "severity": "重大度", + "title": "タイトル", + "route": "ルート", + "occurrences": "発生回数", + "lastSeen": "最終発生", + "actions": "操作" + }, + "status": { + "open": "未対応", + "investigating": "調査中", + "resolved": "解決済み", + "ignored": "無視" + }, + "severity": { + "high": "高", + "medium": "中", + "low": "低", + "unknown": "未判定" + }, + "actions": { + "viewDetail": "詳細を見る" + }, + "detail": { + "firstSeen": "初回発生", + "lastSeen": "最終発生", + "severity": "重大度", + "statusCode": "HTTP ステータス", + "occurrencesShort": "{{count}} 回発生", + "aiSummary": "AI による要約", + "aiRootCause": "AI による原因仮説", + "aiSuggestedFix": "AI による修正方針", + "suspectedFiles": "関連が疑われるファイル", + "githubIssue": "関連 GitHub Issue: #{{number}}", + "statusUpdate": "ステータスを更新", + "save": "保存" + } +} diff --git a/admin/src/i18n/locales/ja/nav.json b/admin/src/i18n/locales/ja/nav.json index a4eb8248..72219d1e 100644 --- a/admin/src/i18n/locales/ja/nav.json +++ b/admin/src/i18n/locales/ja/nav.json @@ -2,11 +2,13 @@ "adminPanelTitle": "Zedi 管理画面", "adminShortTitle": "管理画面", "menu": "メニュー", + "unreadBadgeAriaLabel": "未対応 {{count}} 件", "items": { "aiModels": "AI モデル", "users": "ユーザー管理", "auditLogs": "監査ログ", "wikiHealth": "Wiki Health", - "activityLog": "活動ログ" + "activityLog": "活動ログ", + "errors": "エラー" } } diff --git a/admin/src/lib/sentry.ts b/admin/src/lib/sentry.ts new file mode 100644 index 00000000..9b3bc947 --- /dev/null +++ b/admin/src/lib/sentry.ts @@ -0,0 +1,50 @@ +/** + * 管理画面 (admin) 用の Sentry React SDK 初期化。 + * `VITE_ADMIN_SENTRY_DSN` が未設定なら no-op で動作する。 + * + * Sentry initialization for the admin SPA. No-ops when + * `VITE_ADMIN_SENTRY_DSN` is unset so local dev never reports. + * + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/804 + */ +import * as Sentry from "@sentry/react"; + +let initialized = false; + +/** + * Sentry SDK を初期化する。多重呼び出しは無視する。 + * Initializes the Sentry browser SDK. Subsequent calls are ignored. + * + * @returns 初期化を実行した場合は true、DSN 未設定や二回目以降は false + * / true when init ran, false when skipped + */ +export function initSentry(): boolean { + if (initialized) return false; + const dsn = import.meta.env.VITE_ADMIN_SENTRY_DSN?.trim(); + if (!dsn) return false; + + Sentry.init({ + dsn, + environment: import.meta.env.MODE, + // 管理画面でも PII の自動付与は禁止する(サーバ側と同じポリシー)。 + // Mirror the server-side policy: no automatic PII attachment. + sendDefaultPii: false, + tracesSampleRate: 0, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + }); + + initialized = true; + return true; +} + +/** + * 任意の例外を Sentry に送信するヘルパー。 + * Helper for forwarding caught exceptions to Sentry. + */ +export function captureException(error: unknown): void { + Sentry.captureException(error); +} + +export { Sentry }; diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 0315b359..76d18481 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -1,13 +1,21 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { initSentry } from "./lib/sentry"; import "./i18n"; import "./index.css"; +// Sentry は createRoot より前に初期化する(初回レンダリング時の例外も捕捉するため)。 +// Initialize Sentry before createRoot so first-render exceptions are reported. +initSentry(); + const rootEl = document.getElementById("root"); if (!rootEl) throw new Error("Root element #root not found"); createRoot(rootEl).render( - + + + , ); diff --git a/admin/src/pages/Layout.tsx b/admin/src/pages/Layout.tsx index 604c7525..a4bad156 100644 --- a/admin/src/pages/Layout.tsx +++ b/admin/src/pages/Layout.tsx @@ -1,7 +1,16 @@ import { Outlet, Link, useLocation } from "react-router-dom"; -import { Bot, Users, ScrollText, HeartPulse, Activity } from "lucide-react"; +import { + Bot, + Users, + ScrollText, + HeartPulse, + Activity, + AlertTriangle, + type LucideIcon, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { + Badge, SidebarProvider, Sidebar, SidebarContent, @@ -15,15 +24,73 @@ import { SidebarTrigger, SidebarHeader, } from "@zedi/ui"; +import { useApiErrorActiveCount } from "./errors/useApiErrorActiveCount"; + +interface NavItem { + to: string; + labelKey: string; + icon: LucideIcon; + /** バッジ表示用の件数を返す hook(任意) / Optional hook returning the badge count */ + useBadgeCount?: () => number; +} -const NAV_ITEMS = [ +const NAV_ITEMS: NavItem[] = [ { to: "/ai-models", labelKey: "nav.items.aiModels", icon: Bot }, { to: "/users", labelKey: "nav.items.users", icon: Users }, { to: "/audit-logs", labelKey: "nav.items.auditLogs", icon: ScrollText }, { to: "/wiki-health", labelKey: "nav.items.wikiHealth", icon: HeartPulse }, { to: "/activity-log", labelKey: "nav.items.activityLog", icon: Activity }, + { + to: "/errors", + labelKey: "nav.items.errors", + icon: AlertTriangle, + useBadgeCount: useApiErrorActiveCount, + }, ]; +interface NavLinkProps { + item: NavItem; + isActive: boolean; +} + +/** + * サイドバー 1 項目分のリンク。`useBadgeCount` 指定時はバッジを描画する。 + * Hook ルールを守るために `NavItem` を 1:1 に展開するコンポーネントとして切り出す。 + * + * Renders a single sidebar link, optionally with a numeric badge. Split out so + * the per-item Hook (`useBadgeCount`) is called from a stable component + * position rather than inside `NAV_ITEMS.map`. + */ +function NavLink({ item, isActive }: NavLinkProps) { + const { t } = useTranslation(); + const label = t(item.labelKey); + const Icon = item.icon; + // `useBadgeCount` は描画位置で固定されているため、Rules of Hooks に違反しない。 + // The hook is called from a fixed component position, so Rules of Hooks holds. + const badgeCount = item.useBadgeCount?.() ?? 0; + const showBadge = item.useBadgeCount != null && badgeCount > 0; + + return ( + + + + + {label} + {showBadge && ( + + {badgeCount > 99 ? "99+" : badgeCount} + + )} + + + + ); +} + /** * 管理画面のレイアウト(サイドバー付き)。 * Admin layout with sidebar navigation. @@ -43,20 +110,11 @@ export default function Layout() { {t("nav.menu")} - {NAV_ITEMS.map(({ to, labelKey, icon: Icon }) => { - const label = t(labelKey); + {NAV_ITEMS.map((item) => { const isActive = - location.pathname === to || (to !== "/" && location.pathname.startsWith(to)); - return ( - - - - - {label} - - - - ); + location.pathname === item.to || + (item.to !== "/" && location.pathname.startsWith(item.to)); + return ; })} diff --git a/admin/src/pages/errors/ErrorDetailDialog.tsx b/admin/src/pages/errors/ErrorDetailDialog.tsx new file mode 100644 index 00000000..dc492db8 --- /dev/null +++ b/admin/src/pages/errors/ErrorDetailDialog.tsx @@ -0,0 +1,198 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@zedi/ui"; +import type { ApiErrorRow, ApiErrorStatus } from "@/api/admin"; +import { formatDate, formatNumber } from "@/lib/dateUtils"; + +const STATUS_VALUES: ApiErrorStatus[] = ["open", "investigating", "resolved", "ignored"]; + +interface ErrorDetailDialogProps { + row: ApiErrorRow | null; + saving: boolean; + saveError: string | null; + onClose: () => void; + onUpdateStatus: (id: string, next: ApiErrorStatus) => Promise; +} + +/** + * 単一の API エラー詳細ダイアログ。AI 解析結果(要約・推定原因・関連ファイル)を + * 表示しつつ、`PATCH /api/admin/errors/:id` でステータスを更新できる。 + * + * Detail dialog for a single API error. Shows AI analysis output (summary, + * suspected files, root cause) and lets the admin update the workflow status + * via `PATCH /api/admin/errors/:id`. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +export function ErrorDetailDialog({ + row, + saving, + saveError, + onClose, + onUpdateStatus, +}: ErrorDetailDialogProps) { + const { t } = useTranslation(); + // 「現在開いている行」をキーに「未保存の status 選択」を持つ。row.id をキーに + // 含めることで、別行に切り替わった瞬間に `pendingStatus` を破棄でき、 + // useEffect での setState(cascading render の原因)を不要にできる。 + // + // Track unsaved status by the currently-open row id; switching rows + // automatically discards the prior selection without an effect that would + // trigger a cascading render. + const [pendingFor, setPendingFor] = useState<{ id: string; status: ApiErrorStatus } | null>(null); + const pendingStatus = pendingFor && row && pendingFor.id === row.id ? pendingFor.status : null; + const setPendingStatus = (next: ApiErrorStatus) => { + if (!row) return; + setPendingFor({ id: row.id, status: next }); + }; + + if (!row) return null; + + const effectiveStatus: ApiErrorStatus = pendingStatus ?? row.status; + const dirty = pendingStatus !== null && pendingStatus !== row.status; + + const handleSave = async () => { + if (!pendingStatus || pendingStatus === row.status) return; + await onUpdateStatus(row.id, pendingStatus); + }; + + return ( + !open && onClose()}> + + + {row.title} + + {row.route ? `${row.route} · ` : ""} + {t("errors.detail.occurrencesShort", { count: formatNumber(row.occurrences) })} + + + +
+
+
+
{t("errors.detail.firstSeen")}
+
{formatDate(row.firstSeenAt)}
+
+
+
{t("errors.detail.lastSeen")}
+
{formatDate(row.lastSeenAt)}
+
+
+
{t("errors.detail.severity")}
+
+ {t(`errors.severity.${row.severity}`)} +
+
+
+
{t("errors.detail.statusCode")}
+
{row.statusCode ?? "—"}
+
+
+ + {row.aiSummary && ( +
+

+ {t("errors.detail.aiSummary")} +

+

{row.aiSummary}

+
+ )} + + {row.aiRootCause && ( +
+

+ {t("errors.detail.aiRootCause")} +

+

{row.aiRootCause}

+
+ )} + + {row.aiSuggestedFix && ( +
+

+ {t("errors.detail.aiSuggestedFix")} +

+

{row.aiSuggestedFix}

+
+ )} + + {row.aiSuspectedFiles && row.aiSuspectedFiles.length > 0 && ( +
+

+ {t("errors.detail.suspectedFiles")} +

+
    + {row.aiSuspectedFiles.map((file, idx) => ( +
  • + {file.path} + {file.line != null ? `:${file.line}` : ""} + {file.reason ? ` — ${file.reason}` : ""} +
  • + ))} +
+
+ )} + + {row.githubIssueNumber != null && ( +

+ {t("errors.detail.githubIssue", { number: row.githubIssueNumber })} +

+ )} + +
+ + + {saveError && ( +

+ {saveError} +

+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/admin/src/pages/errors/ErrorsContent.test.tsx b/admin/src/pages/errors/ErrorsContent.test.tsx new file mode 100644 index 00000000..d057eca5 --- /dev/null +++ b/admin/src/pages/errors/ErrorsContent.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ErrorsContent } from "./ErrorsContent"; +import type { ApiErrorRow } from "@/api/admin"; + +vi.mock("@zedi/ui", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), + Select: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( + + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => {children}, + SelectValue: () => null, + Table: ({ children }: { children: React.ReactNode }) => {children}
, + TableBody: ({ children }: { children: React.ReactNode }) => {children}, + TableCell: ({ children }: { children: React.ReactNode }) => {children}, + TableHead: ({ children }: { children: React.ReactNode }) => {children}, + TableHeader: ({ children }: { children: React.ReactNode }) => {children}, + TableRow: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock("@/lib/dateUtils", () => ({ + formatDate: (d: string) => d, + formatNumber: (n: number) => n.toLocaleString("ja-JP"), + getActiveLocale: () => "ja-JP" as const, +})); + +const baseRow: ApiErrorRow = { + id: "00000000-0000-0000-0000-000000000001", + sentryIssueId: "sentry-1", + fingerprint: null, + title: "TypeError: cannot read properties of null", + route: "GET /api/users/:id", + statusCode: 500, + occurrences: 42, + firstSeenAt: "2026-05-01T00:00:00Z", + lastSeenAt: "2026-05-04T00:00:00Z", + severity: "high", + status: "open", + aiSummary: null, + aiSuspectedFiles: null, + aiRootCause: null, + aiSuggestedFix: null, + githubIssueNumber: null, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-04T00:00:00Z", +}; + +const defaultProps = { + rows: [baseRow], + total: 1, + loading: false, + error: null, + statusFilter: "all" as const, + severityFilter: "all" as const, + onStatusFilterChange: vi.fn(), + onSeverityFilterChange: vi.fn(), + onSelect: vi.fn(), +}; + +describe("ErrorsContent", () => { + it("renders the page title", () => { + render(); + expect(screen.getByRole("heading", { name: "API エラー" })).toBeInTheDocument(); + }); + + it("shows the empty-state message when rows is empty and not loading", () => { + render(); + expect(screen.getByText("対象のエラーはまだ報告されていません。")).toBeInTheDocument(); + }); + + it("renders one row with title, route, and occurrences", () => { + render(); + expect(screen.getByText(baseRow.title)).toBeInTheDocument(); + if (baseRow.route) { + expect(screen.getByText(baseRow.route)).toBeInTheDocument(); + } + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("HTTP 500")).toBeInTheDocument(); + }); + + it("invokes onSelect with the row when 詳細を見る is clicked", async () => { + const onSelect = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "詳細を見る" })); + expect(onSelect).toHaveBeenCalledWith(baseRow); + }); + + it("renders the loading message when loading and no rows are present yet", () => { + render(); + expect(screen.getByText("読み込み中...")).toBeInTheDocument(); + }); + + it("renders the error message in an alert region", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("server is down"); + }); +}); diff --git a/admin/src/pages/errors/ErrorsContent.tsx b/admin/src/pages/errors/ErrorsContent.tsx new file mode 100644 index 00000000..1c603551 --- /dev/null +++ b/admin/src/pages/errors/ErrorsContent.tsx @@ -0,0 +1,249 @@ +import { useTranslation } from "react-i18next"; +import { + Badge, + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@zedi/ui"; +import type { ApiErrorRow, ApiErrorSeverity, ApiErrorStatus } from "@/api/admin"; +import { formatDate, formatNumber } from "@/lib/dateUtils"; + +const ANY = "__any__"; + +interface ErrorsContentProps { + rows: ApiErrorRow[]; + total: number; + loading: boolean; + error: string | null; + statusFilter: ApiErrorStatus | "all"; + severityFilter: ApiErrorSeverity | "all"; + onStatusFilterChange: (next: ApiErrorStatus | "all") => void; + onSeverityFilterChange: (next: ApiErrorSeverity | "all") => void; + onSelect: (row: ApiErrorRow) => void; +} + +const STATUS_VALUES: ApiErrorStatus[] = ["open", "investigating", "resolved", "ignored"]; +const SEVERITY_VALUES: ApiErrorSeverity[] = ["high", "medium", "low", "unknown"]; + +/** + * `status` ごとのバッジ色(テーマトークンで揃える)。 + * Status badge variants matching the workflow semantics. + */ +function StatusBadge({ status }: { status: ApiErrorStatus }) { + const { t } = useTranslation(); + const label = t(`errors.status.${status}`); + switch (status) { + case "open": + return {label}; + case "investigating": + return ( + + {label} + + ); + case "resolved": + return ( + + {label} + + ); + case "ignored": + return {label}; + default: + return {status}; + } +} + +/** + * `severity` ごとのバッジ色。`unknown` は AI 解析未完了の暫定値。 + * Severity badge variants; `unknown` is the pre-analysis default. + */ +function SeverityBadge({ severity }: { severity: ApiErrorSeverity }) { + const { t } = useTranslation(); + const label = t(`errors.severity.${severity}`); + switch (severity) { + case "high": + return {label}; + case "medium": + return ( + + {label} + + ); + case "low": + return ( + + {label} + + ); + default: + return {label}; + } +} + +/** + * 管理画面「エラー一覧」のプレゼンテーション層。データ取得・状態管理は + * コンテナ (index.tsx) に分離する。 + * + * Presentational layer for the admin errors list. Data fetching and state + * management live in the container (`index.tsx`). + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +export function ErrorsContent({ + rows, + total, + loading, + error, + statusFilter, + severityFilter, + onStatusFilterChange, + onSeverityFilterChange, + onSelect, +}: ErrorsContentProps) { + const { t } = useTranslation(); + + return ( +
+
+

{t("errors.title")}

+
+ +
+
+ + +
+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {loading && rows.length === 0 ? ( +

{t("common.loading")}

+ ) : rows.length === 0 ? ( +

{t("errors.empty")}

+ ) : ( + <> +
+ + + + {t("errors.columns.status")} + {t("errors.columns.severity")} + {t("errors.columns.title")} + {t("errors.columns.route")} + {t("errors.columns.occurrences")} + {t("errors.columns.lastSeen")} + {t("errors.columns.actions")} + + + + {rows.map((row) => ( + + + + + + + + +
{row.title}
+ {row.statusCode != null && ( +
HTTP {row.statusCode}
+ )} +
+ + {row.route ?? "—"} + + + {formatNumber(row.occurrences)} + + + {formatDate(row.lastSeenAt)} + + + + +
+ ))} +
+
+
+ +

{t("common.totalCount", { count: total })}

+ + )} +
+ ); +} diff --git a/admin/src/pages/errors/index.tsx b/admin/src/pages/errors/index.tsx new file mode 100644 index 00000000..42ab48be --- /dev/null +++ b/admin/src/pages/errors/index.tsx @@ -0,0 +1,84 @@ +import { useCallback, useState } from "react"; +import type { ApiErrorRow, ApiErrorSeverity, ApiErrorStatus } from "@/api/admin"; +import { patchApiErrorStatus } from "@/api/admin"; +import { ErrorsContent } from "./ErrorsContent"; +import { ErrorDetailDialog } from "./ErrorDetailDialog"; +import { useApiErrors } from "./useApiErrors"; + +const PAGE_SIZE = 50; + +/** + * 管理画面「エラー一覧」のコンテナ。`useApiErrors` でポーリング取得しつつ、 + * 詳細ダイアログ・ステータス更新を組み合わせる。 + * + * Container for the admin errors page. Pulls list data with `useApiErrors` + * (Phase 1 polling) and orchestrates the detail dialog + status mutations. + * + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/804 + */ +export default function Errors() { + const [statusFilter, setStatusFilter] = useState("all"); + const [severityFilter, setSeverityFilter] = useState("all"); + const [selected, setSelected] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const { errors, total, loading, error, refetch } = useApiErrors({ + status: statusFilter === "all" ? undefined : statusFilter, + severity: severityFilter === "all" ? undefined : severityFilter, + limit: PAGE_SIZE, + }); + + const handleSelect = useCallback((row: ApiErrorRow) => { + setSelected(row); + setSaveError(null); + }, []); + + const handleClose = useCallback(() => { + setSelected(null); + setSaveError(null); + }, []); + + const handleUpdateStatus = useCallback( + async (id: string, next: ApiErrorStatus) => { + setSaving(true); + setSaveError(null); + try { + const updated = await patchApiErrorStatus(id, next); + // 更新後の最新値を即時反映するため、ダイアログ内の選択状態も書き換える。 + // Sync the dialog state with the server's authoritative row. + setSelected(updated); + await refetch(); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }, + [refetch], + ); + + return ( + <> + + + + ); +} diff --git a/admin/src/pages/errors/useApiErrorActiveCount.ts b/admin/src/pages/errors/useApiErrorActiveCount.ts new file mode 100644 index 00000000..f0e972d9 --- /dev/null +++ b/admin/src/pages/errors/useApiErrorActiveCount.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from "react"; +import { ACTIVE_API_ERROR_STATUSES, getApiErrors } from "@/api/admin"; +import { API_ERRORS_POLL_INTERVAL_MS } from "./useApiErrors"; + +/** + * サイドバーのバッジ用に「未対応 (`open` + `investigating`) 件数」だけを軽量に取得する。 + * + * `getApiErrors({ status, limit: 1 })` を `ACTIVE_API_ERROR_STATUSES` ごとに 1 回ずつ + * 叩き、レスポンスの `total` のみ合算する。行データを使わないので最小トラフィックで済む。 + * + * Lightweight count of "active" errors for the sidebar badge. Issues one + * `limit:1` call per active status and sums the `total` fields, so we only + * pay the cost of the COUNT query — not the row payload. + * + * @returns 件数 (取得失敗・未認証時は 0) / Count; falls back to 0 on error. + */ +export function useApiErrorActiveCount(): number { + const [count, setCount] = useState(0); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + let cancelled = false; + + const fetchCount = async () => { + try { + const results = await Promise.all( + ACTIVE_API_ERROR_STATUSES.map((status) => getApiErrors({ status, limit: 1 })), + ); + if (cancelled || !isMountedRef.current) return; + const total = results.reduce((sum, r) => sum + r.total, 0); + setCount(total); + } catch { + // バッジ取得失敗時は表示を更新しない(古い値を維持して誤った 0 表示を避ける)。 + // Swallow errors to keep stale-but-correct count rather than flashing 0. + } + }; + + void fetchCount(); + const id = window.setInterval(() => { + if (typeof document !== "undefined" && document.hidden) return; + void fetchCount(); + }, API_ERRORS_POLL_INTERVAL_MS); + + const onVisible = () => { + if (typeof document !== "undefined" && !document.hidden) { + void fetchCount(); + } + }; + document.addEventListener("visibilitychange", onVisible); + + return () => { + cancelled = true; + isMountedRef.current = false; + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVisible); + }; + }, []); + + return count; +} diff --git a/admin/src/pages/errors/useApiErrors.ts b/admin/src/pages/errors/useApiErrors.ts new file mode 100644 index 00000000..49610eaa --- /dev/null +++ b/admin/src/pages/errors/useApiErrors.ts @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ApiErrorRow, + ApiErrorSeverity, + ApiErrorStatus, + GetApiErrorsResponse, +} from "@/api/admin"; +import { getApiErrors } from "@/api/admin"; + +/** + * Phase 1 でのポーリング間隔 (ms)。WebSocket / Server-Sent Events を導入する Phase 2 で + * 廃止する。長すぎるとバッジ更新が遅く、短すぎると API 負荷が上がる。 + * + * Polling interval (ms) used during Phase 1; will be replaced by realtime + * updates in a later phase. Tuned to keep the badge fresh without hammering + * the API. + */ +export const API_ERRORS_POLL_INTERVAL_MS = 30_000; + +/** + * `useApiErrors` の入力。 + * Inputs accepted by the polling hook. + */ +export interface UseApiErrorsParams { + status?: ApiErrorStatus; + severity?: ApiErrorSeverity; + limit?: number; + offset?: number; + /** + * ポーリング間隔 (ms)。0 を渡すとポーリングを無効化する(テスト用途)。 + * Polling interval in ms; pass 0 to disable polling (used by tests). + */ + intervalMs?: number; +} + +/** + * `useApiErrors` の戻り値。 + * Hook return shape. + */ +export interface UseApiErrorsResult { + errors: ApiErrorRow[]; + total: number; + loading: boolean; + error: string | null; + /** 即時再取得(リクエスト中のレースは内部で処理) / Force refresh (race-safe) */ + refetch: () => Promise; +} + +/** + * `GET /api/admin/errors` を取得し、定期ポーリングで同期するフック。 + * + * Phase 1 では realtime 接続が無いため、`API_ERRORS_POLL_INTERVAL_MS` ごとに + * 再フェッチする。タブが非表示 (`document.hidden`) の間は API 負荷を抑えるため + * インターバルをスキップし、可視化された時に即時再取得する。 + * + * Polls `GET /api/admin/errors` on a fixed interval. Skips ticks while the tab + * is hidden to avoid wasted API traffic, and refetches immediately when the + * tab becomes visible again. + * + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/804 + */ +export function useApiErrors(params: UseApiErrorsParams = {}): UseApiErrorsResult { + const { status, severity, limit, offset, intervalMs = API_ERRORS_POLL_INTERVAL_MS } = params; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const isMountedRef = useRef(true); + const latestRequestRef = useRef(0); + + const load = useCallback( + async (showLoading: boolean) => { + const requestId = ++latestRequestRef.current; + if (showLoading && isMountedRef.current) setLoading(true); + try { + const result = await getApiErrors({ status, severity, limit, offset }); + if (!isMountedRef.current || requestId !== latestRequestRef.current) return; + setData(result); + setError(null); + } catch (e) { + if (!isMountedRef.current || requestId !== latestRequestRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (isMountedRef.current && requestId === latestRequestRef.current) { + setLoading(false); + } + } + }, + [status, severity, limit, offset], + ); + + useEffect(() => { + isMountedRef.current = true; + void load(true); + return () => { + isMountedRef.current = false; + }; + }, [load]); + + useEffect(() => { + if (intervalMs <= 0) return; + const tick = () => { + if (typeof document !== "undefined" && document.hidden) return; + void load(false); + }; + const id = window.setInterval(tick, intervalMs); + const onVisible = () => { + if (typeof document !== "undefined" && !document.hidden) { + void load(false); + } + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [intervalMs, load]); + + const refetch = useCallback(() => load(false), [load]); + + return { + errors: data?.errors ?? [], + total: data?.total ?? 0, + loading, + error, + refetch, + }; +} diff --git a/bun.lock b/bun.lock index cabae52b..01b7e304 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-visually-hidden": "^1.2.3", + "@sentry/react": "^10.51.0", "@tanstack/react-query": "^5.83.0", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2", @@ -178,6 +179,7 @@ "name": "zedi-admin", "version": "0.1.0", "dependencies": { + "@sentry/react": "^10.51.0", "@zedi/ui": "workspace:*", "i18next": "^26.0.1", "i18next-browser-languagedetector": "^8.2.1", @@ -1013,6 +1015,20 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA=="], + + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA=="], + + "@sentry-internal/replay": ["@sentry-internal/replay@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw=="], + + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.51.0", "", { "dependencies": { "@sentry-internal/replay": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA=="], + + "@sentry/browser": ["@sentry/browser@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry-internal/feedback": "10.51.0", "@sentry-internal/replay": "10.51.0", "@sentry-internal/replay-canvas": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA=="], + + "@sentry/core": ["@sentry/core@10.51.0", "", {}, "sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w=="], + + "@sentry/react": ["@sentry/react@10.51.0", "", { "dependencies": { "@sentry/browser": "10.51.0", "@sentry/core": "10.51.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-RRHHqjNvjji6ebIqdlAr453AkST8Vm4cxdu1vWm772IgbzTO7Jx46Cj6Bt2/GjMyH0YLE5euDaAOQhFMmpvAOw=="], + "@sindresorhus/base62": ["@sindresorhus/base62@1.0.0", "", {}, "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], diff --git a/package.json b/package.json index d151b284..28b916a2 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-visually-hidden": "^1.2.3", + "@sentry/react": "^10.51.0", "@tanstack/react-query": "^5.83.0", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2", diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..d35ecc7e --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,75 @@ +import { Component } from "react"; +import type { ErrorInfo, ReactNode } from "react"; +import { captureException } from "@/lib/sentry"; + +interface ErrorBoundaryProps { + /** 通常時に描画する子ツリー / Children rendered while no error has been caught */ + children: ReactNode; + /** + * エラー時に描画するフォールバック。`reset` で内部状態をリセットする。 + * Optional render-prop for the fallback UI; `reset` clears the error so + * children can mount again. + */ + fallback?: (props: { error: Error; reset: () => void }) => ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * 子ツリーで発生した未捕捉例外をキャッチし Sentry に通知する境界。 + * フォールバック未指定時はミニマルなエラーメッセージを描画する。 + * + * Error boundary that catches unhandled exceptions from its subtree, forwards + * them to Sentry via `@/lib/sentry`, and renders a fallback UI in their place. + * + * @see https://github.com/otomatty/zedi/issues/804 + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + /** React の標準 Error Boundary API。/ React's standard Error Boundary hook. */ + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + /** 例外を Sentry に転送する。/ Forward the caught exception to Sentry. */ + componentDidCatch(error: Error, info: ErrorInfo): void { + // Sentry が未初期化でも例外で落ちないよう、helper 経由でガードする。 + // Route through the helper so an uninitialized Sentry SDK still no-ops cleanly. + try { + captureException(error); + } catch { + // 失敗時もフォールバック描画は継続する。 + // Continue rendering the fallback even if reporting fails. + } + if (import.meta.env.DEV) { + console.error("ErrorBoundary caught:", error, info.componentStack); + } + } + + /** フォールバックから内部状態をクリアする。/ Clear the captured error so children can mount. */ + reset = (): void => { + this.setState({ error: null }); + }; + + /** フォールバック / 子ツリーを切り替えて描画する。/ Render either the fallback or the children. */ + render(): ReactNode { + const { error } = this.state; + if (error) { + if (this.props.fallback) { + return this.props.fallback({ error, reset: this.reset }); + } + return ( +
+

Something went wrong.

+

{error.message}

+
+ ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts new file mode 100644 index 00000000..17fecc80 --- /dev/null +++ b/src/lib/sentry.ts @@ -0,0 +1,58 @@ +/** + * Sentry React SDK のフロントエンド初期化。`VITE_SENTRY_DSN_WEB` が + * 未設定の場合は no-op となり、本番以外の DSN 漏れを防ぐ。 + * + * Frontend Sentry initialization. No-ops when `VITE_SENTRY_DSN_WEB` is unset + * so non-production builds don't accidentally ship a DSN. + * + * @see https://github.com/otomatty/zedi/issues/616 + * @see https://github.com/otomatty/zedi/issues/804 + */ +import * as Sentry from "@sentry/react"; + +let initialized = false; + +/** + * Sentry SDK を初期化する。多重呼び出しは無視する。 + * Initializes the Sentry browser SDK. Subsequent calls are ignored. + * + * @returns 初期化を実行した場合は true、DSN 未設定や二回目以降は false + * / true when init ran, false when skipped + */ +export function initSentry(): boolean { + if (initialized) return false; + const dsn = import.meta.env.VITE_SENTRY_DSN_WEB?.trim(); + if (!dsn) return false; + + Sentry.init({ + dsn, + environment: import.meta.env.MODE, + // PII(メール・IP 等)の自動付与は禁止する。サーバ側 (`server/api/src/lib/sentry.ts`) + // と同じポリシーを採る。 + // Disable automatic PII (email/IP) attachment to mirror the server-side policy. + sendDefaultPii: false, + // Phase 1 はトレースサンプリングを行わない(必要になれば後続で調整)。 + // Phase 1 leaves performance tracing off; revisit when we need it. + tracesSampleRate: 0, + // SPA でのロード負荷を避けるため、デバッグ用の Replay は導入しない。 + // No Session Replay in Phase 1 to keep the bundle small. + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + }); + + initialized = true; + return true; +} + +/** + * 任意の例外を Sentry に送信するヘルパー。テスト容易性のため Sentry の + * `captureException` を直接呼ばずにこの関数を経由する。 + * + * Helper for forwarding caught exceptions to Sentry. Tests can mock this + * module instead of the entire SDK. + */ +export function captureException(error: unknown): void { + Sentry.captureException(error); +} + +export { Sentry }; diff --git a/src/main.tsx b/src/main.tsx index 53a9b226..29ce2b04 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,19 +2,27 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { I18nextProvider } from "react-i18next"; import { MainAuthProvider } from "./components/auth/MainAuthProvider"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { initSentry } from "./lib/sentry"; import App from "./App.tsx"; import "./index.css"; import i18n from "./i18n"; +// Sentry は createRoot より前に初期化し、初回レンダリング時の例外も捕捉できるようにする。 +// Initialize Sentry before createRoot so first-render exceptions are reported. +initSentry(); + const rootEl = document.getElementById("root"); if (!rootEl) throw new Error("Root element #root not found"); createRoot(rootEl).render( {/* react-i18next: supply i18n to Portals (Radix ContextMenu, AlertDialog, …) / Portal 内の翻訳用 */} - - - + + + + + , ); From d494581c9b9b25a54aff11fab994fca89b3f7463 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 21:05:20 +0000 Subject: [PATCH 2/3] fix(admin): guard useApiErrorActiveCount against overlapping requests Polling ticks and visibilitychange callbacks can race. Track the latest request id (mirroring `useApiErrors`) and discard stale responses so a slow earlier fetch can't overwrite a fresher count. Addresses gemini-code-assist review on PR #813. --- admin/src/pages/errors/useApiErrorActiveCount.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/admin/src/pages/errors/useApiErrorActiveCount.ts b/admin/src/pages/errors/useApiErrorActiveCount.ts index f0e972d9..03c60adb 100644 --- a/admin/src/pages/errors/useApiErrorActiveCount.ts +++ b/admin/src/pages/errors/useApiErrorActiveCount.ts @@ -17,17 +17,25 @@ import { API_ERRORS_POLL_INTERVAL_MS } from "./useApiErrors"; export function useApiErrorActiveCount(): number { const [count, setCount] = useState(0); const isMountedRef = useRef(true); + // ポーリングと visibilitychange の発火が重なると、遅い古いリクエストが + // 新しい結果を上書きする恐れがある。`useApiErrors` と同じパターンで + // 「最新リクエスト ID」だけを採用するようガードする。 + // + // Polling and visibilitychange can race; a slow earlier response would + // otherwise overwrite a fresher one. Mirror `useApiErrors`' pattern and + // keep only the latest request's result. + const latestRequestRef = useRef(0); useEffect(() => { isMountedRef.current = true; - let cancelled = false; const fetchCount = async () => { + const requestId = ++latestRequestRef.current; try { const results = await Promise.all( ACTIVE_API_ERROR_STATUSES.map((status) => getApiErrors({ status, limit: 1 })), ); - if (cancelled || !isMountedRef.current) return; + if (!isMountedRef.current || requestId !== latestRequestRef.current) return; const total = results.reduce((sum, r) => sum + r.total, 0); setCount(total); } catch { @@ -50,8 +58,10 @@ export function useApiErrorActiveCount(): number { document.addEventListener("visibilitychange", onVisible); return () => { - cancelled = true; isMountedRef.current = false; + // unmount 後の遅延応答も確実に破棄するため request id を進めておく。 + // Bump the request id on unmount so any in-flight response is discarded. + latestRequestRef.current += 1; window.clearInterval(id); document.removeEventListener("visibilitychange", onVisible); }; From 0d96cf24605948bea48bc68ba2915372b36b4d31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 21:14:20 +0000 Subject: [PATCH 3/3] chore(admin): address CodeRabbit review feedback on PR #813 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18n: add `_one` / `_other` plural keys for `errors.detail.occurrencesShort` (was rendering "1 occurrences"). Pass `count` as a number so i18next plural resolution actually fires. - i18n: make `nav.unreadBadgeAriaLabel` self-describing for screen readers ("{{count}} unresolved API errors"). - sentry: extend `captureException` with an optional `{ extra }` context and forward `componentStack` from both `ErrorBoundary` components so Sentry events capture the failing subtree. - admin/src/api/admin.ts: expose `API_ERROR_STATUS_VALUES` and `API_ERROR_SEVERITY_VALUES` as the single source of truth; drop the local copies in `ErrorsContent` and `ErrorDetailDialog`. - useApiErrorActiveCount: fix JSDoc to match actual behavior (preserves last successful count on errors), and guard the bootstrap fetch with the same visibility check as the polling tick so a hidden tab doesn't pay for the initial fan-out. Skipped: extracting `ErrorBoundary` into a shared package — out of scope here. --- admin/src/api/admin.ts | 25 +++++++++++++++++++ admin/src/components/ErrorBoundary.test.tsx | 12 ++++++--- admin/src/components/ErrorBoundary.tsx | 8 +++++- admin/src/i18n/locales/en/errors.json | 3 ++- admin/src/i18n/locales/en/nav.json | 2 +- admin/src/i18n/locales/ja/errors.json | 3 ++- admin/src/i18n/locales/ja/nav.json | 2 +- admin/src/lib/sentry.ts | 17 +++++++++++-- admin/src/pages/errors/ErrorDetailDialog.tsx | 11 ++++---- admin/src/pages/errors/ErrorsContent.tsx | 8 +++--- .../pages/errors/useApiErrorActiveCount.ts | 14 +++++++++-- src/components/ErrorBoundary.tsx | 8 +++++- src/lib/sentry.ts | 17 +++++++++++-- 13 files changed, 105 insertions(+), 25 deletions(-) diff --git a/admin/src/api/admin.ts b/admin/src/api/admin.ts index c3afb29b..46d194cc 100644 --- a/admin/src/api/admin.ts +++ b/admin/src/api/admin.ts @@ -341,9 +341,34 @@ export async function deleteUser(id: string): Promise<{ user: UserAdminBase }> { /** API エラーのワークフロー状態 / Workflow status for an API error */ export type ApiErrorStatus = "open" | "investigating" | "resolved" | "ignored"; +/** + * UI のセレクト・タブ等で参照する `ApiErrorStatus` の全値。型定義から逸脱しない + * よう Single source of truth として `@/api/admin` に置く。 + * + * Single source of truth for the full set of `ApiErrorStatus` values consumed + * by UI (select dropdowns, tabs, status badges). + */ +export const API_ERROR_STATUS_VALUES: readonly ApiErrorStatus[] = [ + "open", + "investigating", + "resolved", + "ignored", +]; + /** API エラーの重大度 / Severity assigned by AI analysis */ export type ApiErrorSeverity = "high" | "medium" | "low" | "unknown"; +/** + * `ApiErrorSeverity` の全値(UI フィルターで使用)。 + * Full set of `ApiErrorSeverity` values used by UI filters. + */ +export const API_ERROR_SEVERITY_VALUES: readonly ApiErrorSeverity[] = [ + "high", + "medium", + "low", + "unknown", +]; + /** * 一覧でアクティブ(未対応扱い)として件数バッジに数えるステータス。 * ナビバッジ表示やフィルター初期値の Single source of truth。 diff --git a/admin/src/components/ErrorBoundary.test.tsx b/admin/src/components/ErrorBoundary.test.tsx index fd8ba4bb..81f78006 100644 --- a/admin/src/components/ErrorBoundary.test.tsx +++ b/admin/src/components/ErrorBoundary.test.tsx @@ -4,7 +4,8 @@ import { ErrorBoundary } from "./ErrorBoundary"; const captureException = vi.fn(); vi.mock("@/lib/sentry", () => ({ - captureException: (error: unknown) => captureException(error), + captureException: (error: unknown, context?: { extra?: Record }) => + captureException(error, context), })); /** @@ -47,7 +48,7 @@ describe("ErrorBoundary", () => { expect(screen.getByRole("alert")).toHaveTextContent("caught: boom!"); }); - it("forwards the caught exception to Sentry via captureException", () => { + it("forwards the caught exception and component stack to Sentry", () => { const error = new Error("explode"); render(
fallback
}> @@ -56,7 +57,12 @@ describe("ErrorBoundary", () => { ); expect(captureException).toHaveBeenCalledTimes(1); - expect(captureException).toHaveBeenCalledWith(error); + const [forwardedError, context] = captureException.mock.calls[0]; + expect(forwardedError).toBe(error); + // componentStack は React が提供する文字列。中身は React のバージョンに + // 依存するので具体値ではなく型のみ検証する。 + // The component stack content depends on React internals; verify shape only. + expect(context).toMatchObject({ extra: { componentStack: expect.any(String) } }); }); it("renders a default fallback when no fallback prop is provided", () => { diff --git a/admin/src/components/ErrorBoundary.tsx b/admin/src/components/ErrorBoundary.tsx index 690bc6ac..291d420c 100644 --- a/admin/src/components/ErrorBoundary.tsx +++ b/admin/src/components/ErrorBoundary.tsx @@ -37,9 +37,15 @@ export class ErrorBoundary extends Component; +} + /** * 任意の例外を Sentry に送信するヘルパー。 * Helper for forwarding caught exceptions to Sentry. + * + * @param error - 例外オブジェクト / Caught exception + * @param context - `{ extra: {...} }` 形式の追加コンテキスト(任意) + * / Optional `{ extra: {...} }` context attached to the event */ -export function captureException(error: unknown): void { - Sentry.captureException(error); +export function captureException(error: unknown, context?: CaptureExtras): void { + Sentry.captureException(error, context); } export { Sentry }; diff --git a/admin/src/pages/errors/ErrorDetailDialog.tsx b/admin/src/pages/errors/ErrorDetailDialog.tsx index dc492db8..d2660091 100644 --- a/admin/src/pages/errors/ErrorDetailDialog.tsx +++ b/admin/src/pages/errors/ErrorDetailDialog.tsx @@ -16,9 +16,8 @@ import { SelectValue, } from "@zedi/ui"; import type { ApiErrorRow, ApiErrorStatus } from "@/api/admin"; -import { formatDate, formatNumber } from "@/lib/dateUtils"; - -const STATUS_VALUES: ApiErrorStatus[] = ["open", "investigating", "resolved", "ignored"]; +import { API_ERROR_STATUS_VALUES } from "@/api/admin"; +import { formatDate } from "@/lib/dateUtils"; interface ErrorDetailDialogProps { row: ApiErrorRow | null; @@ -77,7 +76,9 @@ export function ErrorDetailDialog({ {row.title} {row.route ? `${row.route} · ` : ""} - {t("errors.detail.occurrencesShort", { count: formatNumber(row.occurrences) })} + {/* `count` は i18next の plural ルール解決にそのまま使われるので number で渡す。 + Pass `count` as a number so i18next plural resolution works (`_one` / `_other`). */} + {t("errors.detail.occurrencesShort", { count: row.occurrences })} @@ -169,7 +170,7 @@ export function ErrorDetailDialog({ - {STATUS_VALUES.map((value) => ( + {API_ERROR_STATUS_VALUES.map((value) => ( {t(`errors.status.${value}`)} diff --git a/admin/src/pages/errors/ErrorsContent.tsx b/admin/src/pages/errors/ErrorsContent.tsx index 1c603551..32cb5c6d 100644 --- a/admin/src/pages/errors/ErrorsContent.tsx +++ b/admin/src/pages/errors/ErrorsContent.tsx @@ -15,6 +15,7 @@ import { TableRow, } from "@zedi/ui"; import type { ApiErrorRow, ApiErrorSeverity, ApiErrorStatus } from "@/api/admin"; +import { API_ERROR_SEVERITY_VALUES, API_ERROR_STATUS_VALUES } from "@/api/admin"; import { formatDate, formatNumber } from "@/lib/dateUtils"; const ANY = "__any__"; @@ -31,9 +32,6 @@ interface ErrorsContentProps { onSelect: (row: ApiErrorRow) => void; } -const STATUS_VALUES: ApiErrorStatus[] = ["open", "investigating", "resolved", "ignored"]; -const SEVERITY_VALUES: ApiErrorSeverity[] = ["high", "medium", "low", "unknown"]; - /** * `status` ごとのバッジ色(テーマトークンで揃える)。 * Status badge variants matching the workflow semantics. @@ -136,7 +134,7 @@ export function ErrorsContent({ {t("common.all")} - {STATUS_VALUES.map((value) => ( + {API_ERROR_STATUS_VALUES.map((value) => ( {t(`errors.status.${value}`)} @@ -166,7 +164,7 @@ export function ErrorsContent({ {t("common.all")} - {SEVERITY_VALUES.map((value) => ( + {API_ERROR_SEVERITY_VALUES.map((value) => ( {t(`errors.severity.${value}`)} diff --git a/admin/src/pages/errors/useApiErrorActiveCount.ts b/admin/src/pages/errors/useApiErrorActiveCount.ts index 03c60adb..5ab9acac 100644 --- a/admin/src/pages/errors/useApiErrorActiveCount.ts +++ b/admin/src/pages/errors/useApiErrorActiveCount.ts @@ -12,7 +12,11 @@ import { API_ERRORS_POLL_INTERVAL_MS } from "./useApiErrors"; * `limit:1` call per active status and sums the `total` fields, so we only * pay the cost of the COUNT query — not the row payload. * - * @returns 件数 (取得失敗・未認証時は 0) / Count; falls back to 0 on error. + * @returns 初期値は 0。取得成功で値を更新し、失敗時は直前の取得値を維持する + * (誤った 0 表示でフラッシュしないため)。 + * / Starts at 0; updates on successful fetches and preserves the last + * successful value when a refresh fails (so the badge does not flash + * to 0 on transient errors). */ export function useApiErrorActiveCount(): number { const [count, setCount] = useState(0); @@ -44,7 +48,13 @@ export function useApiErrorActiveCount(): number { } }; - void fetchCount(); + // 初回ブートストラップもポーリングと同じ可視性ガードに従わせる。バックグラウンド + // タブで mount された際に fan-out リクエストを走らせない。 + // Apply the same visibility guard to the bootstrap fetch so a tab that + // mounts while hidden does not pay the full fan-out before any tick fires. + if (typeof document === "undefined" || !document.hidden) { + void fetchCount(); + } const id = window.setInterval(() => { if (typeof document !== "undefined" && document.hidden) return; void fetchCount(); diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index d35ecc7e..02e9df78 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -37,9 +37,15 @@ export class ErrorBoundary extends Component; +} + /** * 任意の例外を Sentry に送信するヘルパー。テスト容易性のため Sentry の * `captureException` を直接呼ばずにこの関数を経由する。 * * Helper for forwarding caught exceptions to Sentry. Tests can mock this * module instead of the entire SDK. + * + * @param error - 例外オブジェクト / Caught exception + * @param context - `{ extra: {...} }` 形式の追加コンテキスト(任意) + * / Optional `{ extra: {...} }` context attached to the event */ -export function captureException(error: unknown): void { - Sentry.captureException(error); +export function captureException(error: unknown, context?: CaptureExtras): void { + Sentry.captureException(error, context); } export { Sentry };