-
Notifications
You must be signed in to change notification settings - Fork 0
feat(wiki): index ページ・活動ログ・stale 検出を追加 (P4, #598) #604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
8c413a4
feat(wiki): index ページ・活動ログ・stale 検出を追加 (P4, #598)
otomatty 37948e4
fix(wiki): addressレビュー指摘3件 (PR #604)
otomatty cbcd9a3
fix(api): wikiSchema トランザクション整合と ingest プロバイダー解決を修正 (PR #604)
otomatty 23e50b9
fix(lint,extractor): cross-tenant 漏洩・1桁数値検出・Shorts URL を修正 (PR #604)
otomatty 4795070
fix(web): キャッシュ分離・abort 整理・型ガード・プロンプト堅牢化 (PR #604)
otomatty a987f79
fix(wiki): index 画面のマウント時は read-only GET に切替 (PR #604)
otomatty 56df41e
fix(admin): stale 追加に伴い lint サマリを 6 カラムに拡張 (PR #604)
otomatty File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { adminFetch } from "./client"; | ||
|
|
||
| /** | ||
| * 活動ログの種別。 | ||
| * Activity kind enumeration. | ||
| */ | ||
| export type ActivityKind = | ||
| | "clip_ingest" | ||
| | "chat_promote" | ||
| | "lint_run" | ||
| | "wiki_generate" | ||
| | "index_build" | ||
| | "wiki_schema_update"; | ||
|
|
||
| /** | ||
| * 活動ログの起点(user / ai / system)。 | ||
| * Activity actor. | ||
| */ | ||
| export type ActivityActor = "user" | "ai" | "system"; | ||
|
|
||
| /** | ||
| * 1 件の活動ログ。 | ||
| * A single activity entry. | ||
| */ | ||
| export interface ActivityEntry { | ||
| id: string; | ||
| kind: ActivityKind; | ||
| actor: ActivityActor; | ||
| target_page_ids: string[]; | ||
| detail: Record<string, unknown> | null; | ||
| created_at: string; | ||
| } | ||
|
|
||
| /** | ||
| * 活動ログ取得レスポンス。 | ||
| * Response from GET /api/activity. | ||
| */ | ||
| export interface ActivityListResponse { | ||
| entries: ActivityEntry[]; | ||
| total: number; | ||
| limit: number; | ||
| } | ||
|
|
||
| /** | ||
| * 一覧クエリパラメータ。 | ||
| * List query parameters. | ||
| */ | ||
| export interface ActivityListParams { | ||
| kind?: ActivityKind; | ||
| actor?: ActivityActor; | ||
| from?: string; | ||
| to?: string; | ||
| limit?: number; | ||
| offset?: number; | ||
| } | ||
|
|
||
| async function getErrorMessage(res: Response, fallback: string): Promise<string> { | ||
| const err = await res.json().catch(() => ({ message: res.statusText })); | ||
| return (err as { message?: string }).message ?? fallback; | ||
| } | ||
|
|
||
| /** | ||
| * 自分の活動ログを取得する。 | ||
| * Fetches the current user's activity entries. | ||
| * | ||
| * @param params - フィルタ・ページング / Filters and paging | ||
| */ | ||
| export async function listActivity(params: ActivityListParams = {}): Promise<ActivityListResponse> { | ||
| const query = new URLSearchParams(); | ||
| if (params.kind) query.set("kind", params.kind); | ||
| if (params.actor) query.set("actor", params.actor); | ||
| if (params.from) query.set("from", params.from); | ||
| if (params.to) query.set("to", params.to); | ||
| if (typeof params.limit === "number") query.set("limit", String(params.limit)); | ||
| if (typeof params.offset === "number") query.set("offset", String(params.offset)); | ||
| const qs = query.toString(); | ||
| const path = qs ? `/api/activity?${qs}` : "/api/activity"; | ||
|
|
||
| const res = await adminFetch(path); | ||
| if (!res.ok) { | ||
| throw new Error(await getErrorMessage(res, "Failed to fetch activity entries")); | ||
| } | ||
| return res.json(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { | ||
| Badge, | ||
| Button, | ||
| Select, | ||
| SelectContent, | ||
| SelectItem, | ||
| SelectTrigger, | ||
| SelectValue, | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from "@zedi/ui"; | ||
| import { | ||
| listActivity, | ||
| type ActivityActor, | ||
| type ActivityEntry, | ||
| type ActivityKind, | ||
| } from "@/api/activity"; | ||
| import { formatDate } from "@/lib/dateUtils"; | ||
|
|
||
| /** | ||
| * ルール/起点のラベルマップ。 | ||
| * Labels for activity kind and actor. | ||
| */ | ||
| const KIND_LABELS: Record<ActivityKind, string> = { | ||
| clip_ingest: "クリップ取り込み / Clip ingest", | ||
| chat_promote: "Chat → Wiki 昇格 / Chat promote", | ||
| lint_run: "Lint 実行 / Lint run", | ||
| wiki_generate: "Wiki 生成 / Wiki generate", | ||
| index_build: "Index 構築 / Index build", | ||
| wiki_schema_update: "スキーマ更新 / Schema update", | ||
| }; | ||
| const ACTOR_LABELS: Record<ActivityActor, string> = { | ||
| user: "ユーザー / User", | ||
| ai: "AI", | ||
| system: "システム / System", | ||
| }; | ||
|
|
||
| const ANY = "__any__"; | ||
| const KINDS: ActivityKind[] = [ | ||
| "clip_ingest", | ||
| "chat_promote", | ||
| "lint_run", | ||
| "wiki_generate", | ||
| "index_build", | ||
| "wiki_schema_update", | ||
| ]; | ||
| const ACTORS: ActivityActor[] = ["user", "ai", "system"]; | ||
|
|
||
| /** | ||
| * detail JSON からサマリ文字列を組み立てる。 | ||
| * Builds a human-readable summary from a detail payload. | ||
| */ | ||
| function formatDetail(entry: ActivityEntry): string { | ||
| const detail = entry.detail ?? {}; | ||
| if (entry.kind === "lint_run" && typeof detail.total === "number") { | ||
| return `${detail.total} findings`; | ||
| } | ||
| if (entry.kind === "index_build") { | ||
| const total = typeof detail.totalPages === "number" ? detail.totalPages : "?"; | ||
| const cats = typeof detail.categoryCount === "number" ? detail.categoryCount : "?"; | ||
| return `${total} pages / ${cats} categories`; | ||
| } | ||
| if (entry.kind === "clip_ingest" || entry.kind === "chat_promote") { | ||
| const title = typeof detail.title === "string" ? detail.title : null; | ||
| const url = typeof detail.url === "string" ? detail.url : null; | ||
| return [title, url].filter(Boolean).join(" — ") || "—"; | ||
| } | ||
| if (entry.kind === "wiki_schema_update") { | ||
| const len = typeof detail.contentLength === "number" ? detail.contentLength : "?"; | ||
| return `content: ${len} chars`; | ||
| } | ||
| return JSON.stringify(detail); | ||
| } | ||
|
|
||
| /** | ||
| * 管理画面「活動ログ」ページ。`__index__` 再構築ボタンも備える。 | ||
| * Admin Activity Log page. | ||
| */ | ||
| export default function ActivityLog() { | ||
| const [entries, setEntries] = useState<ActivityEntry[]>([]); | ||
| const [total, setTotal] = useState(0); | ||
| const [loading, setLoading] = useState(true); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [kindFilter, setKindFilter] = useState<ActivityKind | undefined>(undefined); | ||
| const [actorFilter, setActorFilter] = useState<ActivityActor | undefined>(undefined); | ||
|
|
||
| const mountedRef = useRef(true); | ||
|
|
||
| const load = useCallback(async () => { | ||
| if (mountedRef.current) setLoading(true); | ||
| if (mountedRef.current) setError(null); | ||
| try { | ||
| const result = await listActivity({ kind: kindFilter, actor: actorFilter, limit: 100 }); | ||
| if (!mountedRef.current) return; | ||
| setEntries(result.entries); | ||
| setTotal(result.total); | ||
| } catch (e) { | ||
| if (!mountedRef.current) return; | ||
| setError(e instanceof Error ? e.message : String(e)); | ||
| } finally { | ||
| if (mountedRef.current) setLoading(false); | ||
| } | ||
| }, [kindFilter, actorFilter]); | ||
|
|
||
| useEffect(() => { | ||
| mountedRef.current = true; | ||
| void load(); | ||
| return () => { | ||
| mountedRef.current = false; | ||
| }; | ||
| }, [load]); | ||
|
|
||
| const onKind = (v: string) => setKindFilter(v === ANY ? undefined : (v as ActivityKind)); | ||
| const onActor = (v: string) => setActorFilter(v === ANY ? undefined : (v as ActivityActor)); | ||
|
|
||
| return ( | ||
| <div> | ||
| <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> | ||
| <h1 className="text-lg font-semibold">Activity Log / 活動ログ</h1> | ||
| <Button type="button" onClick={() => void load()} disabled={loading}> | ||
| {loading ? "読み込み中..." : "再読み込み"} | ||
| </Button> | ||
| </div> | ||
|
|
||
| <div className="mt-4 flex flex-wrap items-end gap-4"> | ||
| <div> | ||
| <label htmlFor="activity-kind" className="text-muted-foreground mb-1 block text-xs"> | ||
| 種別 / Kind | ||
| </label> | ||
| <Select value={kindFilter ?? ANY} onValueChange={onKind}> | ||
| <SelectTrigger id="activity-kind" className="w-60"> | ||
| <SelectValue /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value={ANY}>すべて</SelectItem> | ||
| {KINDS.map((k) => ( | ||
| <SelectItem key={k} value={k}> | ||
| {KIND_LABELS[k]} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| </div> | ||
| <div> | ||
| <label htmlFor="activity-actor" className="text-muted-foreground mb-1 block text-xs"> | ||
| 起点 / Actor | ||
| </label> | ||
| <Select value={actorFilter ?? ANY} onValueChange={onActor}> | ||
| <SelectTrigger id="activity-actor" className="w-48"> | ||
| <SelectValue /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value={ANY}>すべて</SelectItem> | ||
| {ACTORS.map((a) => ( | ||
| <SelectItem key={a} value={a}> | ||
| {ACTOR_LABELS[a]} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| </div> | ||
| </div> | ||
|
|
||
| {error && ( | ||
| <div className="mt-2 rounded bg-red-900/30 px-3 py-2 text-sm text-red-200">{error}</div> | ||
| )} | ||
|
|
||
| {loading && entries.length === 0 ? ( | ||
| <p className="text-muted-foreground mt-4">読み込み中...</p> | ||
| ) : entries.length === 0 ? ( | ||
| <p className="text-muted-foreground mt-4">活動ログはまだありません。</p> | ||
| ) : ( | ||
| <div className="mt-4 overflow-x-auto"> | ||
| <Table className="border-border min-w-[720px] rounded border"> | ||
| <TableHeader> | ||
| <TableRow className="border-border bg-muted/50 hover:bg-transparent"> | ||
| <TableHead className="px-3 py-2">種別</TableHead> | ||
| <TableHead className="px-3 py-2">起点</TableHead> | ||
| <TableHead className="px-3 py-2">詳細</TableHead> | ||
| <TableHead className="px-3 py-2">関連ページ</TableHead> | ||
| <TableHead className="px-3 py-2">記録日時</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {entries.map((entry) => ( | ||
| <TableRow key={entry.id} className="border-border align-top"> | ||
| <TableCell className="px-3 py-2"> | ||
| <Badge variant="outline">{KIND_LABELS[entry.kind]}</Badge> | ||
| </TableCell> | ||
| <TableCell className="px-3 py-2"> | ||
| <Badge variant="secondary">{ACTOR_LABELS[entry.actor]}</Badge> | ||
| </TableCell> | ||
| <TableCell className="max-w-md px-3 py-2 text-sm break-words"> | ||
| {formatDetail(entry)} | ||
| </TableCell> | ||
| <TableCell className="text-muted-foreground px-3 py-2 text-xs"> | ||
| {entry.target_page_ids.length} | ||
| </TableCell> | ||
| <TableCell className="text-muted-foreground px-3 py-2 whitespace-nowrap"> | ||
| {formatDate(entry.created_at)} | ||
| </TableCell> | ||
| </TableRow> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| <p className="text-muted-foreground mt-2 text-xs"> | ||
| 表示 {entries.length} / 合計 {total} 件 | ||
| </p> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| -- Add `activity_log` table for Wiki-level actions (P4, otomatty/zedi#598). | ||
| -- Wiki レベルの行動履歴(ingest / chat 昇格 / lint 実行 / wiki 生成等)を記録する | ||
| -- append-only ログ。`ai_usage_logs`(課金)や `admin_audit_logs`(管理者監査) | ||
| -- とは用途が異なり、ユーザーの Wiki がどう育ったかを時系列で可視化する。 | ||
| -- | ||
| -- See parent epic otomatty/zedi#594, sub-issue #598. | ||
|
|
||
| CREATE TABLE "activity_log" ( | ||
| "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | ||
| "owner_id" text NOT NULL, | ||
| "kind" text NOT NULL, | ||
| "actor" text NOT NULL, | ||
| "target_page_ids" text[] DEFAULT '{}' NOT NULL, | ||
| "detail" jsonb, | ||
| "created_at" timestamp with time zone DEFAULT now() NOT NULL | ||
| ); | ||
| --> statement-breakpoint | ||
| ALTER TABLE "activity_log" | ||
| ADD CONSTRAINT "activity_log_owner_id_user_id_fk" | ||
| FOREIGN KEY ("owner_id") REFERENCES "user"("id") ON DELETE cascade ON UPDATE no action; | ||
| --> statement-breakpoint | ||
| CREATE INDEX "idx_activity_log_owner_created" ON "activity_log" ("owner_id", "created_at" DESC); | ||
| --> statement-breakpoint | ||
| CREATE INDEX "idx_activity_log_owner_kind_created" | ||
| ON "activity_log" ("owner_id", "kind", "created_at" DESC); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.