Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,18 @@ jobs:

web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: apps/web/package-lock.json
- run: npm ci --workspaces=false
- run: npm run lint
- run: npm run test
- run: npm run build
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm --prefix apps/web install --no-save --package-lock=false --ignore-scripts @tailwindcss/oxide-linux-x64-gnu@4.2.2 lightningcss-linux-x64-gnu@1.32.0 react@19.2.4 react-dom@19.2.4
- run: npm --prefix apps/web run lint
- run: npm --prefix apps/web run test
- run: npm --prefix apps/web run build

api-go:
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go)](https://go.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178c6?logo=typescript)](https://www.typescriptlang.org)

**Live Demo** · [diffaudit.vectorcontrol.tech](https://diffaudit.vectorcontrol.tech)
**Demo** · Run locally or deploy your own instance

[English](#english) · [简体中文](#简体中文)

Expand Down Expand Up @@ -151,7 +151,7 @@ DiffAudit Platform 围绕三个问题提供答案:

### 快速体验

在线 Demo:[diffaudit.vectorcontrol.tech](https://diffaudit.vectorcontrol.tech)
在线 Demo:请部署自己的实例,或按下方命令在本地启动。

本地启动:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ function trackTone(job: JobRecord): Tone {
return "muted";
}

function trackIcon(tone: Tone) {
if (tone === "green") return Shield;
if (tone === "purple") return Eye;
if (tone === "blue") return Activity;
if (tone === "orange") return Search;
return Activity;
function TrackIcon({ tone, size }: { tone: Tone; size: number }) {
const props = { size, strokeWidth: 1.7, "aria-hidden": true as const };
if (tone === "green") return <Shield {...props} />;
if (tone === "purple") return <Eye {...props} />;
if (tone === "blue") return <Activity {...props} />;
if (tone === "orange") return <Search {...props} />;
return <Activity {...props} />;
}

function statusToneClass(status: string): string {
Expand Down Expand Up @@ -75,7 +76,6 @@ function formatRemaining(progressPct: number, createdAt: string, locale: Locale,
export function RunningCard({ job, locale }: { job: JobRecord; locale: Locale }) {
const copy = WORKSPACE_COPY[locale].audits;
const tone = trackTone(job);
const Icon = trackIcon(tone);
const pct = typeof job.progress_pct === "number" ? Math.round(job.progress_pct) : 0;
const [now, setNow] = useState(() => Date.now());

Expand All @@ -93,7 +93,7 @@ export function RunningCard({ job, locale }: { job: JobRecord; locale: Locale })
className="audits-running-card"
>
<span className={`audits-running-icon is-${tone}`}>
<Icon size={16} strokeWidth={1.7} aria-hidden="true" />
<TrackIcon tone={tone} size={16} />
</span>
<div className="audits-running-info">
<div className="audits-running-title">
Expand Down Expand Up @@ -201,14 +201,13 @@ export function HistoryTable({ jobs, locale, loading, loadError, onRefresh }: Hi
<tbody>
{jobs.map((job) => {
const tone = trackTone(job);
const Icon = trackIcon(tone);
const reportHref = buildCompletedJobReportHref(job);
return (
<tr key={job.job_id}>
<td>
<div className="audits-cell-task">
<span className={`audits-running-icon is-${tone} is-sm`}>
<Icon size={13} strokeWidth={1.7} aria-hidden="true" />
<TrackIcon tone={tone} size={13} />
</span>
<div>
<strong>{job.job_id}</strong>
Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/app/(workspace)/workspace/audits/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ export const dynamic = "force-dynamic";
import { resolveLocaleFromHeaderStore } from "@/lib/locale";
import { WORKSPACE_COPY } from "@/lib/workspace-copy";
import { WorkspacePageFrame } from "@/components/workspace-frame";
import { sanitizeAuditJobPayload } from "@/lib/audit-job-payload";
import { isDemoModeEnabledServer } from "@/lib/demo-mode";
import { listDemoJobs } from "@/lib/demo-jobs-store";
import { getWorkspaceAuditJobsData } from "@/lib/workspace-source";
import { AuditsPageClient } from "./AuditsPageClient";

type WorkspaceAuditsPageOptions = {
Expand All @@ -20,9 +18,7 @@ type WorkspaceAuditsPageOptions = {
async function renderWorkspaceAuditsPage({ locale }: WorkspaceAuditsPageOptions = {}) {
const resolvedLocale = locale ?? resolveLocaleFromHeaderStore(await headers());
const copy = WORKSPACE_COPY[resolvedLocale].audits;
const initialJobs = await isDemoModeEnabledServer()
? sanitizeAuditJobPayload(listDemoJobs())
: [];
const initialJobs = await getWorkspaceAuditJobsData();

return (
<WorkspacePageFrame
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/app/(workspace)/workspace/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,24 @@ describe("WorkspaceHomePage", () => {
const { default: WorkspaceHomePage } = await import("./start/page");
const markup = await renderMarkup(await WorkspaceHomePage());

expect(markup).toContain("建议操作");
expect(markup).toContain("最近结果");
expect(markup).toContain("工作台总览");
expect(markup).toContain("AUC 风险分布");
expect(markup).toContain("近期任务");
expect(markup).toContain("PIA");
expect(markup).toContain("stable-diffusion-v1-4");
expect(markup).toContain("可审计模型");
expect(markup).toContain("已防御结果");
expect(markup).toContain("可审计合同");
expect(markup).toContain("已评估防御");
});

it("renders en-US copy with forced demo data", async () => {
headersMock.mockResolvedValue(new Headers([["x-platform-locale", "en-US"]]));
const { default: WorkspaceHomePage } = await import("./start/page");
const markup = await renderMarkup(await WorkspaceHomePage());

expect(markup).toContain("Suggested actions");
expect(markup).toContain("Recent results");
expect(markup).toContain("Workspace Overview");
expect(markup).toContain("AUC Risk Distribution");
expect(markup).toContain("Recent tasks");
expect(markup).toContain("stable-diffusion-v1-4");
expect(markup).toContain("Auditable models");
expect(markup).toContain("Auditable contracts");
});
});
21 changes: 10 additions & 11 deletions apps/web/src/app/(workspace)/workspace/reports/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,22 @@ describe("WorkspaceReportsPage", () => {
const { default: WorkspaceReportsPage } = await import("./page");
const markup = await renderMarkup(await WorkspaceReportsPage());

expect(markup).toContain("报告生成");
expect(markup).toContain("按审计模式生成");
expect(markup).toContain("已生成报告");
expect(markup).toContain("综合分析");
expect(markup).toContain("导出选项");
expect(markup).toContain("审计结果和覆盖缺口");
expect(markup).toContain("任务报告");
expect(markup).toContain("任务报告表");
expect(markup).toContain("photo-real-xl");
expect(markup).toContain("查看审计报告");
});

it("renders en-US copy with forced demo data", async () => {
headersMock.mockResolvedValue(new Headers([["x-platform-locale", "en-US"]]));
const { default: WorkspaceReportsPage } = await import("./page");
const markup = await renderMarkup(await WorkspaceReportsPage());

expect(markup).toContain("Reports");
expect(markup).toContain("Report Generation");
expect(markup).toContain("Generate by Audit Mode");
expect(markup).toContain("Generated Reports");
expect(markup).toContain("Comprehensive Analysis");
expect(markup).toContain("Export Options");
expect(markup).toContain("Audit results and coverage gaps");
expect(markup).toContain("Task reports");
expect(markup).toContain("Task reports table");
expect(markup).toContain("photo-real-xl");
expect(markup).toContain("View Report");
});
});
8 changes: 2 additions & 6 deletions apps/web/src/app/(workspace)/workspace/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { headers } from "next/headers";
import { resolveLocaleFromHeaderStore } from "@/lib/locale";
import { WORKSPACE_COPY } from "@/lib/workspace-copy";
import { WorkspacePageFrame } from "@/components/workspace-frame";
import { sanitizeAuditJobPayload } from "@/lib/audit-job-payload";
import { isDemoModeEnabledServer } from "@/lib/demo-mode";
import { listDemoJobs } from "@/lib/demo-jobs-store";
import { getWorkspaceAuditJobsData } from "@/lib/workspace-source";
import { ReportsPageClient } from "./ReportsPageClient";

export const dynamic = "force-dynamic";
Expand All @@ -17,9 +15,7 @@ type WorkspaceReportsPageOptions = {
async function renderWorkspaceReportsPage({ locale }: WorkspaceReportsPageOptions = {}) {
const resolvedLocale = locale ?? resolveLocaleFromHeaderStore(await headers());
const copy = WORKSPACE_COPY[resolvedLocale].reports;
const initialJobs = await isDemoModeEnabledServer()
? sanitizeAuditJobPayload(listDemoJobs())
: [];
const initialJobs = await getWorkspaceAuditJobsData();

return (
<WorkspacePageFrame title={copy.title} titleClassName="text-xl">
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/app/api/auth/me/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

const getCurrentUserProfile = vi.fn();
const isDemoModeEnabledServer = vi.fn();

vi.mock("@/lib/auth", () => ({
getCurrentUserProfile,
SESSION_COOKIE_NAME: "diffaudit_session",
}));

vi.mock("@/lib/demo-mode", () => ({
isDemoModeEnabledServer,
}));

describe("auth me route", () => {
beforeEach(() => {
vi.resetModules();
getCurrentUserProfile.mockReset();
isDemoModeEnabledServer.mockReset();
});

it("returns anonymous profile without a 401 response when demo mode is enabled", async () => {
isDemoModeEnabledServer.mockResolvedValue(true);
const route = await import("./route");

const response = await route.GET(new NextRequest("http://localhost/api/auth/me"));
const payload = await response.json();

expect(response.status).toBe(200);
expect(payload).toEqual({ user: null });
expect(getCurrentUserProfile).not.toHaveBeenCalled();
});

it("keeps the unauthenticated 401 response outside demo mode", async () => {
isDemoModeEnabledServer.mockResolvedValue(false);
const route = await import("./route");

const response = await route.GET(new NextRequest("http://localhost/api/auth/me"));
const payload = await response.json();

expect(response.status).toBe(401);
expect(payload).toEqual({ user: null });
});

it("returns the current user when a valid session exists", async () => {
const user = { id: "user-1", username: "demo-reviewer" };
getCurrentUserProfile.mockReturnValue(user);
const route = await import("./route");

const response = await route.GET(
new NextRequest("http://localhost/api/auth/me", {
headers: { cookie: "diffaudit_session=session-token" },
}),
);
const payload = await response.json();

expect(response.status).toBe(200);
expect(payload).toEqual({ user });
expect(getCurrentUserProfile).toHaveBeenCalledWith("session-token");
});
});
13 changes: 11 additions & 2 deletions apps/web/src/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { type NextRequest } from "next/server";

import { getCurrentUserProfile, SESSION_COOKIE_NAME } from "@/lib/auth";
import { isDemoModeEnabledServer } from "@/lib/demo-mode";

async function anonymousResponse(request: NextRequest) {
if (await isDemoModeEnabledServer(request)) {
return Response.json({ user: null });
}

return Response.json({ user: null }, { status: 401 });
}

export async function GET(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
if (!token) {
return Response.json({ user: null }, { status: 401 });
return anonymousResponse(request);
}

const user = getCurrentUserProfile(token);
if (!user) {
return Response.json({ user: null }, { status: 401 });
return anonymousResponse(request);
}

return Response.json({ user });
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/app/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const isDemoModeEnabledServer = vi.fn();
const proxyToBackend = vi.fn();

vi.mock("@/lib/demo-mode", () => ({
isDemoModeEnabledServer,
}));

vi.mock("@/lib/api-proxy", () => ({
proxyToBackend,
}));

describe("health route", () => {
beforeEach(() => {
vi.resetModules();
isDemoModeEnabledServer.mockReset();
proxyToBackend.mockReset();
proxyToBackend.mockResolvedValue(Response.json({ upstream: true }));
});

it("returns demo health without proxying when demo mode is enabled", async () => {
isDemoModeEnabledServer.mockResolvedValue(true);
const route = await import("./route");

const response = await route.GET(new Request("http://localhost/health"));
const payload = await response.json();

expect(response.status).toBe(200);
expect(payload).toMatchObject({
demo_mode: true,
snapshot_available: true,
build: { revision: "demo-snapshot" },
});
expect(proxyToBackend).not.toHaveBeenCalled();
});

it("proxies backend health outside demo mode", async () => {
isDemoModeEnabledServer.mockResolvedValue(false);
const route = await import("./route");

await route.GET(new Request("http://localhost/health"));

expect(proxyToBackend).toHaveBeenCalledWith("/health");
});
});
13 changes: 12 additions & 1 deletion apps/web/src/app/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { proxyToBackend } from "@/lib/api-proxy";
import { isDemoModeEnabledServer } from "@/lib/demo-mode";

export async function GET(request: Request) {
if (await isDemoModeEnabledServer(request)) {
return Response.json({
demo_mode: true,
snapshot_available: true,
build: {
revision: "demo-snapshot",
},
});
}

export async function GET() {
return proxyToBackend("/health");
}
13 changes: 8 additions & 5 deletions apps/web/src/components/chart-risk-donut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,20 @@ export function ChartRiskDonut({ data, totalLabel = "总结果", height = 160 }:
const router = useRouter();
const total = data.reduce((acc, item) => acc + item.count, 0);
const chartSize = Math.max(106, Math.min(124, height - 46));
let cursor = 0;
const segments = data.map((item, index) => {
const start = data
.slice(0, index)
.reduce((acc, previous) => acc + (total > 0 ? (previous.count / total) * 360 : 0), 0);
const sweep = total > 0 ? (item.count / total) * 360 : 0;
return { item, start, sweep };
});

return (
<div className="risk-donut-wrap" style={{ height }}>
<div className="risk-donut-canvas" style={{ height: chartSize, width: chartSize }}>
<svg viewBox="0 0 160 160" width={chartSize} height={chartSize} role="img" aria-label="Risk distribution">
<circle cx="80" cy="80" r="58" fill="none" stroke="var(--muted)" strokeWidth="18" opacity="0.55" />
{data.map((item) => {
const start = cursor;
const sweep = total > 0 ? (item.count / total) * 360 : 0;
cursor += sweep;
{segments.map(({ item, start, sweep }) => {
return (
<path
key={item.key}
Expand Down
Loading
Loading