Skip to content

Commit fb0bbd0

Browse files
Edwin ChanEdwin Chan
authored andcommitted
implemented
1 parent ee6d7f0 commit fb0bbd0

53 files changed

Lines changed: 2501 additions & 799 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,70 @@ on:
99
jobs:
1010
verify:
1111
runs-on: ubuntu-latest
12+
services:
13+
postgres:
14+
image: postgres:16
15+
env:
16+
POSTGRES_DB: dbsmo
17+
POSTGRES_PASSWORD: postgres
18+
POSTGRES_USER: postgres
19+
ports:
20+
- 5432:5432
21+
options: >-
22+
--health-cmd pg_isready
23+
--health-interval 10s
24+
--health-timeout 5s
25+
--health-retries 5
26+
env:
27+
AUTH_DEV_BYPASS: "true"
28+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/dbsmo?schema=public
29+
GOOGLE_CLIENT_ID: ""
30+
GOOGLE_CLIENT_SECRET: ""
31+
LOCAL_STORAGE_ROOT: ./storage
32+
NEXTAUTH_SECRET: ci-only-nextauth-secret
33+
NEXTAUTH_URL: http://localhost:3001
34+
SCHOOL_EMAIL_DOMAINS: g.dbs.edu.hk
35+
STORAGE_DRIVER: local
1236
steps:
1337
- uses: actions/checkout@v4
1438
- uses: actions/setup-node@v4
1539
with:
1640
node-version: 22
1741
cache: npm
42+
- uses: astral-sh/setup-uv@v5
1843
- run: npm ci
44+
- run: npx prisma migrate deploy
45+
- run: npm run db:seed
1946
- run: npm run lint
2047
- run: npm run typecheck
2148
- run: npm run test
49+
- run: npm run build
50+
- run: python3 -m py_compile tests/browser_harness_smoke.py
51+
- name: Browser harness E2E
52+
run: |
53+
git clone https://github.com/browser-use/browser-harness /tmp/browser-harness
54+
uv tool install -e /tmp/browser-harness
55+
export PATH="$HOME/.local/bin:$PATH"
56+
57+
npm run dev -- --port 3001 > /tmp/dbsmo-next.log 2>&1 &
58+
SERVER_PID=$!
59+
60+
CHROME_BIN="$(command -v google-chrome || command -v chromium-browser || command -v chromium)"
61+
"$CHROME_BIN" --headless=new --no-sandbox --disable-gpu --remote-debugging-port=9222 --user-data-dir=/tmp/dbsmo-chrome about:blank > /tmp/dbsmo-chrome.log 2>&1 &
62+
CHROME_PID=$!
63+
64+
cleanup() {
65+
kill "$SERVER_PID" "$CHROME_PID" 2>/dev/null || true
66+
}
67+
trap cleanup EXIT
68+
69+
for _ in {1..90}; do
70+
if curl -fsS http://localhost:3001 >/dev/null 2>&1; then
71+
break
72+
fi
73+
sleep 1
74+
done
75+
curl -fsS http://localhost:3001 >/dev/null
76+
77+
BU_NAME=dbsmo-ci BU_CDP_URL=http://127.0.0.1:9222 BASE_URL=http://localhost:3001 browser-harness -c 'exec(open("tests/browser_harness_smoke.py").read())'
78+
fi

app/admin/analytics/page.tsx

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import Link from "next/link";
22
import { ArrowLeft, Download, Flame } from "lucide-react";
33
import { Prisma } from "@prisma/client";
4+
import { getServerSession } from "next-auth/next";
5+
import { redirect } from "next/navigation";
46
import { prisma } from "@/lib/db";
7+
import { authOptions } from "@/lib/auth";
58
import { computeTopicAccuracy, computeQuestionStats, accuracyLevel } from "@/lib/analytics";
69
import { normalizeTagList } from "@/lib/problem-tags";
10+
import { hasPermission } from "@/lib/permissions";
711

812
export const dynamic = "force-dynamic";
913

1014
type AnalyticsSearchParams = Promise<{
1115
from?: string;
16+
group?: string;
1217
set?: string;
1318
student?: string;
1419
to?: string;
@@ -30,6 +35,10 @@ export default async function AnalyticsOverviewPage({
3035
}: {
3136
searchParams?: AnalyticsSearchParams;
3237
}) {
38+
const session = await getServerSession(authOptions);
39+
if (!session?.user?.id) redirect("/");
40+
if (!hasPermission(session.user.role, "admin:analytics")) redirect("/dashboard");
41+
3342
const params = (await searchParams) ?? {};
3443
const fromDate = parseDateParam(params.from);
3544
const toDate = parseDateParam(params.to, true);
@@ -38,15 +47,21 @@ export default async function AnalyticsOverviewPage({
3847
prisma.problemSet.findMany({ select: { id: true, title: true, slug: true } }),
3948
prisma.user.findMany({
4049
where: { role: "STUDENT" },
41-
select: { id: true, name: true, email: true, displayName: true },
50+
select: { id: true, name: true, email: true, displayName: true, group: true },
4251
orderBy: { name: "asc" },
4352
}),
4453
prisma.problem.findMany({ select: { topicTags: true } }),
4554
]);
4655

4756
const selectedSet = problemSets.find((set) => set.slug === params.set) ?? null;
4857
const selectedTopic = params.topic?.trim() || "";
58+
const selectedGroup = params.group?.trim() || "";
4959
const selectedStudent = students.find((student) => student.id === params.student) ?? null;
60+
const groupOptions = Array.from(
61+
new Set(
62+
students.map((student) => student.group).filter((group): group is string => Boolean(group)),
63+
),
64+
).sort((a, b) => a.localeCompare(b));
5065
const topicOptions = normalizeTagList(allProblems.flatMap((problem) => problem.topicTags)).sort(
5166
(a, b) => a.localeCompare(b),
5267
);
@@ -58,10 +73,11 @@ export default async function AnalyticsOverviewPage({
5873

5974
const responseWhere: Prisma.ResponseWhereInput = {
6075
...(Object.keys(problemWhere).length > 0 ? { problem: problemWhere } : {}),
61-
...(selectedStudent || fromDate || toDate
76+
...(selectedStudent || selectedGroup || fromDate || toDate
6277
? {
6378
attempt: {
6479
...(selectedStudent ? { userId: selectedStudent.id } : {}),
80+
...(selectedGroup ? { user: { group: selectedGroup } } : {}),
6581
...(fromDate || toDate
6682
? {
6783
submittedAt: {
@@ -78,6 +94,7 @@ export default async function AnalyticsOverviewPage({
7894
const attemptWhere: Prisma.AttemptWhereInput = {
7995
...(selectedSet ? { problemSetId: selectedSet.id } : {}),
8096
...(selectedStudent ? { userId: selectedStudent.id } : {}),
97+
...(selectedGroup ? { user: { group: selectedGroup } } : {}),
8198
...(fromDate || toDate
8299
? {
83100
submittedAt: {
@@ -186,6 +203,31 @@ export default async function AnalyticsOverviewPage({
186203
})
187204
.filter((question) => question.reasons.length > 0)
188205
.slice(0, 6);
206+
const trendBuckets = new Map<string, { attempts: number; completions: number }>();
207+
const trendNow = new Date();
208+
for (let offset = 5; offset >= 0; offset--) {
209+
const start = new Date(trendNow);
210+
start.setDate(start.getDate() - offset * 7);
211+
const key = `${start.getMonth() + 1}/${start.getDate()}`;
212+
trendBuckets.set(key, { attempts: 0, completions: 0 });
213+
}
214+
for (const attempt of attempts) {
215+
const ageDays = Math.floor((trendNow.getTime() - attempt.submittedAt.getTime()) / 86_400_000);
216+
if (ageDays < 0 || ageDays > 41) continue;
217+
const bucketStart = new Date(trendNow);
218+
bucketStart.setDate(bucketStart.getDate() - Math.floor(ageDays / 7) * 7);
219+
const key = `${bucketStart.getMonth() + 1}/${bucketStart.getDate()}`;
220+
const bucket = trendBuckets.get(key) ?? { attempts: 0, completions: 0 };
221+
bucket.attempts += 1;
222+
if (attempt.maxScore > 0 && attempt.score / attempt.maxScore >= 0.8) {
223+
bucket.completions += 1;
224+
}
225+
trendBuckets.set(key, bucket);
226+
}
227+
const trendRows = Array.from(trendBuckets.entries()).map(([label, bucket]) => ({
228+
label,
229+
...bucket,
230+
}));
189231

190232
return (
191233
<main className="single-page">
@@ -231,6 +273,14 @@ export default async function AnalyticsOverviewPage({
231273
</option>
232274
))}
233275
</select>
276+
<select aria-label="Filter by cohort" name="group" defaultValue={selectedGroup}>
277+
<option value="">All cohorts</option>
278+
{groupOptions.map((group) => (
279+
<option key={group} value={group}>
280+
{group}
281+
</option>
282+
))}
283+
</select>
234284
<select aria-label="Filter by topic" name="topic" defaultValue={selectedTopic}>
235285
<option value="">All topics</option>
236286
{topicOptions.map((topic) => (
@@ -244,7 +294,12 @@ export default async function AnalyticsOverviewPage({
244294
<button className="secondary-action compact" type="submit">
245295
Filter
246296
</button>
247-
{selectedSet || selectedStudent || selectedTopic || params.from || params.to ? (
297+
{selectedSet ||
298+
selectedStudent ||
299+
selectedGroup ||
300+
selectedTopic ||
301+
params.from ||
302+
params.to ? (
248303
<Link className="text-link" href="/admin/analytics">
249304
Clear
250305
</Link>
@@ -281,6 +336,28 @@ export default async function AnalyticsOverviewPage({
281336
<strong>{recentAttemptCount}</strong>
282337
</article>
283338
</section>
339+
340+
<section className="panel table-panel">
341+
<div className="panel-header">
342+
<div>
343+
<p className="eyebrow">Completion trends</p>
344+
<h2>Last 6 weeks</h2>
345+
</div>
346+
</div>
347+
<div className="trend-strip">
348+
{trendRows.map((row) => (
349+
<div className="trend-bar" key={row.label}>
350+
<span
351+
style={{
352+
height: `${Math.max(8, Math.min(100, row.attempts * 14))}%`,
353+
}}
354+
/>
355+
<strong>{row.completions}</strong>
356+
<small>{row.label}</small>
357+
</div>
358+
))}
359+
</div>
360+
</section>
284361
<section className="heatmap-section">
285362
<div className="panel-header">
286363
<div>

app/admin/audit/page.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import Link from "next/link";
2+
import { getServerSession } from "next-auth/next";
3+
import { redirect } from "next/navigation";
4+
import { ArrowLeft, ShieldCheck } from "lucide-react";
5+
import { prisma } from "@/lib/db";
6+
import { authOptions } from "@/lib/auth";
7+
import { hasPermission } from "@/lib/permissions";
8+
9+
export const dynamic = "force-dynamic";
10+
11+
export default async function AdminAuditPage() {
12+
const session = await getServerSession(authOptions);
13+
if (!session?.user?.id) redirect("/");
14+
if (!hasPermission(session.user.role, "admin:audit")) redirect("/dashboard");
15+
16+
const logs = await prisma.auditLog.findMany({
17+
orderBy: { createdAt: "desc" },
18+
take: 100,
19+
include: { actor: { select: { email: true, name: true, displayName: true } } },
20+
});
21+
22+
return (
23+
<main className="single-page">
24+
<div className="page-frame">
25+
<header className="topbar standalone">
26+
<div>
27+
<p className="eyebrow">Admin</p>
28+
<h1>
29+
<ShieldCheck size={22} />
30+
Audit log
31+
</h1>
32+
</div>
33+
<Link className="secondary-action" href="/dashboard">
34+
<ArrowLeft size={18} />
35+
Dashboard
36+
</Link>
37+
</header>
38+
39+
<section className="panel table-panel">
40+
<div className="panel-header">
41+
<div>
42+
<p className="eyebrow">Latest events</p>
43+
<h2>{logs.length} recorded actions</h2>
44+
</div>
45+
</div>
46+
<div className="table-wrap">
47+
<table>
48+
<thead>
49+
<tr>
50+
<th>Time</th>
51+
<th>Actor</th>
52+
<th>Action</th>
53+
<th>Target</th>
54+
<th>Metadata</th>
55+
</tr>
56+
</thead>
57+
<tbody>
58+
{logs.length === 0 ? (
59+
<tr>
60+
<td colSpan={5}>No audit events recorded yet.</td>
61+
</tr>
62+
) : (
63+
logs.map((log) => (
64+
<tr key={log.id}>
65+
<td>{log.createdAt.toLocaleString()}</td>
66+
<td>
67+
{log.actor?.displayName || log.actor?.name || log.actor?.email || "System"}
68+
</td>
69+
<td>
70+
<code className="slug-code">{log.action}</code>
71+
</td>
72+
<td>
73+
{log.targetType ?? "—"}
74+
{log.targetId ? `:${log.targetId.slice(0, 8)}` : ""}
75+
</td>
76+
<td>
77+
<code className="slug-code">
78+
{log.metadata ? JSON.stringify(log.metadata).slice(0, 120) : "—"}
79+
</code>
80+
</td>
81+
</tr>
82+
))
83+
)}
84+
</tbody>
85+
</table>
86+
</div>
87+
</section>
88+
</div>
89+
</main>
90+
);
91+
}

0 commit comments

Comments
 (0)