mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Mirror main branch to main-mirror-for-wdb / lint_and_build (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
Sync Main to Dev / sync-commits (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automated AI QA review pipeline and human-verified knowledge base consulted first * Internal MCP review tool: call log viewer, conversation replay, add/edit/publish Q&A, knowledge editor, and analytics * Docs search now preserves follow-up conversation context * **Documentation** * Added “Ask DeepWiki” badge to README * **Chores** * Added local SpacetimeDB background service and internal-tool app scaffolding <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mantrakp04 <mantrakp@gmail.com> Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { formatDistanceToNow, format } from "date-fns";
|
|
import type { McpCallLogRow } from "../types";
|
|
import { toDate } from "../utils";
|
|
import { clsx } from "clsx";
|
|
|
|
function truncate(str: string, max: number): string {
|
|
return str.length > max ? str.slice(0, max) + "..." : str;
|
|
}
|
|
|
|
type SortField = "time" | "tool" | "steps" | "duration" | "qa" | "status";
|
|
type SortDir = "asc" | "desc";
|
|
type StatusFilter = "all" | "ok" | "error";
|
|
type QaFilter = "all" | "pending" | "pass" | "warn" | "fail" | "error" | "needs-review" | "human-reviewed" | "not-reviewed";
|
|
|
|
function getSortValue(row: McpCallLogRow, field: SortField): number | string {
|
|
switch (field) {
|
|
case "time": { return Number(row.id); }
|
|
case "tool": { return row.toolName; }
|
|
case "steps": { return row.stepCount; }
|
|
case "duration": { return Number(row.durationMs); }
|
|
case "qa": { return row.qaOverallScore ?? -1; }
|
|
case "status": { return row.errorMessage ? 1 : 0; }
|
|
}
|
|
}
|
|
|
|
export function CallLogList({
|
|
rows,
|
|
connectionState,
|
|
onSelect,
|
|
selectedId,
|
|
}: {
|
|
rows: McpCallLogRow[];
|
|
connectionState: string;
|
|
onSelect: (row: McpCallLogRow) => void;
|
|
selectedId?: bigint;
|
|
}) {
|
|
const [textFilter, setTextFilter] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
const [qaFilter, setQaFilter] = useState<QaFilter>("all");
|
|
const [sortField, setSortField] = useState<SortField>("time");
|
|
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
|
|
|
const handleSort = (field: SortField) => {
|
|
if (sortField === field) {
|
|
setSortDir(prev => prev === "asc" ? "desc" : "asc");
|
|
} else {
|
|
setSortField(field);
|
|
setSortDir("desc");
|
|
}
|
|
};
|
|
|
|
const toolNames = useMemo(() => {
|
|
const names = new Set(rows.map(r => r.toolName));
|
|
return Array.from(names).sort();
|
|
}, [rows]);
|
|
|
|
const [toolFilter, setToolFilter] = useState<string>("all");
|
|
|
|
const filteredAndSorted = useMemo(() => {
|
|
let result = rows;
|
|
|
|
// Text filter
|
|
if (textFilter) {
|
|
const lower = textFilter.toLowerCase();
|
|
result = result.filter(
|
|
r =>
|
|
r.question.toLowerCase().includes(lower) ||
|
|
r.reason.toLowerCase().includes(lower) ||
|
|
r.response.toLowerCase().includes(lower)
|
|
);
|
|
}
|
|
|
|
// Tool filter
|
|
if (toolFilter !== "all") {
|
|
result = result.filter(r => r.toolName === toolFilter);
|
|
}
|
|
|
|
// Status filter
|
|
if (statusFilter === "ok") {
|
|
result = result.filter(r => !r.errorMessage);
|
|
} else if (statusFilter === "error") {
|
|
result = result.filter(r => !!r.errorMessage);
|
|
}
|
|
|
|
// QA filter
|
|
if (qaFilter === "pending") {
|
|
result = result.filter(r => r.qaOverallScore == null && !r.qaErrorMessage);
|
|
} else if (qaFilter === "pass") {
|
|
result = result.filter(r => r.qaOverallScore != null && r.qaOverallScore >= 80);
|
|
} else if (qaFilter === "warn") {
|
|
result = result.filter(r => r.qaOverallScore != null && r.qaOverallScore >= 50 && r.qaOverallScore < 80);
|
|
} else if (qaFilter === "fail") {
|
|
result = result.filter(r => r.qaOverallScore != null && r.qaOverallScore < 50);
|
|
} else if (qaFilter === "error") {
|
|
result = result.filter(r => !!r.qaErrorMessage);
|
|
} else if (qaFilter === "needs-review") {
|
|
result = result.filter(r => r.qaNeedsHumanReview);
|
|
} else if (qaFilter === "human-reviewed") {
|
|
result = result.filter(r => r.humanReviewedAt != null);
|
|
} else if (qaFilter === "not-reviewed") {
|
|
result = result.filter(r => r.humanReviewedAt == null && r.qaNeedsHumanReview);
|
|
}
|
|
|
|
// Sort
|
|
result = [...result].sort((a, b) => {
|
|
const aVal = getSortValue(a, sortField);
|
|
const bVal = getSortValue(b, sortField);
|
|
const cmp = typeof aVal === "string"
|
|
? (aVal < (bVal as string) ? -1 : aVal > (bVal as string) ? 1 : 0)
|
|
: (aVal as number) - (bVal as number);
|
|
return sortDir === "asc" ? cmp : -cmp;
|
|
});
|
|
|
|
return result;
|
|
}, [rows, textFilter, toolFilter, statusFilter, qaFilter, sortField, sortDir]);
|
|
|
|
if (connectionState === "connecting") {
|
|
return <div className="text-gray-500 text-sm p-4">Connecting to SpacetimeDB...</div>;
|
|
}
|
|
|
|
if (connectionState === "error") {
|
|
return (
|
|
<div className="text-red-600 text-sm p-4">
|
|
Failed to connect to SpacetimeDB. Check that <code>NEXT_PUBLIC_SPACETIMEDB_HOST</code> and{" "}
|
|
<code>NEXT_PUBLIC_SPACETIMEDB_DB_NAME</code> are set correctly.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const SortHeader = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
|
|
<th
|
|
className="px-4 py-2 cursor-pointer hover:text-gray-700 select-none"
|
|
onClick={() => handleSort(field)}
|
|
>
|
|
<span className="flex items-center gap-1">
|
|
{children}
|
|
{sortField === field && (
|
|
<span className="text-[10px]">{sortDir === "asc" ? "▲" : "▼"}</span>
|
|
)}
|
|
</span>
|
|
</th>
|
|
);
|
|
|
|
const hasActiveFilters = textFilter || toolFilter !== "all" || statusFilter !== "all" || qaFilter !== "all";
|
|
|
|
return (
|
|
<div>
|
|
{/* Filters */}
|
|
<div className="mb-4 space-y-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Search question, reason, or response..."
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={textFilter}
|
|
onChange={(e) => setTextFilter(e.target.value)}
|
|
/>
|
|
<div className="flex gap-2 flex-wrap items-center">
|
|
{toolNames.length > 1 && (
|
|
<select
|
|
className="px-2 py-1 border border-gray-300 rounded text-xs bg-white"
|
|
value={toolFilter}
|
|
onChange={(e) => setToolFilter(e.target.value)}
|
|
>
|
|
<option value="all">All tools</option>
|
|
{toolNames.map(name => (
|
|
<option key={name} value={name}>{name}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<select
|
|
className="px-2 py-1 border border-gray-300 rounded text-xs bg-white"
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
|
>
|
|
<option value="all">All status</option>
|
|
<option value="ok">OK</option>
|
|
<option value="error">Error</option>
|
|
</select>
|
|
<select
|
|
className="px-2 py-1 border border-gray-300 rounded text-xs bg-white"
|
|
value={qaFilter}
|
|
onChange={(e) => setQaFilter(e.target.value as QaFilter)}
|
|
>
|
|
<option value="all">All QA</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="pass">Pass (80+)</option>
|
|
<option value="warn">Warning (50-79)</option>
|
|
<option value="fail">Fail (<50)</option>
|
|
<option value="error">QA Error</option>
|
|
<option value="needs-review">Needs Review</option>
|
|
<option value="human-reviewed">Human Reviewed</option>
|
|
<option value="not-reviewed">Not Yet Reviewed</option>
|
|
</select>
|
|
{hasActiveFilters && (
|
|
<button
|
|
className="px-2 py-1 text-xs text-gray-500 hover:text-gray-700"
|
|
onClick={() => {
|
|
setTextFilter("");
|
|
setToolFilter("all");
|
|
setStatusFilter("all");
|
|
setQaFilter("all");
|
|
}}
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
<span className="text-xs text-gray-400 ml-auto">
|
|
{filteredAndSorted.length} of {rows.length} calls
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{filteredAndSorted.length === 0 ? (
|
|
<div className="text-center text-gray-400 py-12">
|
|
{hasActiveFilters ? (
|
|
<p className="text-sm">No calls match the current filters</p>
|
|
) : (
|
|
<>
|
|
<p className="text-lg mb-2">No MCP calls logged yet</p>
|
|
<p className="text-sm">
|
|
Make sure <code className="bg-gray-100 px-1 rounded">STACK_MCP_LOG_TOKEN</code> is set
|
|
in the backend and the SpacetimeDB module is published.
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-50 text-left text-gray-500 text-xs uppercase tracking-wider">
|
|
<SortHeader field="time">Time</SortHeader>
|
|
<SortHeader field="tool">Tool</SortHeader>
|
|
<th className="px-4 py-2">Reason</th>
|
|
<th className="px-4 py-2">Question</th>
|
|
<SortHeader field="steps">Steps</SortHeader>
|
|
<SortHeader field="duration">Duration</SortHeader>
|
|
<SortHeader field="qa">QA</SortHeader>
|
|
<SortHeader field="status">Status</SortHeader>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredAndSorted.map((row) => (
|
|
<tr
|
|
key={String(row.id)}
|
|
onClick={() => onSelect(row)}
|
|
className={clsx(
|
|
"cursor-pointer border-t border-gray-100 hover:bg-blue-50 transition-colors",
|
|
selectedId === row.id && "bg-blue-50"
|
|
)}
|
|
>
|
|
<td className="px-4 py-2 whitespace-nowrap text-gray-500" title={format(toDate(row.createdAt), "PPpp")}>
|
|
{formatDistanceToNow(toDate(row.createdAt), { addSuffix: true })}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
|
{row.toolName}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-600 max-w-[200px]" title={row.reason}>
|
|
{truncate(row.reason, 60)}
|
|
</td>
|
|
<td className="px-4 py-2 text-gray-900 max-w-[300px]" title={row.question}>
|
|
{truncate(row.question, 80)}
|
|
</td>
|
|
<td className="px-4 py-2 text-center text-gray-500">{row.stepCount}</td>
|
|
<td className="px-4 py-2 whitespace-nowrap text-gray-500">
|
|
{Number(row.durationMs).toLocaleString()}ms
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<span className="flex items-center gap-1">
|
|
{row.qaErrorMessage ? (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
|
err
|
|
</span>
|
|
) : row.qaOverallScore != null ? (
|
|
<span className={clsx(
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
row.qaOverallScore >= 80 && "bg-green-100 text-green-800",
|
|
row.qaOverallScore >= 50 && row.qaOverallScore < 80 && "bg-yellow-100 text-yellow-800",
|
|
row.qaOverallScore < 50 && "bg-red-100 text-red-800"
|
|
)}>
|
|
{row.qaOverallScore}
|
|
{row.qaNeedsHumanReview && !row.humanReviewedAt && " !"}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-gray-300">--</span>
|
|
)}
|
|
{row.humanReviewedAt && (
|
|
<span className="text-green-600 text-xs" title={`Reviewed by ${row.humanReviewedBy}`}>✓</span>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{row.errorMessage ? (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
|
error
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
|
ok
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|