Skip to content

Commit 300039c

Browse files
mgrange1998facebook-github-bot
authored andcommitted
Add web playground — in-browser privacy analysis via Pyodide (#122)
Summary: ## Problem PrivacyGuard is an OSS privacy auditing library, but users need to install Python + dependencies to try it. There's no interactive way to explore the attacks and analyses without setting up a local environment. ## Solution A React + Vite single-page app that runs PrivacyGuard's actual Python analysis code in the browser via Pyodide (CPython compiled to WebAssembly). No server, no backend — user data never leaves the browser. ## What's Included (Stage 1 MVP) **Web UI** (`privacy_guard/github/web/`): - Landing page with attack category cards - MIA tab (LiRA, RMIA, Calib sub-tabs) with parameter forms and CSV input - LIA tab with target + calibration model inputs - Text Similarity tab (TextInclusion + EditSimilarity, Probabilistic Memorization) - f-DP Calculator tab (instant epsilon computation) - Code Similarity tab (Coming Soon placeholder) - Results display with summary cards and JSON/CSV export **Pyodide Integration**: - Web Worker runs Pyodide in background thread for UI responsiveness - Bridge layer for async React ↔ Worker communication - Python runners module with entry points for each attack/analysis - Module loader copies PrivacyGuard .py files into static assets at build time (no duplication) **Deployment**: - GitHub Actions workflow (`deploy-web.yml`) deploys to GitHub Pages - Triggers on changes to `web/`, `attacks/`, or `analysis/` - Separate from library CI — web build failures don't block library releases **Design document**: `privacy_guard/docs/plans/2026-03-27-web-playground-design.md` ## What's NOT included (future stages) - Stage 2: torch shim for MIA AnalysisNode (epsilon/AUC/CI), Plotly.js charts - Stage 3: Code Similarity via web-tree-sitter Differential Revision: D98518329
1 parent 34cd166 commit 300039c

26 files changed

Lines changed: 2578 additions & 0 deletions

.github/workflows/deploy-web.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Deploy Web Playground
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
paths:
7+
- 'web/**'
8+
- 'attacks/**'
9+
- 'analysis/**'
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
pages: write
15+
id-token: write
16+
17+
concurrency:
18+
group: pages
19+
cancel-in-progress: false
20+
21+
jobs:
22+
build:
23+
name: Build web playground
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
- name: Set up Node.js
28+
uses: actions/setup-node@v4
29+
with:
30+
node-version: '20'
31+
cache: 'npm'
32+
cache-dependency-path: web/package-lock.json
33+
- name: Install dependencies
34+
run: npm ci
35+
working-directory: web
36+
- name: Build
37+
run: npm run build
38+
working-directory: web
39+
- name: Upload pages artifact
40+
uses: actions/upload-pages-artifact@v3
41+
with:
42+
path: web/dist
43+
44+
deploy:
45+
name: Deploy to GitHub Pages
46+
needs: build
47+
runs-on: ubuntu-latest
48+
environment:
49+
name: github-pages
50+
url: ${{ steps.deployment.outputs.page_url }}
51+
steps:
52+
- name: Deploy to GitHub Pages
53+
id: deployment
54+
uses: actions/deploy-pages@v4

web/.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Dependencies
2+
node_modules
3+
4+
# Build output
5+
dist
6+
7+
# Generated at build time by vite.config.ts
8+
public/pg_modules
9+
10+
# Editor directories and files
11+
.vscode/*
12+
!.vscode/extensions.json
13+
*.swp
14+
*.swo
15+
*~
16+
17+
# OS files
18+
.DS_Store

web/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>PrivacyGuard Playground</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

web/package.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "privacyguard-web",
3+
"private": true,
4+
"version": "0.0.1",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc -b && vite build",
9+
"lint": "eslint .",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"plotly.js-dist-min": "^2.35.0",
14+
"react": "^19.0.0",
15+
"react-dom": "^19.0.0",
16+
"react-plotly.js": "^2.6.0",
17+
"react-router-dom": "^7.1.0"
18+
},
19+
"devDependencies": {
20+
"@eslint/js": "^9.17.0",
21+
"@types/react": "^19.0.0",
22+
"@types/react-dom": "^19.0.0",
23+
"@types/react-plotly.js": "^2.6.0",
24+
"@vitejs/plugin-react": "^4.3.0",
25+
"eslint": "^9.17.0",
26+
"eslint-plugin-react-hooks": "^5.0.0",
27+
"eslint-plugin-react-refresh": "^0.4.16",
28+
"globals": "^15.14.0",
29+
"@types/node": "^22.0.0",
30+
"typescript": "~5.7.0",
31+
"typescript-eslint": "^8.18.2",
32+
"vite": "^6.0.0"
33+
}
34+
}

web/src/App.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
2+
import Landing from "./tabs/Landing";
3+
import MIATab from "./tabs/MIATab";
4+
import LIATab from "./tabs/LIATab";
5+
import TextSimilarityTab from "./tabs/TextSimilarityTab";
6+
import CodeSimilarityTab from "./tabs/CodeSimilarityTab";
7+
import FDPCalculatorTab from "./tabs/FDPCalculatorTab";
8+
9+
function App() {
10+
return (
11+
<BrowserRouter basename="/PrivacyGuard">
12+
<nav className="tab-bar">
13+
<NavLink to="/">Home</NavLink>
14+
<NavLink to="/mia">MIA</NavLink>
15+
<NavLink to="/lia">LIA</NavLink>
16+
<NavLink to="/text-similarity">Text Similarity</NavLink>
17+
<NavLink to="/code-similarity">Code Similarity</NavLink>
18+
<NavLink to="/fdp">f-DP Calculator</NavLink>
19+
</nav>
20+
<main>
21+
<Routes>
22+
<Route path="/" element={<Landing />} />
23+
<Route path="/mia" element={<MIATab />} />
24+
<Route path="/lia" element={<LIATab />} />
25+
<Route path="/text-similarity" element={<TextSimilarityTab />} />
26+
<Route path="/code-similarity" element={<CodeSimilarityTab />} />
27+
<Route path="/fdp" element={<FDPCalculatorTab />} />
28+
</Routes>
29+
</main>
30+
</BrowserRouter>
31+
);
32+
}
33+
34+
export default App;

web/src/components/DataInput.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { useState, useRef } from "react";
2+
3+
interface DataInputProps {
4+
format: "csv" | "jsonl";
5+
requiredColumns: string[];
6+
placeholder: string;
7+
onData: (data: string) => void;
8+
}
9+
10+
function DataInput({ format, requiredColumns, placeholder, onData }: DataInputProps) {
11+
const [text, setText] = useState("");
12+
const [validationError, setValidationError] = useState<string | null>(null);
13+
const fileInputRef = useRef<HTMLInputElement>(null);
14+
15+
const validate = (data: string): string | null => {
16+
const trimmed = data.trim();
17+
if (!trimmed) {
18+
return "No data provided.";
19+
}
20+
21+
if (format === "csv") {
22+
const firstLine = trimmed.split("\n")[0];
23+
const headers = firstLine.split(",").map((h) => h.trim());
24+
const missing = requiredColumns.filter((col) => !headers.includes(col));
25+
if (missing.length > 0) {
26+
return `CSV is missing required column(s): ${missing.join(", ")}. Found headers: ${headers.join(", ")}`;
27+
}
28+
} else {
29+
// jsonl — validate the first line
30+
const firstLine = trimmed.split("\n")[0];
31+
try {
32+
const obj = JSON.parse(firstLine);
33+
const keys = Object.keys(obj);
34+
const missing = requiredColumns.filter((col) => !keys.includes(col));
35+
if (missing.length > 0) {
36+
return `JSONL first line is missing required field(s): ${missing.join(", ")}. Found fields: ${keys.join(", ")}`;
37+
}
38+
} catch {
39+
return "First line is not valid JSON. Expected JSONL format (one JSON object per line).";
40+
}
41+
}
42+
43+
return null;
44+
};
45+
46+
const handleLoad = () => {
47+
const err = validate(text);
48+
if (err) {
49+
setValidationError(err);
50+
return;
51+
}
52+
setValidationError(null);
53+
onData(text.trim());
54+
};
55+
56+
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
57+
const file = e.target.files?.[0];
58+
if (!file) return;
59+
60+
const reader = new FileReader();
61+
reader.onload = (event) => {
62+
const content = event.target?.result;
63+
if (typeof content === "string") {
64+
setText(content);
65+
setValidationError(null);
66+
}
67+
};
68+
reader.readAsText(file);
69+
70+
// Reset file input so the same file can be re-selected
71+
if (fileInputRef.current) {
72+
fileInputRef.current.value = "";
73+
}
74+
};
75+
76+
return (
77+
<div className="data-input">
78+
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.5rem" }}>
79+
<button
80+
type="button"
81+
onClick={() => fileInputRef.current?.click()}
82+
className="secondary"
83+
>
84+
Upload {format.toUpperCase()} file
85+
</button>
86+
<input
87+
ref={fileInputRef}
88+
type="file"
89+
accept={format === "csv" ? ".csv" : ".jsonl,.json"}
90+
onChange={handleFileUpload}
91+
style={{ display: "none" }}
92+
/>
93+
<span className="hint">
94+
Required {format === "csv" ? "columns" : "fields"}:{" "}
95+
<code>{requiredColumns.join(", ")}</code>
96+
</span>
97+
</div>
98+
99+
<textarea
100+
value={text}
101+
onChange={(e) => {
102+
setText(e.target.value);
103+
setValidationError(null);
104+
}}
105+
placeholder={placeholder}
106+
rows={8}
107+
spellCheck={false}
108+
/>
109+
110+
{validationError && <div className="error">{validationError}</div>}
111+
112+
<button onClick={handleLoad} disabled={!text.trim()}>
113+
Load Data
114+
</button>
115+
</div>
116+
);
117+
}
118+
119+
export default DataInput;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { downloadJSON, downloadCSV } from "../utils/export";
2+
3+
interface ExportButtonProps {
4+
data: any;
5+
perSampleData?: any[];
6+
filename: string;
7+
}
8+
9+
export default function ExportButton({ data, perSampleData, filename }: ExportButtonProps) {
10+
return (
11+
<div className="export-buttons">
12+
<button onClick={() => downloadJSON(data, filename)}>Export JSON</button>
13+
{perSampleData && perSampleData.length > 0 && (
14+
<button onClick={() => downloadCSV(perSampleData, filename)}>Export CSV</button>
15+
)}
16+
</div>
17+
);
18+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useState } from "react";
2+
3+
export interface ParamField {
4+
name: string;
5+
label: string;
6+
type: "number" | "select" | "checkbox";
7+
default: any;
8+
options?: { label: string; value: string }[];
9+
step?: number;
10+
advanced?: boolean;
11+
}
12+
13+
interface ParameterFormProps {
14+
fields: ParamField[];
15+
values: Record<string, any>;
16+
onChange: (values: Record<string, any>) => void;
17+
}
18+
19+
function renderField(
20+
field: ParamField,
21+
value: any,
22+
onChange: (name: string, value: any) => void,
23+
) {
24+
switch (field.type) {
25+
case "select":
26+
return (
27+
<select
28+
value={value}
29+
onChange={(e) => onChange(field.name, e.target.value)}
30+
>
31+
{field.options?.map((opt) => (
32+
<option key={opt.value} value={opt.value}>
33+
{opt.label}
34+
</option>
35+
))}
36+
</select>
37+
);
38+
case "checkbox":
39+
return (
40+
<input
41+
type="checkbox"
42+
checked={!!value}
43+
onChange={(e) => onChange(field.name, e.target.checked)}
44+
/>
45+
);
46+
case "number":
47+
return (
48+
<input
49+
type="number"
50+
value={value}
51+
step={field.step ?? "any"}
52+
onChange={(e) => onChange(field.name, parseFloat(e.target.value))}
53+
/>
54+
);
55+
default:
56+
return null;
57+
}
58+
}
59+
60+
function ParameterForm({ fields, values, onChange }: ParameterFormProps) {
61+
const [showAdvanced, setShowAdvanced] = useState(false);
62+
63+
const basicFields = fields.filter((f) => !f.advanced);
64+
const advancedFields = fields.filter((f) => f.advanced);
65+
66+
const handleChange = (name: string, value: any) => {
67+
onChange({ ...values, [name]: value });
68+
};
69+
70+
return (
71+
<div className="parameter-form">
72+
<div className="param-grid">
73+
{basicFields.map((field) => (
74+
<label key={field.name} className="param-field">
75+
<span className="param-label">{field.label}</span>
76+
{renderField(field, values[field.name], handleChange)}
77+
</label>
78+
))}
79+
</div>
80+
81+
{advancedFields.length > 0 && (
82+
<details
83+
open={showAdvanced}
84+
onToggle={(e) =>
85+
setShowAdvanced((e.target as HTMLDetailsElement).open)
86+
}
87+
>
88+
<summary>Advanced Parameters</summary>
89+
<div className="param-grid">
90+
{advancedFields.map((field) => (
91+
<label key={field.name} className="param-field">
92+
<span className="param-label">{field.label}</span>
93+
{renderField(field, values[field.name], handleChange)}
94+
</label>
95+
))}
96+
</div>
97+
</details>
98+
)}
99+
</div>
100+
);
101+
}
102+
103+
export default ParameterForm;

0 commit comments

Comments
 (0)