feat: add audit logging and web dashboard#1
Conversation
- Add audit logger system to track all user actions - Implement /audit/* endpoints for audit log retrieval - Create modern React web dashboard with Tailwind CSS - Add Dashboard, Audit Logs, Team, Memories, and Rules pages - Implement real-time statistics and activity feeds - Add search and filtering capabilities - Dark theme with professional UI/UX - Use Vite for fast development and builds - Add comprehensive feature documentation
There was a problem hiding this comment.
Pull request overview
This PR introduces an audit logging subsystem on the Python HTTP server and adds a new React + Vite + Tailwind web dashboard for interacting with Claude Memory.
Changes:
- Add
AuditLoggerimplementation plus new/audit/*endpoints on the HTTP server. - Add a new
web/React dashboard (pages for Dashboard, Audit Logs, Team, Memories, Rules) and shared UI components. - Add documentation for the new features and web setup.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 21 comments.
Show a summary per file
| File | Description |
|---|---|
memory_server/server_http.py |
Adds audit module import/init and exposes /audit/logs, /audit/stats, /audit/user/<username> endpoints. |
memory_server/audit/audit_logger.py |
Implements JSONL audit log writer/reader and simple stats/summary helpers. |
web/package.json |
Adds frontend dependencies/scripts for Vite/React/Tailwind and lint command. |
web/vite.config.js |
Configures Vite dev server and API proxy to the Python server. |
web/tailwind.config.js |
Tailwind content scanning and theme color extension. |
web/index.html |
Vite entry HTML for the dashboard app. |
web/src/main.jsx |
React root render entrypoint. |
web/src/index.css |
Tailwind directives + basic global styles. |
web/src/api/client.js |
Axios client with X-API-Key request interceptor. |
web/src/App.jsx |
App shell with auth fetch, layout, sidebar/navbar, and page switching. |
web/src/components/Navbar.jsx |
Top navigation bar UI. |
web/src/components/Sidebar.jsx |
Left navigation / page selection UI. |
web/src/components/Card.jsx |
Reusable stat card component. |
web/src/pages/Dashboard.jsx |
Dashboard page fetching /brief and /audit/stats and rendering stats/activity. |
web/src/pages/AuditLogs.jsx |
Audit log table with client-side filtering. |
web/src/pages/Team.jsx |
Team list page fetching /list-team and rendering member cards. |
web/src/pages/Memories.jsx |
Placeholder UI for memories/search. |
web/src/pages/Rules.jsx |
Placeholder UI for rules. |
web/README.md |
Setup and usage docs for the dashboard. |
FEATURES.md |
Feature documentation/overview including audit + dashboard. |
.claude/settings.local.json |
Updates local Claude settings allowlist commands. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Add scripts to path | ||
| sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) | ||
| sys.path.insert(0, str(Path(__file__).parent / "audit")) | ||
|
|
There was a problem hiding this comment.
Avoid modifying sys.path at runtime to import the audit module. Consider turning memory_server/audit into a proper package (add __init__.py) and importing via a stable module path so imports work consistently in different execution contexts (gunicorn, tests, etc.).
| # Global store | ||
| MEMORY_DIR = Path(__file__).parent.parent / ".memory" | ||
| store = MemoryStore(str(MEMORY_DIR)) | ||
| audit_logger = AuditLogger(str(MEMORY_DIR / "audit")) | ||
|
|
There was a problem hiding this comment.
Audit logging is declared/initialized here, but the server never actually writes audit entries (no calls to audit_logger.log(...) anywhere). As a result /audit/* will stay empty, which conflicts with the PR description (“automatic logging of all API actions”). Add logging calls in the relevant endpoints/decorators (ideally in require_auth + per-route handlers with action + status) so actions are recorded.
|
|
||
| @app.route("/audit/logs", methods=["GET"]) | ||
| @require_auth | ||
| def get_audit_logs(): | ||
| """Get audit logs with optional filtering.""" | ||
| user_filter = request.args.get("user") | ||
| action_filter = request.args.get("action") | ||
| limit = int(request.args.get("limit", 100)) | ||
|
|
There was a problem hiding this comment.
limit is parsed with int(...) without validation; a non-integer value will raise and return 500, and very large values could be used to force large reads/sorts. Handle invalid values gracefully (400) and clamp limit to a reasonable max.
| @app.route("/audit/logs", methods=["GET"]) | |
| @require_auth | |
| def get_audit_logs(): | |
| """Get audit logs with optional filtering.""" | |
| user_filter = request.args.get("user") | |
| action_filter = request.args.get("action") | |
| limit = int(request.args.get("limit", 100)) | |
| MAX_AUDIT_LOG_LIMIT = 1000 | |
| @app.route("/audit/logs", methods=["GET"]) | |
| @require_auth | |
| def get_audit_logs(): | |
| """Get audit logs with optional filtering.""" | |
| user_filter = request.args.get("user") | |
| action_filter = request.args.get("action") | |
| limit_raw = request.args.get("limit", "100") | |
| try: | |
| limit = int(limit_raw) | |
| except (TypeError, ValueError): | |
| return jsonify({"error": "Invalid 'limit' parameter; must be an integer"}), 400 | |
| if limit < 1: | |
| return jsonify({"error": "Invalid 'limit' parameter; must be >= 1"}), 400 | |
| limit = min(limit, MAX_AUDIT_LOG_LIMIT) |
| const fetchCurrentUser = async () => { | ||
| try { | ||
| const response = await api.get('/whoami') | ||
| setCurrentUser(response.data) | ||
| setLoading(false) | ||
| } catch (error) { | ||
| console.error('Failed to fetch user:', error) | ||
| setLoading(false) | ||
| } |
There was a problem hiding this comment.
The /whoami API returns username (and not name), but the app stores the raw response and downstream components render user.name. This will render “undefined” and can break UI assumptions; normalize the response in fetchCurrentUser (e.g., map username to a name field) or update components to use username.
| {/* Stats Grid */} | ||
| <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> | ||
| <Card icon={Users} label="Team Members" value={stats?.team_count || 0} /> | ||
| <Card icon={FileText} label="Memories" value={stats?.memories_count || 0} /> | ||
| <Card icon={Lock} label="Rules" value={stats?.rules_count || 0} /> | ||
| <Card icon={Activity} label="Sessions" value={stats?.sessions_count || 0} /> | ||
| </div> |
There was a problem hiding this comment.
The /brief endpoint returns a “brief” payload (user, roles, team array, rules, sessions, etc.), but the dashboard renders team_count, memories_count, rules_count, sessions_count which are not present in that response. Update the dashboard to derive the displayed values from the actual /brief shape (or add a dedicated stats endpoint that returns these counts).
| logs = [] | ||
| with open(self.log_file, "r") as f: | ||
| logs = [json.loads(line) for line in f if line.strip()] | ||
|
|
||
| users = set() | ||
| actions = {} | ||
|
|
||
| for log in logs: | ||
| users.add(log["user"]) | ||
| action = log["action"] | ||
| actions[action] = actions.get(action, 0) + 1 | ||
|
|
||
| return { | ||
| "total_actions": len(logs), | ||
| "active_users": sorted(list(users)), | ||
| "action_breakdown": actions, | ||
| "last_action": logs[-1]["timestamp"] if logs else None, |
There was a problem hiding this comment.
get_team_stats loads the entire audit log file into memory, which won’t scale as the audit trail grows. Consider streaming the file and aggregating counts incrementally (and/or limiting to a time window) to keep memory usage predictable.
| logs = [] | |
| with open(self.log_file, "r") as f: | |
| logs = [json.loads(line) for line in f if line.strip()] | |
| users = set() | |
| actions = {} | |
| for log in logs: | |
| users.add(log["user"]) | |
| action = log["action"] | |
| actions[action] = actions.get(action, 0) + 1 | |
| return { | |
| "total_actions": len(logs), | |
| "active_users": sorted(list(users)), | |
| "action_breakdown": actions, | |
| "last_action": logs[-1]["timestamp"] if logs else None, | |
| total_actions = 0 | |
| users = set() | |
| actions = {} | |
| last_action = None | |
| with open(self.log_file, "r") as f: | |
| for line in f: | |
| if not line.strip(): | |
| continue | |
| log = json.loads(line) | |
| total_actions += 1 | |
| users.add(log["user"]) | |
| action = log["action"] | |
| actions[action] = actions.get(action, 0) + 1 | |
| last_action = log["timestamp"] | |
| return { | |
| "total_actions": total_actions, | |
| "active_users": sorted(list(users)), | |
| "action_breakdown": actions, | |
| "last_action": last_action, |
| <button | ||
| onClick={onMenuClick} | ||
| className="p-2 hover:bg-slate-700 rounded-lg text-slate-400 hover:text-white" | ||
| > | ||
| <Menu size={20} /> | ||
| </button> |
There was a problem hiding this comment.
Icon-only buttons here (menu toggle, notifications, logout) don’t have accessible names for screen readers. Add aria-label (or visible text) so the controls are usable with assistive tech.
| "Bash(git add:*)", | ||
| "Bash(python -m black --check . && echo \"✅ All files pass Black formatting check!\")", | ||
| "Bash(gh repo:*)", | ||
| "Bash(find /d/Claude_memeory -name \"*.py\" -type f ! -path \"*/venv/*\" | xargs wc -l | tail -1)" |
There was a problem hiding this comment.
This command hardcodes a machine-specific absolute path and appears to have a typo (Claude_memeory). This makes the settings non-portable and likely broken for other developers/CI. Consider removing the absolute path, fixing the spelling, or avoiding committing local-only commands into the repo.
| "Bash(find /d/Claude_memeory -name \"*.py\" -type f ! -path \"*/venv/*\" | xargs wc -l | tail -1)" | |
| "Bash(find . -name \"*.py\" -type f ! -path \"*/venv/*\" | xargs wc -l | tail -1)" |
| logs = [] | ||
| with open(self.log_file, "r") as f: | ||
| for line in f: | ||
| if not line.strip(): | ||
| continue | ||
| entry = json.loads(line) | ||
|
|
||
| if user and entry["user"] != user: | ||
| continue | ||
| if action and entry["action"] != action: | ||
| continue | ||
|
|
||
| logs.append(entry) |
There was a problem hiding this comment.
get_audit_trail assumes every line is valid JSON; a single partial/corrupt line will raise JSONDecodeError and break the endpoint. Add per-line error handling (skip/bucket invalid lines) so reads are resilient, especially given concurrent writes.
|
|
||
| logs = [] | ||
| with open(self.log_file, "r") as f: | ||
| logs = [json.loads(line) for line in f if line.strip()] |
There was a problem hiding this comment.
get_team_stats parses the entire file with json.loads in a list comprehension without error handling. If a single line is malformed, stats will 500. Consider streaming with try/except per line so stats remain available even if the log contains a bad entry.
| logs = [json.loads(line) for line in f if line.strip()] | |
| for line in f: | |
| if not line.strip(): | |
| continue | |
| try: | |
| logs.append(json.loads(line)) | |
| except json.JSONDecodeError: | |
| continue |
Summary
Add two major features to Claude Memory:
1. Audit Logging System ✅
2. Modern Web Dashboard 🎨
Files Added (21 new files)
How to Test
Dashboard: http://localhost:3000
Server: http://localhost:8765