Skip to content
Merged
2 changes: 2 additions & 0 deletions admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import AiModels from "./pages/ai-models";
import Users from "./pages/users";
import AuditLogs from "./pages/audit-logs";
import WikiHealth from "./pages/wiki-health";
import ActivityLog from "./pages/ActivityLog";

/**
* Root component for the admin SPA: sets up routing and the admin auth guard.
Expand All @@ -31,6 +32,7 @@ function App() {
<Route path="users" element={<Users />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="wiki-health" element={<WikiHealth />} />
<Route path="activity-log" element={<ActivityLog />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
Expand Down
84 changes: 84 additions & 0 deletions admin/src/api/activity.ts
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();
}
8 changes: 7 additions & 1 deletion admin/src/api/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { adminFetch } from "./client";
* Lint ルール名の型。
* Lint rule name type.
*/
export type LintRule = "orphan" | "ghost_many" | "title_similar" | "conflict" | "broken_link";
export type LintRule =
| "orphan"
| "ghost_many"
| "title_similar"
| "conflict"
| "broken_link"
| "stale";

/**
* Lint 重要度の型。
Expand Down
218 changes: 218 additions & 0 deletions admin/src/pages/ActivityLog.tsx
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>
);
}
3 changes: 2 additions & 1 deletion admin/src/pages/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Outlet, Link, useLocation } from "react-router-dom";
import { Bot, Users, ScrollText, HeartPulse } from "lucide-react";
import { Bot, Users, ScrollText, HeartPulse, Activity } from "lucide-react";
import {
SidebarProvider,
Sidebar,
Expand All @@ -20,6 +20,7 @@ const navLinks = [
{ to: "/users", label: "ユーザー管理", icon: Users },
{ to: "/audit-logs", label: "監査ログ", icon: ScrollText },
{ to: "/wiki-health", label: "Wiki Health", icon: HeartPulse },
{ to: "/activity-log", label: "活動ログ", icon: Activity },
];

/**
Expand Down
3 changes: 2 additions & 1 deletion admin/src/pages/wiki-health/WikiHealthContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const RULE_LABELS: Record<LintRule, string> = {
title_similar: "タイトル類似 / Title Similar",
conflict: "矛盾 / Conflict",
broken_link: "リンク切れ / Broken Link",
stale: "古い情報 / Stale",
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
};

/**
Expand Down Expand Up @@ -104,7 +105,7 @@ export function WikiHealthContent({

{/* サマリ表示 / Summary display */}
{summary && (
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-5">
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-6">
{summary.map((s) => (
<div key={s.rule} className="bg-muted/50 rounded border px-3 py-2">
<div className="text-muted-foreground text-xs">{RULE_LABELS[s.rule]}</div>
Expand Down
25 changes: 25 additions & 0 deletions server/api/drizzle/0010_add_activity_log.sql
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);
Loading
Loading