Add read-only admin role, client verification workflow, and email notifications

- Three-tier role system: user, admin, readonly_admin with dashboard gating
- Client field (Yes/No) on registration with conditional Client Name and Studio Name
- Auto-tag client agents as "needs_verification" with Verification tab on admin dashboard
- Client agent email notification via Mailgun to configured recipients
- Daily agent digest email scheduled via APScheduler (configurable hour)
- Manual digest trigger endpoint: POST /api/admin/digest/send
- Role dropdown replaces is_admin checkbox in user edit modal
- Registration form reordered: Name, Description, Purpose, Client, Client Name, Studio, Tool
- Stat card CSS fix for text truncation on admin dashboard
- Updated CLAUDE.md documentation and PLAN file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-03-10 20:47:02 +02:00
parent 3bc757b99c
commit 6c231cb094
10 changed files with 2008 additions and 136 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
AgentHub is a FastAPI-based AI Agent Management System with MongoDB backend. It provides role-based authentication (admin/user), agent CRUD operations, user management, and a web interface built with Jinja2 templates and Bootstrap 5.
AgentHub is a FastAPI-based AI Agent Management System with MongoDB backend. It provides three-tier role-based authentication (admin/readonly_admin/user), agent CRUD operations, client verification workflow, email notifications, user management, and a web interface built with Jinja2 templates and Bootstrap 5.
## Common Development Tasks
@ -33,6 +33,8 @@ Create `.env` file with:
- `MAILGUN_FROM_EMAIL`: Sender address (default: `AgentHub <noreply@{MAILGUN_DOMAIN}>`)
- `TOKEN_USAGE_THRESHOLD`: Token count that triggers an alert (default: 100000)
- `NOTIFICATION_COOLDOWN_HOURS`: Hours between repeat alerts for the same agent (default: 24)
- `CLIENT_AGENT_NOTIFY_EMAILS`: Comma-separated list of emails for client agent notifications
- `DAILY_DIGEST_HOUR`: Hour (24h format) to send daily digest (default: 7)
### Default Login Credentials
- Admin: `admin@agenthub.com` / `admin123`
@ -46,22 +48,26 @@ Create `.env` file with:
- JWT cookie-based authentication system
- HTML routes for web interface
- REST API endpoints for agent/user management
- Role-based access control (admin vs regular user)
- Three-tier role-based access control (admin / readonly_admin / user)
- Client verification workflow with email notifications
- Daily agent digest scheduler (APScheduler)
- Template rendering with Jinja2
**Key Authentication Functions**:
- `get_current_user_optional()`: Cookie-based auth for templates
- `get_current_user_from_cookie()`: Required auth for API endpoints
- `require_admin()`: Admin-only access control
- `require_admin()`: Admin-only access control (write operations)
- `require_admin_or_readonly()`: Admin + readonly_admin access (read-only dashboard views)
### Data Layer
**models.py**: Pydantic models for:
- `AiAgent`: Core agent model with comprehensive fields (includes `discipline`, `rating`)
- `AiAgent`: Core agent model with comprehensive fields (includes `discipline`, `rating`, `client`, `client_name`, `studio_name`)
- `UsageTimelineEntry`: Daily usage data including `message_count` and `token_count`
- `UserCreate/UserResponse`: User management models
- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`, `rating`, `rating_count`)
- `AgentCollectorCreate`: Collector API input model (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`)
- `UserCreate/UserResponse`: User management models (includes `role` field: `user`/`admin`/`readonly_admin`)
- `UserUpdate`: Includes `role` field for three-tier role management
- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`, `rating`, `rating_count`, `client`, `client_name`, `studio_name`, `verification_status`, `verified_by`, `verified_date`)
- `AgentCollectorCreate`: Collector API input model (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`, `client`, `client_name`, `studio_name`)
- `AgentUsageStatsResponse`: Usage statistics response (includes `total_tokens`, `prompt_tokens`, `completion_tokens`)
**crud.py**: Database operations using Motor (async MongoDB driver):
@ -72,13 +78,17 @@ Create `.env` file with:
**database.py**: MongoDB connection setup with Motor async client
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`
- `ensure_indexes()`: Creates compound unique index on `agent_ratings(agent_id, user_id)`
- `ensure_indexes()`: Creates compound unique index on `agent_ratings(agent_id, user_id)` and index on `verification_status`
**notifications.py**: Optional Mailgun email notification system:
**notifications.py**: Mailgun email notification system:
- `is_mailgun_configured()`: Returns False if env vars not set (gracefully disabled)
- `send_mailgun_email()`: POST to Mailgun HTTP API with 10s timeout
- `build_threshold_email()`: HTML email template for threshold alerts
- `check_and_notify_threshold()`: Checks token usage against threshold, enforces cooldown via `token_notifications` collection, sends to admin users
- `send_client_agent_notification()`: Sends email when client-facing agent is created (to `CLIENT_AGENT_NOTIFY_EMAILS`)
- `build_client_agent_email()`: HTML email template for client agent notifications
- `send_daily_agent_digest()`: Queries agents created in last 24 hours, sends summary to all admin users
- `build_daily_digest_email()`: HTML email template for daily digest
**auth.py**: JWT authentication with:
- bcrypt password hashing
@ -96,7 +106,7 @@ Located in `templates/` directory:
- **agent_management.html**: Agent dashboard with real data
- **search.html**: Global search functionality
- **user_management.html**: User management interface
- **admin/dashboard.html**: Admin statistics and management
- **admin/dashboard.html**: Admin statistics, management, and verification workflow
### Static Assets
@ -110,8 +120,13 @@ Located in `templates/` directory:
### Authentication Flow
- Cookie-based JWT authentication
- Role-based access (admin/user permissions)
- Automatic redirects based on user role
- Three-tier role-based access: `user`, `admin`, `readonly_admin`
- `user`: Standard access, can manage own agents
- `admin`: Full access to admin dashboard, all write operations
- `readonly_admin`: Can view admin dashboard but all write actions (edit, delete, approve, create) are hidden
- `role` field on user documents; `is_admin` kept in sync for backward compatibility
- `require_admin_or_readonly()` dependency for read-only admin endpoints
- Automatic redirects based on user role (admin/readonly_admin → /admin, user → /agent-management)
- Secure logout with token cleanup
### Agent Management
@ -122,6 +137,32 @@ Located in `templates/` directory:
- Filtering by status, audit status (Audited / Not Audited), and discipline
- Admin can view/manage all agents
### Client & Verification System
- `client` field on agents: `"yes"` or `"no"` (mandatory on registration form)
- `client_name`: free text, required when client is "yes"
- `studio_name`: optional free text field
- Registration form order: Name, Description, Purpose, Client (Yes/No), Client Name (conditional), Studio Name, Tool, then Version/Status/etc.
- When `client == "yes"`: agent auto-tagged with `verification_status = "needs_verification"`
- Verification tab on admin dashboard shows pending agents with Approve button
- `PUT /api/admin/agents/{id}/verify` — admin-only, sets status to "verified" with verifier info
- `GET /api/admin/agents/pending-verification` — returns agents needing verification
- Verification badges displayed on agent cards (orange "Needs Verification", green "Verified")
### Client Agent Email Notification
- When `client == "yes"` on agent creation, sends email via Mailgun to `CLIENT_AGENT_NOTIFY_EMAILS`
- Subject: "Client Agent Created"
- Body includes: Agent Name, Description, Purpose, Client Name, Studio Name, Tool, Created By
- Non-blocking: failure does not break agent creation
### Daily Agent Digest Email
- Scheduled via APScheduler at configured hour (default 7:00 AM, `DAILY_DIGEST_HOUR` env var)
- Queries agents created in last 24 hours
- Sends to all active admin users
- Body includes: Agent Name, Purpose, Description, Created By (email)
- Subject: "Agents Created Last 24 Hours"
- Skips sending if no agents created
- Can be manually triggered via `POST /api/admin/digest/send` (admin-only)
### Discipline & Star Rating
- `discipline` field classifies agents into business categories: Strategy, Creative, Oversight including delivery, Optimization, Back Office including operations, Pencil Agents
- Required on registration form, optional on edit (to support legacy agents)
@ -161,6 +202,9 @@ Located in `templates/` directory:
### User Management
- User registration with validation
- Admin user creation capabilities
- Three-tier role system: `user`, `admin`, `readonly_admin`
- Role dropdown in admin user edit modal (replaces is_admin checkbox)
- `role` and `is_admin` fields kept in sync on update
- Profile management
- User statistics and administration
@ -182,8 +226,10 @@ Located in `templates/` directory:
### Authentication
- Use cookie-based auth for web interface
- API endpoints require `get_current_user_from_cookie()` dependency
- Admin endpoints use `require_admin()` dependency
- Write endpoints use `require_admin()` dependency
- Read-only admin endpoints use `require_admin_or_readonly()` dependency
- Always validate user permissions for data access
- `readonly_admin` users can view admin dashboard but UI hides all write-action buttons via `admin-write-action` CSS class
### Template Context
- Pass `current_user` to all templates for navigation
@ -213,4 +259,14 @@ Key dependencies from requirements.txt:
- **pydantic**: Data validation
- **jinja2**: Template engine
- **python-multipart**: Form handling
- **requests**: HTTP client (used for Mailgun API calls)
- **requests**: HTTP client (used for Mailgun API calls)
- **apscheduler**: Task scheduling (daily digest email)
## API Endpoints (New)
### Verification
- `GET /api/admin/agents/pending-verification` — List agents with verification status (admin + readonly_admin)
- `PUT /api/admin/agents/{agent_id}/verify` — Approve/verify an agent (admin only)
### Daily Digest
- `POST /api/admin/digest/send` — Manually trigger the daily agent digest email (admin only)

588
PLAN-prompt-audit.md Normal file
View file

@ -0,0 +1,588 @@
# Agent Tracker: Prompt Audit & Auto-Audit — Implementation Plan
## Context
The admin team needs visibility into what each LibreChat agent's system prompt says and whether it poses business/legal/compliance risks. System prompts will arrive via the agent-sync pipeline (see the agent-sync plan). Agent Tracker needs to: (1) accept and store them, and (2) provide on-demand AI analysis using Google Gemini to classify agents against business category definitions and flag those needing scrutiny.
### Business Category Definitions (used for backend analysis, not visible fields)
- **Cat 1**: Created in the Oliver AI Sandbox, used behind the scenes for experimentation. Needs IT/Compliance consideration for safety.
- **Cat 1B**: Agents that may incur large cost but aren't Cat 2 or 3.
- **Cat 2**: Created in Pencil, exposed to clients but not for sale. Needs Legal consideration.
- **Cat 3**: Created in Pencil, sold to clients or sold via teams using them. Needs Commercial team consideration.
## Architecture
```
Agent-sync pipeline → POST /agents collector API (now includes system_prompt)
→ Stored on agent document
Admin clicks "Run Audit"
→ Reads stored system_prompt + tools from agent docs
→ Sends to Google Gemini API for analysis
→ Stores audit results on agent doc + audit_history collection
→ Displays in new Prompt Audit tab
```
- **LLM**: Google Gemini (`gemini-2.5-pro`) via `google-generativeai` SDK
- **API Key**: Reuses existing `GOOGLE_API_KEY` (same key as the ai_qc project)
- **Trigger**: On-demand only (admin clicks "Run Audit")
- **No new DB connections** — prompts come through the existing collector API
## Files to Modify
### 1. MODIFY: `requirements.txt`
Add:
```
google-generativeai>=0.3.0
```
### 2. MODIFY: `database.py` — Add audit_history collection
After line 16 (after `agent_ratings_collection`), add:
```python
audit_history_collection = db.get_collection("audit_history")
```
In `ensure_indexes()`, add:
```python
await agents_collection.create_index([("audit_status", 1)])
await audit_history_collection.create_index([("agent_id", 1), ("audit_date", -1)])
```
Also update the import in `audit_analyzer.py` to use `audit_history_collection`.
### 3. MODIFY: `models.py` — Add system_prompt + audit models
**Add to `AgentCollectorCreate`** (around line 164, with the other optional fields):
```python
system_prompt: Optional[str] = None
```
**Add to `AiAgentResponse`** (around line 144, after `completion_tokens`):
```python
audit_status: Optional[str] = None
audit_date: Optional[str] = None
system_prompt: Optional[str] = None
```
**Add new model after line 210:**
```python
class AuditReviewRequest(BaseModel):
audit_status: str = Field(..., pattern="^(flagged|reviewed|cleared)$")
reviewer_notes: Optional[str] = None
```
### 4. NEW: `audit_analyzer.py` — Core audit module
New file with two responsibilities: Gemini analysis and result storage.
**A. Gemini API Analysis**
```python
import os
import json
import re
import asyncio
from datetime import datetime
from bson import ObjectId
import google.generativeai as genai
from database import agents_collection, audit_history_collection
def is_gemini_configured() -> bool:
return bool(os.getenv("GOOGLE_API_KEY"))
def _get_model():
api_key = os.getenv("GOOGLE_API_KEY")
genai.configure(api_key=api_key)
model_name = os.getenv("AUDIT_GEMINI_MODEL", "gemini-2.5-pro")
return genai.GenerativeModel(model_name)
```
Key functions:
| Function | Purpose |
|----------|---------|
| `is_gemini_configured()` | Checks `GOOGLE_API_KEY` env var |
| `analyze_single_agent(agent_name, system_prompt, tools, description, model_name, author)` | Builds prompt with category definitions, calls Gemini, parses JSON response |
| `run_audit_batch(agents, concurrency=3)` | `asyncio.Semaphore` for rate-limit-safe parallel processing |
| `store_audit_result(agent_id, audit_data)` | `$set` on agent doc + insert into `audit_history` |
| `get_all_audit_results()` | Query agents with audit fields |
| `update_audit_review(agent_id, status, notes, reviewer_info)` | Mark as reviewed/cleared |
**Gemini Prompt Design:**
System instruction:
```
You are an AI agent compliance analyst. Classify AI agents based on their system prompts
and tool configurations into risk categories.
Respond ONLY with valid JSON matching this schema:
{
"category": "1" | "1B" | "2" | "3",
"category_reasoning": "string",
"flags": ["array", "of", "strings"],
"summary": "2-3 sentence analysis",
"recommendations": "which team(s) should review and why",
"risk_level": "low" | "medium" | "high" | "critical"
}
CATEGORY DEFINITIONS:
Cat 1 - Internal Sandbox/Experimentation (Oliver AI Sandbox, behind the scenes, needs IT/Compliance)
Cat 1B - High Cost Internal (may incur large cost, not Cat 2 or 3)
Cat 2 - Client-Exposed Not Sold (Pencil platform, exposed to clients, needs Legal)
Cat 3 - Client-Sold (Pencil platform, sold to clients, needs Commercial team)
RISK LEVELS:
- low: Internal-only, limited capabilities
- medium: Internal with external tool access or moderate cost
- high: Client-facing or accesses sensitive data
- critical: Client-sold or handles financial/legal/PII data
FLAGS TO CONSIDER:
internal_only, experimental, sandbox, client_facing, pencil_platform,
revenue_generating, not_for_sale, uses_external_tools, uses_code_interpreter,
uses_file_search, accesses_sensitive_data, handles_pii, high_cost,
resource_intensive, legal_review_needed, commercial_review_needed,
compliance_review_needed, no_instructions
```
User prompt per agent:
```
Analyze this AI agent:
AGENT NAME: {name}
DESCRIPTION: {description}
MODEL: {model}
AUTHOR: {author}
SYSTEM PROMPT / INSTRUCTIONS:
---
{system_prompt}
---
TOOLS: {tools_list}
TOOL RESOURCES: {tool_resources}
ACTIONS: {actions}
```
**Concurrency**: `asyncio.Semaphore(3)` — configurable via `AUDIT_CONCURRENCY` env var. ~50 agents completes in ~50-85 seconds.
**Error handling per agent**: Wrap each call in try/except. On failure, return `{"error": "...", "agent_name": "..."}`. Parse JSON response with regex fallback if needed. All audited agents start with `audit_status = "flagged"`.
### 5. MODIFY: `main.py` — 3 new endpoints + collector update
**A. Update `map_agent_collector_to_internal()`** to pass through `system_prompt`:
Add to the mapping dict:
```python
"system_prompt": collector_data.system_prompt,
```
**B. Update `create_agent_response()`** to include audit fields:
Add to the response construction:
```python
audit_status=agent.get("audit_status"),
audit_date=agent.get("audit_date"),
system_prompt=agent.get("system_prompt"),
```
**C. Add 3 new admin-only endpoints** (after the analytics endpoint, ~line 1163):
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `POST /api/admin/audit/run` | POST | Reads stored `system_prompt` from agent docs, sends to Gemini. Optional `agent_id` body param for single agent. Returns `{status, total, audited_count, failed_count, skipped_count, results_summary}`. |
| `GET /api/admin/audit/results` | GET | Returns all agents with audit data + `config_status: {gemini_configured: bool}`. |
| `PUT /api/admin/audit/{agent_id}/review` | PUT | Admin marks agent as reviewed/cleared with optional `reviewer_notes`. |
Pre-flight check: `POST /api/admin/audit/run` returns 503 if `GOOGLE_API_KEY` not set.
### 6. MODIFY: `templates/admin/dashboard.html` — Add Prompt Audit tab
**A. New tab** (4th tab, after Agents Management):
```html
<li class="nav-item">
<a class="nav-link" id="audit-tab" data-bs-toggle="tab" href="#audit">
<i class="fas fa-shield-alt me-2"></i>Prompt Audit
<span class="badge bg-danger ms-1" id="auditFlaggedBadge" style="display:none;">0</span>
</a>
</li>
```
**B. Tab content layout:**
```
Row 1: Header + [Run Audit] button (with spinner loading state)
Row 2: Summary cards — Audited | Flagged | Reviewed | Cleared | No Prompt
Row 3: Filters — [Category ▼] [Risk Level ▼] [Status ▼] [Search...]
Row 4: Results table:
Agent Name | Category | Risk Level | Flags | Status | Last Audited | Actions
Row 5: Agents without prompts notice (collapsible)
```
**C. Audit Detail Modal:**
- Agent name + basic info header
- LLM analysis summary
- Category badge + reasoning
- Flags list (as badges)
- Recommendations text
- System prompt (collapsible `<pre>` block, can be long)
- Tools config (collapsible)
- Review controls: status dropdown (flagged/reviewed/cleared) + notes textarea + Save button
- Review history trail (who reviewed, when)
**D. JavaScript functions:**
| Function | Purpose |
|----------|---------|
| `loadAuditResults()` | `GET /api/admin/audit/results` → populate table + summary cards + flagged badge count |
| `runAudit(agentId?)` | `POST /api/admin/audit/run` with loading spinner. Disable button during run. On complete, show summary alert + reload results. |
| `displayAuditResults(agents)` | Render table rows with color-coded badges |
| `showAuditDetail(agentId)` | Open detail modal with full analysis |
| `submitAuditReview(agentId)` | `PUT /api/admin/audit/{id}/review` → reload results |
| `filterAuditResults()` | Client-side filtering by category, risk, status, search text |
Wire up:
- `DOMContentLoaded` → call `loadAuditResults()` alongside existing `loadAdminData()` and `loadAnalytics()`
- Refresh button → also call `loadAuditResults()`
**E. CSS additions:**
```css
.risk-critical { background-color: #dc3545; color: white; }
.risk-high { background-color: #fd7e14; color: white; }
.risk-medium { background-color: #ffc107; color: #212529; }
.risk-low { background-color: #28a745; color: white; }
.cat-1 { background-color: #28a745; color: white; }
.cat-1b { background-color: #17a2b8; color: white; }
.cat-2 { background-color: #6f42c1; color: white; }
.cat-3 { background-color: #dc3545; color: white; }
.audit-status-flagged { border-left: 4px solid #dc3545; }
.audit-status-reviewed { border-left: 4px solid #ffc107; }
.audit-status-cleared { border-left: 4px solid #28a745; }
```
**F. Config warning:** If `config_status.gemini_configured` is false in the results response, show an info card explaining that `GOOGLE_API_KEY` needs to be set, and hide the Run Audit button.
## Environment Variables
Add to `.env`:
```
GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
AUDIT_GEMINI_MODEL=gemini-2.5-pro # default, configurable
AUDIT_CONCURRENCY=3 # concurrent Gemini API calls
```
## Error Handling
| Scenario | Handling |
|----------|---------|
| `GOOGLE_API_KEY` not set | 503 with message; UI shows config warning, hides Run button |
| Agent has no `system_prompt` | Skip during audit, count as "skipped", show in "No Prompt" card |
| Gemini API auth failure | Abort batch, return 503 |
| Gemini rate limit | Retry with backoff; semaphore limits concurrency; mark agent as failed |
| Prompt too long | Gemini 2.5 Pro has 1M token limit — unlikely to hit; truncate at 900K chars if needed |
| JSON parse failure from Gemini | Regex extraction fallback `\{[\s\S]*\}`; if fails, store raw text as summary |
| Partial failures | Continue processing remaining agents; report success/fail/skipped counts |
## Design Decisions
- **Prompts via agent-sync** — no second DB connection needed, reuses existing infrastructure
- **All audited agents start as "flagged"** — admin must explicitly review/clear each
- **Synchronous HTTP request** for v1 — admin waits with spinner (~30-120s for full audit)
- **Gemini 2.5 Pro** — fast, 1M token context window, reuses existing Google API key
- **Separate from quality_audit_status** — that's a manual human checkbox; this is automated analysis. They coexist.
- **audit_history_collection** — historical record of each audit run
## Implementation Order
1. `requirements.txt` — add `google-generativeai`
2. `database.py` — add `audit_history_collection` + indexes
3. `models.py` — add `system_prompt` to collector model, audit fields to response, `AuditReviewRequest`
4. `audit_analyzer.py` — new file (Gemini analysis + result storage)
5. `main.py` — update collector mapping + `create_agent_response()` + add 3 audit endpoints
6. `templates/admin/dashboard.html` — Prompt Audit tab (HTML, JS, CSS)
## Verification
1. Add `GOOGLE_API_KEY` to `.env`
2. `pip install -r requirements.txt`
3. `uvicorn main:app --reload --port 8000`
4. Login as admin → `/admin` → click "Prompt Audit" tab
5. Before agent-sync runs: agents show as "No prompt available"
6. After agent-sync (with instructions included): `system_prompt` appears on agent docs
7. Click "Run Audit" → spinner → results populate with category/risk/flags badges
8. Click detail on an agent → see full LLM analysis + raw system prompt
9. Mark as "Reviewed" with notes → status updates, reviewer trail shows
10. Refresh → all data persists
11. Test without `GOOGLE_API_KEY` → config warning shown gracefully
## Files Changed Summary (for deployment)
| File | Change |
|------|--------|
| `audit_analyzer.py` | **New file** |
| `database.py` | Add collection + indexes |
| `models.py` | Add fields + new model |
| `main.py` | Update collector mapping + 3 new endpoints |
| `templates/admin/dashboard.html` | Add Prompt Audit tab |
| `requirements.txt` | Add `google-generativeai` |
---
# Phase 2: Read-Only Admin, Client Verification & Daily Digest
## 1. Read-Only Admin Role
### Problem
Currently only two roles exist: `user` and `admin`. There's a need for users who can **view** the admin dashboard but not modify anything.
### Solution
Add a third role: `readonly_admin`.
**A. Data Model Changes (`models.py`)**
- Update `UserCreate` and user handling to support `role` values: `user`, `admin`, `readonly_admin`
**B. Auth Changes (`auth.py` / `main.py`)**
- Add `require_admin_or_readonly()` dependency — allows access to admin dashboard GET routes
- Existing `require_admin()` stays as-is for write operations (create/update/delete)
- `readonly_admin` users:
- Can access `/admin` dashboard and view all tabs
- Cannot run audits, edit agents, edit users, approve verifications, or perform any write action
- UI hides action buttons (edit, delete, approve, Run Audit) for readonly users
**C. User Management UI (`templates/admin/dashboard.html`)**
- In the user edit modal, add a role dropdown: `User` | `Admin` | `Read-Only Admin`
- Admin can change any user's role (except their own demotion from admin)
**D. API Changes (`main.py`)**
- `GET /admin` → allow `admin` + `readonly_admin`
- All `POST/PUT/DELETE` admin endpoints → require `admin` only (no change)
- `GET /api/admin/*` data endpoints → allow `admin` + `readonly_admin`
- New endpoint: `PUT /api/users/{user_id}/role` — admin-only, sets role
---
## 2. Client Verification System
### Problem
Agents created for clients need a verification step before they're considered approved for use.
### Solution
Add a `verification_status` field to agents and a verification workflow.
**A. Data Model Changes (`models.py`)**
- Add to `AiAgent` / `AiAgentResponse`:
- `client: Optional[str] = None``"yes"` or `"no"`
- `client_name: Optional[str] = None` — free text, required when `client == "yes"`
- `studio_name: Optional[str] = None` — free text, not mandatory
- `verification_status: Optional[str] = None``"needs_verification"` | `"verified"` | `None`
- `verified_by: Optional[str] = None` — user who verified
- `verified_date: Optional[str] = None` — when verified
- Add to `AiAgentCreate`:
- `client: str` (mandatory, `"yes"` or `"no"`)
- `client_name: Optional[str] = None`
- `studio_name: Optional[str] = None`
- Add to `AgentCollectorCreate`:
- `client: Optional[str] = None`
- `client_name: Optional[str] = None`
- `studio_name: Optional[str] = None`
**B. Registration Form (`templates/agent_register.html`)**
Reorder form fields to:
1. **Agent Name** (mandatory)
2. **Description** (stays the same)
3. **Purpose** (stays the same)
4. **Client** — dropdown: `Yes` / `No` (mandatory)
- 4a. If `Yes` → show a text input for **Client Name** (mandatory when visible)
5. **Studio Name** — free text (not mandatory)
6. **Tool** (stays the same)
7. *...rest of form continues from Version, Status, etc.*
JavaScript: toggle `client_name` field visibility based on `client` dropdown value.
**C. Auto-Tagging Logic**
- When `client == "yes"` on agent creation → set `verification_status = "needs_verification"`
- When `client == "no"` or not set → `verification_status` remains `None`
**D. Verification Tab on Admin Dashboard (`templates/admin/dashboard.html`)**
New tab: **Verification** (between Agents Management and Prompt Audit tabs)
```
Tab content:
- Header: "Agents Pending Verification"
- Table: Agent Name | Client Name | Studio | Created By | Date Created | Status | Action
- Each row with status "needs_verification" shows an [Approve ✓] button
- Clicking Approve → calls PUT /api/admin/agents/{id}/verify → status changes to "verified"
- Verified agents move to a collapsible "Recently Verified" section below
- Filter: Show All | Needs Verification | Verified
```
**E. API Endpoints (`main.py`)**
- `PUT /api/admin/agents/{agent_id}/verify` — admin-only, sets `verification_status = "verified"`, records `verified_by` and `verified_date`
- `GET /api/admin/agents/pending-verification` — returns agents where `verification_status == "needs_verification"`
**F. Agent Card Display**
- Show verification badge on agent cards:
- `needs_verification` → orange badge: "Needs Verification"
- `verified` → green badge: "Verified"
- `None` → no badge (non-client agents)
---
## 3. Client Agent Email Notification
### Problem
When a client-facing agent is created, stakeholders need to be notified immediately.
### Solution
Trigger an email via Mailgun when `client == "yes"` on agent creation.
**A. Mailgun Configuration (`.env`)**
```
MAILGUN_API_KEY=4fccfbb0606b55852d243fc848f0356c-c6620443-faf31917
MAILGUN_DOMAIN=mg.oliver.solutions
MAILGUN_FROM_EMAIL=AgentHub <noreply@mg.oliver.solutions>
CLIENT_AGENT_NOTIFY_EMAILS=EstellevanHeerden@oliver.agency
```
Note: `CLIENT_AGENT_NOTIFY_EMAILS` is a comma-separated list, making it easy to add recipients later.
**B. Notification Logic (`notifications.py`)**
New function: `send_client_agent_notification(agent_data: dict)`
- **Trigger**: Called from the agent creation endpoint when `client == "yes"`
- **Subject**: `Client Agent Created`
- **Body** (HTML email):
```
A new client-facing agent has been created and requires verification.
Agent Name: {agent_name}
Description: {description}
Purpose: {purpose}
Client: Yes
Client Name: {client_name}
Studio Name: {studio_name or "N/A"}
Tool: {tool}
Created By: {user_email}
Please review this agent in AgentHub.
```
- **To**: `CLIENT_AGENT_NOTIFY_EMAILS` env var (comma-split)
- **Non-blocking**: Failure does not break agent creation (try/except, log warning)
**C. Mailgun API Call**
Uses existing `send_mailgun_email()` pattern from `notifications.py`:
```
POST https://api.mailgun.net/v3/mg.oliver.solutions/messages
Auth: api:{MAILGUN_API_KEY}
Form data: from, to, subject, html
```
---
## 4. Daily Agent Digest Email
### Problem
Admins need a daily summary of all agents created in the last 24 hours for quick scanning.
### Solution
A scheduled daily email summarising new agents.
**A. New Function (`notifications.py`)**
`send_daily_agent_digest()`
- **Query**: Find all agents with `created_date` in the last 24 hours
- **Subject**: `Agents Created Last 24 Hours`
- **Body** (HTML email, kept short and scannable):
```
{count} agent(s) created in the last 24 hours:
┌──────────────────────────────────────────────────┐
│ 1. Agent Name │
│ Purpose: Brief purpose text │
│ Description: Brief description text │
│ Created by: user@email.com │
│ │
│ 2. Agent Name │
│ Purpose: ... │
│ Description: ... │
│ Created by: ... │
└──────────────────────────────────────────────────┘
View full details at {AGENTHUB_URL}/admin
```
- **To**: All active admin users (query `users` collection for `role == "admin"`)
- **If no agents created**: Skip sending (don't send empty digest)
**B. Scheduling**
Option: Use `apscheduler` (add to `requirements.txt`):
```python
from apscheduler.schedulers.asyncio import AsyncIOScheduler
scheduler = AsyncIOScheduler()
scheduler.add_job(send_daily_agent_digest, 'cron', hour=7, minute=0) # 7:00 AM daily
scheduler.start()
```
Wire into FastAPI `startup` event in `main.py`.
Alternatively, can be triggered via a cron job calling a new admin endpoint:
- `POST /api/admin/digest/send` — admin-only, manually trigger the digest
**C. Configuration (`.env`)**
```
DAILY_DIGEST_HOUR=7 # Hour to send (24h format), default 7
DAILY_DIGEST_TIMEZONE=UTC # Timezone, default UTC
```
---
## Implementation Order (Phase 2)
1. **Models** — Add `client`, `client_name`, `studio_name`, `verification_status`, `verified_by`, `verified_date` fields; update `role` to support `readonly_admin`
2. **Database** — Add index on `verification_status`
3. **Auth** — Add `require_admin_or_readonly()` dependency
4. **Registration Form** — Reorder fields, add Client dropdown with conditional Client Name, add Studio Name
5. **CRUD/main.py** — Auto-tag verification status on creation, add verification + role endpoints
6. **Notifications** — Client agent email + daily digest function
7. **Admin Dashboard** — Verification tab, role dropdown in user edit, readonly UI gating
8. **Scheduler** — Wire up daily digest (apscheduler or cron endpoint)
## Files Changed Summary (Phase 2)
| File | Change |
|------|--------|
| `models.py` | Add client/verification/studio fields, readonly_admin role support |
| `database.py` | Add index on `verification_status` |
| `auth.py` | Add `require_admin_or_readonly()` |
| `crud.py` | Verification queries, daily digest query |
| `main.py` | Verification endpoints, role endpoint, digest endpoint, auth updates |
| `notifications.py` | Client agent notification, daily digest email |
| `templates/agent_register.html` | Reorder form, add Client/Studio fields |
| `templates/admin/dashboard.html` | Verification tab, role dropdown, readonly gating |
| `requirements.txt` | Add `apscheduler` (if using scheduler approach) |
| `.env` | Add `CLIENT_AGENT_NOTIFY_EMAILS`, `DAILY_DIGEST_HOUR`, `DAILY_DIGEST_TIMEZONE` |
## Verification (Phase 2)
1. Create a user with `readonly_admin` role → confirm they can view `/admin` but all action buttons are hidden
2. Register agent with Client = Yes, Client Name = "Test Client" → confirm `verification_status = "needs_verification"` and email sent to configured address
3. Register agent with Client = No → confirm no verification tag, no email
4. Admin dashboard → Verification tab → approve an agent → confirm status changes to "verified" with badge update
5. Wait for scheduled time (or trigger manually) → confirm daily digest email arrives with correct agent list
6. Test with no agents created → confirm no email sent
---
## Dependency on Agent-Sync
This plan requires the agent-sync changes to be deployed (removing the `instructions` exclusion and adding `system_prompt` to the payload). Without it, agents will have no `system_prompt` stored and the audit will skip all agents. The audit tab will still load and show the "No Prompt" count gracefully.

140
crud.py
View file

@ -218,20 +218,142 @@ async def search_agents(search_term: str, user_id: str = None):
return await agents_collection.find(query).sort("created_at", -1).to_list(length=None)
async def get_agent_stats():
async def get_agent_stats(discipline_filter: str = None):
"""Get agent statistics"""
pipeline = [
{
"$group": {
"_id": "$agent_status",
"count": {"$sum": 1}
}
pipeline = []
if discipline_filter:
pipeline.append({"$match": {"discipline": discipline_filter}})
pipeline.append({
"$group": {
"_id": "$agent_status",
"count": {"$sum": 1}
}
]
})
stats = await agents_collection.aggregate(pipeline).to_list(length=None)
return {stat["_id"]: stat["count"] for stat in stats}
async def get_analytics_summary(status_filter: str = None, discipline_filter: str = None):
"""Get aggregated analytics summary across all agents"""
match_filter = {}
if status_filter:
match_filter["agent_status"] = status_filter
if discipline_filter:
match_filter["discipline"] = discipline_filter
pipeline = []
if match_filter:
pipeline.append({"$match": match_filter})
pipeline.append({
"$group": {
"_id": None,
"total_messages": {"$sum": {"$ifNull": ["$total_messages", 0]}},
"total_tokens": {"$sum": {"$ifNull": ["$total_tokens", 0]}},
"prompt_tokens": {"$sum": {"$ifNull": ["$prompt_tokens", 0]}},
"completion_tokens": {"$sum": {"$ifNull": ["$completion_tokens", 0]}},
"conversation_count": {"$sum": {"$ifNull": ["$conversation_count", 0]}},
"unique_users": {"$sum": {"$ifNull": ["$unique_users", 0]}},
}
})
results = await agents_collection.aggregate(pipeline).to_list(length=1)
if results:
r = results[0]
del r["_id"]
return r
return {
"total_messages": 0, "total_tokens": 0, "prompt_tokens": 0,
"completion_tokens": 0, "conversation_count": 0, "unique_users": 0,
}
async def get_discipline_breakdown(status_filter: str = None):
"""Group agents by discipline field"""
pipeline = []
if status_filter:
pipeline.append({"$match": {"agent_status": status_filter}})
pipeline.append({"$group": {"_id": "$discipline", "count": {"$sum": 1}}})
results = await agents_collection.aggregate(pipeline).to_list(length=None)
return {(r["_id"] or "Unassigned"): r["count"] for r in results}
async def get_aggregated_usage_timeline(limit_days: int = 90, status_filter: str = None, discipline_filter: str = None):
"""Aggregate usage_timeline across all agents for system-wide daily totals"""
match_filter = {"usage_timeline": {"$exists": True, "$ne": []}}
if status_filter:
match_filter["agent_status"] = status_filter
if discipline_filter:
match_filter["discipline"] = discipline_filter
pipeline = [
{"$match": match_filter},
{"$unwind": "$usage_timeline"},
{
"$group": {
"_id": "$usage_timeline.date",
"message_count": {"$sum": {"$ifNull": ["$usage_timeline.message_count", 0]}},
"token_count": {"$sum": {"$ifNull": ["$usage_timeline.token_count", 0]}},
}
},
{"$sort": {"_id": 1}},
{"$limit": limit_days},
]
results = await agents_collection.aggregate(pipeline).to_list(length=None)
return [{"date": r["_id"], "message_count": r["message_count"], "token_count": r["token_count"]} for r in results]
async def get_top_agents(sort_field: str, limit: int = 5, status_filter: str = None, discipline_filter: str = None):
"""Return top N agents by a given numeric field"""
match_filter = {sort_field: {"$exists": True, "$ne": None, "$gt": 0}}
if status_filter:
match_filter["agent_status"] = status_filter
if discipline_filter:
match_filter["discipline"] = discipline_filter
pipeline = [
{"$match": match_filter},
{"$sort": {sort_field: -1}},
{"$limit": limit},
{
"$project": {
"agent_name": 1,
"agent_status": 1,
"discipline": 1,
"total_messages": 1,
"total_tokens": 1,
"last_used": 1,
}
},
]
results = await agents_collection.aggregate(pipeline).to_list(length=None)
for r in results:
r["_id"] = str(r["_id"])
return results
async def get_recently_active_agents(limit: int = 10, status_filter: str = None, discipline_filter: str = None):
"""Return recently active agents sorted by last_used"""
match_filter = {"last_used": {"$exists": True, "$ne": None}}
if status_filter:
match_filter["agent_status"] = status_filter
if discipline_filter:
match_filter["discipline"] = discipline_filter
pipeline = [
{"$match": match_filter},
{"$sort": {"last_used": -1}},
{"$limit": limit},
{
"$project": {
"agent_name": 1,
"agent_status": 1,
"discipline": 1,
"total_messages": 1,
"total_tokens": 1,
"last_used": 1,
}
},
]
results = await agents_collection.aggregate(pipeline).to_list(length=None)
for r in results:
r["_id"] = str(r["_id"])
return results
async def update_agent(agent_id: str, update_data: dict, user_id: str = None, admin_user_info: dict = None, last_edited_by: str = None):
try:
filter_query = {"_id": ObjectId(agent_id)}

View file

@ -22,6 +22,7 @@ async def ensure_indexes():
[("agent_id", 1), ("user_id", 1)],
unique=True
)
await agents_collection.create_index([("verification_status", 1)])
print("Database indexes ensured successfully")
except Exception as e:
print(f"Warning: Failed to create indexes: {e}")

246
main.py
View file

@ -19,6 +19,7 @@ from fastapi import Header
import csv
import io
import json
import asyncio
load_dotenv()
@ -162,6 +163,14 @@ async def require_admin(current_user: dict = Depends(get_current_user_from_cooki
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
async def require_admin_or_readonly(current_user: dict = Depends(get_current_user_from_cookie)):
"""Require admin or readonly_admin access"""
if current_user.get("is_admin"):
return current_user
if current_user.get("role") == "readonly_admin":
return current_user
raise HTTPException(status_code=403, detail="Admin access required")
def sanitize_metadata(metadata: dict) -> dict[str, str]:
"""
Sanitize agent metadata to ensure all values are strings.
@ -228,6 +237,12 @@ def create_agent_response(agent: dict) -> models.AiAgentResponse:
discipline=agent.get("discipline"),
rating=agent.get("rating"),
rating_count=agent.get("rating_count"),
client=agent.get("client"),
client_name=agent.get("client_name"),
studio_name=agent.get("studio_name"),
verification_status=agent.get("verification_status"),
verified_by=agent.get("verified_by"),
verified_date=agent.get("verified_date"),
created_by=agent["created_by"],
# Usage tracking fields
usage_timeline=agent.get("usage_timeline"),
@ -290,6 +305,9 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate)
"agent_metadata": collector_data.metadata,
"url": collector_data.url,
"discipline": discipline,
"client": collector_data.client,
"client_name": collector_data.client_name,
"studio_name": collector_data.studio_name,
# Usage tracking fields
"usage_timeline": usage_timeline,
"conversation_count": collector_data.conversation_count,
@ -310,7 +328,8 @@ async def me(current_user: dict = Depends(get_current_user)):
"email": current_user["email"],
"full_name": current_user.get("full_name"),
"is_active": current_user["is_active"],
"is_admin": current_user["is_admin"]
"is_admin": current_user["is_admin"],
"role": current_user.get("role", "admin" if current_user["is_admin"] else "user"),
}
@app.on_event("startup")
@ -324,6 +343,23 @@ async def startup_event():
if count > 0:
print(f"Pencil Agents migration: updated {count} agent(s)")
# Start daily digest scheduler
try:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
digest_hour = int(os.getenv("DAILY_DIGEST_HOUR", "7"))
scheduler = AsyncIOScheduler()
scheduler.add_job(
notifications.send_daily_agent_digest,
'cron',
hour=digest_hour,
minute=0,
id='daily_agent_digest',
)
scheduler.start()
print(f"Daily digest scheduler started (runs at {digest_hour}:00)")
except Exception as e:
print(f"Warning: Failed to start daily digest scheduler: {e}")
# HTML Routes
@app.get("/")
async def home(request: Request):
@ -344,7 +380,7 @@ async def home(request: Request):
current_user = await get_current_user_optional(request)
if current_user:
# Redirect logged-in users appropriately
if current_user.get("is_admin"):
if current_user.get("is_admin") or current_user.get("role") == "readonly_admin":
return RedirectResponse(url=get_app_url("admin"), status_code=303)
else:
return RedirectResponse(url=get_app_url("agent-management"), status_code=303)
@ -441,9 +477,9 @@ async def login_form(
# Create token (you can store this in session/cookie in a real app)
token = auth.create_access_token({"sub": str(user["_id"])})
# Check if user is admin
if user.get("is_admin"):
# Admin goes to admin dashboard
# Check if user is admin or readonly_admin
if user.get("is_admin") or user.get("role") == "readonly_admin":
# Admin / readonly_admin goes to admin dashboard
response = RedirectResponse(url=get_app_url("admin"), status_code=303)
else:
# Regular user goes to all agents page
@ -589,6 +625,9 @@ async def agent_register_form(
agent_tool: str = Form(...),
agent_description: str = Form(None),
agent_purpose: str = Form(None),
client: str = Form(...),
client_name: str = Form(None),
studio_name: str = Form(None),
agent_version: str = Form(None),
agent_status: str = Form("Development"),
agent_location: str = Form(None),
@ -606,9 +645,15 @@ async def agent_register_form(
current_user = await get_current_user_optional(request)
if not current_user:
return RedirectResponse(url=get_app_url("login"), status_code=303)
user_id = str(current_user["_id"])
# Validate client_name is provided when client is Yes
if client.lower() == "yes" and not client_name:
context = get_template_context(request, current_user)
context["error"] = "Client Name is required when Client is set to Yes."
return templates.TemplateResponse("agent_register.html", context)
# Prepare agent data
agent_data = {
"agent_name": agent_name,
@ -621,8 +666,15 @@ async def agent_register_form(
"agent_department": agent_department,
"agent_contact_person": agent_contact_person,
"discipline": discipline,
"client": client.lower(),
"studio_name": studio_name,
}
# Handle client-specific fields
if client.lower() == "yes":
agent_data["client_name"] = client_name
agent_data["verification_status"] = "needs_verification"
# Process tags, userbase, and capabilities (convert comma-separated to lists)
if agent_tags:
agent_data["agent_tags"] = [tag.strip() for tag in agent_tags.split(',') if tag.strip()]
@ -630,7 +682,7 @@ async def agent_register_form(
agent_data["agent_userbase"] = [user.strip() for user in agent_userbase.split(',') if user.strip()]
if agent_capabilities:
agent_data["agent_capabilities"] = [cap.strip() for cap in agent_capabilities.split(',') if cap.strip()]
# Handle Quality Audit - only admins can set it to True
if quality_audit_status and current_user.get("is_admin"):
# Admin is setting quality audit to true
@ -639,32 +691,43 @@ async def agent_register_form(
agent_data["quality_audit_updated_by"] = user_id
agent_data["quality_audit_updated_at"] = datetime.utcnow().isoformat()
agent_data["quality_audit_updated_by_name"] = current_user.get("full_name", current_user.get("email"))
# Validate Risk Factor when Quality Audit is checked
if risk_factor is None or not (1 <= risk_factor <= 5):
context = get_template_context(request, current_user)
context["error"] = "Risk Factor (1-5) is required when Quality Audit is checked."
return templates.TemplateResponse("agent_register.html", context)
agent_data["risk_factor"] = risk_factor
else:
# Non-admin or quality audit not checked
agent_data["quality_audit_status"] = False
agent_data["risk_factor"] = None
# Remove None values
agent_data = {k: v for k, v in agent_data.items() if v is not None}
# Create agent in database
created_agent = await crud.create_agent(agent_data, user_id)
# Send client agent notification if client is Yes (non-blocking)
if client.lower() == "yes":
try:
notification_data = {
**agent_data,
"created_by_email": current_user.get("email", "Unknown"),
}
notifications.send_client_agent_notification(notification_data)
except Exception as notify_err:
print(f"Client agent notification failed: {notify_err}")
# Redirect to agent management with success message
from urllib.parse import quote
success_msg = quote(f"Agent '{agent_name}' registered successfully!")
redirect_url = get_app_url(f"agent-management?success={success_msg}")
print(f"DEBUG: Redirecting to: {redirect_url}") # Debug line
return RedirectResponse(
url=redirect_url,
url=redirect_url,
status_code=303
)
@ -745,7 +808,9 @@ async def agent_management_page(request: Request, view: Optional[str] = Query(No
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request):
current_user = await get_current_user_optional(request)
if not current_user or not current_user.get("is_admin"):
if not current_user:
return RedirectResponse(url=get_app_url("login"), status_code=303)
if not current_user.get("is_admin") and current_user.get("role") != "readonly_admin":
return RedirectResponse(url=get_app_url("login"), status_code=303)
# Get statistics
@ -1002,7 +1067,7 @@ async def toggle_admin_view(request: Request, current_user: dict = Depends(get_c
# Admin endpoints
@app.get("/api/admin/users", response_model=List[models.UserResponse])
async def get_all_users(current_user: dict = Depends(require_admin)):
async def get_all_users(current_user: dict = Depends(require_admin_or_readonly)):
users = await crud.get_all_users()
return [
@ -1011,6 +1076,7 @@ async def get_all_users(current_user: dict = Depends(require_admin)):
full_name=user.get("full_name"),
is_active=user["is_active"],
is_admin=user["is_admin"],
role=user.get("role", "admin" if user.get("is_admin") else "user"),
auth_provider=user.get("auth_provider", "local")
) for user in users
]
@ -1033,6 +1099,7 @@ async def admin_create_user(
full_name=created_user.get("full_name"),
is_active=created_user["is_active"],
is_admin=created_user["is_admin"],
role=created_user.get("role", "admin" if created_user["is_admin"] else "user"),
auth_provider=created_user.get("auth_provider", "local")
)
except ValueError as e:
@ -1047,6 +1114,24 @@ async def update_user(email: str, user_update: models.UserUpdate, current_user:
# Update the user
update_data = user_update.model_dump(exclude_unset=True)
# Sync role and is_admin fields
if "role" in update_data:
role = update_data["role"]
if role == "admin":
update_data["is_admin"] = True
elif role == "readonly_admin":
update_data["is_admin"] = False
elif role == "user":
update_data["is_admin"] = False
elif "is_admin" in update_data:
if update_data["is_admin"]:
update_data["role"] = "admin"
else:
# Only reset to user if not already readonly_admin
if existing_user.get("role") != "readonly_admin":
update_data["role"] = "user"
updated_user = await crud.update_user(str(existing_user["_id"]), update_data)
if not updated_user:
@ -1057,6 +1142,7 @@ async def update_user(email: str, user_update: models.UserUpdate, current_user:
full_name=updated_user.get("full_name"),
is_active=updated_user["is_active"],
is_admin=updated_user["is_admin"],
role=updated_user.get("role", "admin" if updated_user["is_admin"] else "user"),
auth_provider=updated_user.get("auth_provider", "local")
)
@ -1109,8 +1195,132 @@ async def change_password(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/admin/agents/pending-verification")
async def get_pending_verification(current_user: dict = Depends(require_admin_or_readonly)):
"""Get agents pending verification"""
from database import agents_collection as ac
cursor = ac.find(
{"verification_status": {"$in": ["needs_verification", "verified"]}}
).sort("created_at", -1)
agents = await cursor.to_list(length=None)
result = []
for agent in agents:
# Resolve creator email
created_by = agent.get("created_by", "")
created_by_email = created_by
if created_by and created_by != "agent_collector_api":
try:
from bson import ObjectId
creator = await crud.get_user_by_id(created_by)
if creator:
created_by_email = creator.get("email", created_by)
except Exception:
pass
result.append({
"agent_id": str(agent["_id"]),
"agent_name": agent.get("agent_name"),
"client_name": agent.get("client_name"),
"studio_name": agent.get("studio_name"),
"created_by": created_by_email,
"created_at": agent["created_at"].isoformat() if agent.get("created_at") else None,
"verification_status": agent.get("verification_status"),
"verified_by": agent.get("verified_by"),
"verified_date": agent.get("verified_date"),
})
return result
@app.put("/api/admin/agents/{agent_id}/verify")
async def verify_agent(agent_id: str, current_user: dict = Depends(require_admin)):
"""Approve/verify an agent (admin only)"""
from bson import ObjectId
from database import agents_collection as ac
agent = await crud.get_agent_by_id(agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
result = await ac.update_one(
{"_id": ObjectId(agent_id)},
{"$set": {
"verification_status": "verified",
"verified_by": current_user.get("email", str(current_user["_id"])),
"verified_date": datetime.utcnow().isoformat(),
"updated_at": datetime.utcnow(),
}}
)
if result.modified_count:
return {"message": "Agent verified successfully"}
raise HTTPException(status_code=500, detail="Failed to verify agent")
@app.post("/api/admin/digest/send")
async def trigger_daily_digest(current_user: dict = Depends(require_admin)):
"""Manually trigger the daily agent digest email"""
try:
await notifications.send_daily_agent_digest()
return {"message": "Daily digest sent successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to send digest: {str(e)}")
@app.get("/api/admin/analytics")
async def get_admin_analytics(
status: Optional[str] = None,
discipline: Optional[str] = None,
days: int = 90,
current_user: dict = Depends(require_admin_or_readonly),
):
"""Get analytics data for admin dashboard"""
(
summary,
status_breakdown,
discipline_breakdown,
usage_timeline,
top_by_messages,
top_by_tokens,
recently_active,
all_users,
) = await asyncio.gather(
crud.get_analytics_summary(status_filter=status, discipline_filter=discipline),
crud.get_agent_stats(discipline_filter=discipline),
crud.get_discipline_breakdown(status_filter=status),
crud.get_aggregated_usage_timeline(days, status_filter=status, discipline_filter=discipline),
crud.get_top_agents("total_messages", 5, status_filter=status, discipline_filter=discipline),
crud.get_top_agents("total_tokens", 5, status_filter=status, discipline_filter=discipline),
crud.get_recently_active_agents(10, status_filter=status, discipline_filter=discipline),
crud.get_all_users(),
)
# Rating distribution: count agents in each star bucket (1-5)
all_agents = await crud.get_all_agents(status_filter=status)
filtered_agents = all_agents
if discipline:
filtered_agents = [a for a in all_agents if a.get("discipline") == discipline]
rating_dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
for agent in filtered_agents:
r = agent.get("rating")
if r is not None:
bucket = max(1, min(5, round(r)))
rating_dist[bucket] += 1
total_users = len(all_users)
admin_users = sum(1 for u in all_users if u.get("is_admin"))
active_users = sum(1 for u in all_users if u.get("is_active"))
return {
"summary": summary,
"status_breakdown": status_breakdown,
"discipline_breakdown": discipline_breakdown,
"usage_timeline": usage_timeline,
"top_by_messages": top_by_messages,
"top_by_tokens": top_by_tokens,
"recently_active": recently_active,
"rating_distribution": rating_dist,
"total_users": total_users,
"admin_users": admin_users,
"active_users": active_users,
"total_agents": len(filtered_agents),
}
@app.get("/api/admin/agents", response_model=List[models.AiAgentResponse])
async def get_all_agents_admin(current_user: dict = Depends(require_admin)):
async def get_all_agents_admin(current_user: dict = Depends(require_admin_or_readonly)):
agents = await crud.get_all_agents()
return [

View file

@ -53,12 +53,14 @@ class UserResponse(BaseModel):
full_name: Optional[str] = None
is_active: bool
is_admin: bool
role: Optional[str] = "user"
auth_provider: Optional[str] = "local"
class UserUpdate(BaseModel):
full_name: Optional[str] = None
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
role: Optional[str] = Field(default=None, pattern="^(user|admin|readonly_admin)$")
class Token(BaseModel):
access_token: str
@ -102,6 +104,9 @@ class AiAgentCreate(BaseModel):
last_edited_by: Optional[str] = None
discipline: Optional[str] = None
rating: Optional[float] = Field(default=None, ge=1, le=5)
client: Optional[str] = None
client_name: Optional[str] = None
studio_name: Optional[str] = None
class AiAgentResponse(BaseModel):
agent_id: str
@ -130,6 +135,12 @@ class AiAgentResponse(BaseModel):
discipline: Optional[str] = None
rating: Optional[float] = None
rating_count: Optional[int] = None
client: Optional[str] = None
client_name: Optional[str] = None
studio_name: Optional[str] = None
verification_status: Optional[str] = None
verified_by: Optional[str] = None
verified_date: Optional[str] = None
created_by: str
# Usage tracking fields (new)
@ -162,6 +173,9 @@ class AgentCollectorCreate(BaseModel):
metadata: Optional[dict] = None
url: Optional[str] = None
discipline: Optional[str] = None
client: Optional[str] = None
client_name: Optional[str] = None
studio_name: Optional[str] = None
# Usage tracking fields (new)
usage_timeline: Optional[List[UsageTimelineEntry]] = None

View file

@ -1,7 +1,7 @@
import os
import requests
from datetime import datetime, timedelta
from database import notifications_collection, users_collection
from database import notifications_collection, users_collection, agents_collection
def is_mailgun_configured() -> bool:
@ -105,3 +105,164 @@ async def check_and_notify_threshold(agent_name: str, total_tokens: int):
"success": success,
"sent_at": datetime.utcnow(),
})
def build_client_agent_email(agent_data: dict) -> str:
"""Build HTML email body for a client agent creation notification."""
return f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #6f42c1, #5a32a3); padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="color: white; margin: 0;">Client Agent Created</h2>
</div>
<div style="padding: 20px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<p>A new client-facing agent has been created and requires verification.</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Agent Name</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_name', 'N/A')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Description</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_description', 'N/A')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Purpose</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_purpose', 'N/A')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Client</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">Yes</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Client Name</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('client_name', 'N/A')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Studio Name</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('studio_name', 'N/A')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Tool</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_tool', 'N/A')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Created By</td>
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('created_by_email', 'N/A')}</td>
</tr>
</table>
<p style="color: #666; font-size: 0.9em;">
Please review this agent in AgentHub.
</p>
</div>
</body>
</html>
"""
def send_client_agent_notification(agent_data: dict):
"""Send email notification when a client-facing agent is created. Non-blocking."""
if not is_mailgun_configured():
return
notify_emails_str = os.getenv("CLIENT_AGENT_NOTIFY_EMAILS", "")
if not notify_emails_str:
return
to_emails = [e.strip() for e in notify_emails_str.split(",") if e.strip()]
if not to_emails:
return
try:
subject = "Client Agent Created"
html_body = build_client_agent_email(agent_data)
send_mailgun_email(to_emails, subject, html_body)
except Exception as e:
print(f"Failed to send client agent notification: {e}")
def build_daily_digest_email(agents: list) -> str:
"""Build HTML email body for the daily agent digest."""
rows = ""
for i, agent in enumerate(agents, 1):
rows += f"""
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 10px; vertical-align: top; font-weight: bold; color: #333;">{i}.</td>
<td style="padding: 10px;">
<strong>{agent.get('agent_name', 'N/A')}</strong><br>
<span style="color: #555;">Purpose: {agent.get('agent_purpose', 'N/A')}</span><br>
<span style="color: #555;">Description: {agent.get('agent_description', 'N/A')}</span><br>
<span style="color: #888; font-size: 0.9em;">Created by: {agent.get('created_by_email', 'N/A')}</span>
</td>
</tr>"""
return f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #2d6a4f, #40916c); padding: 20px; border-radius: 8px 8px 0 0;">
<h2 style="color: white; margin: 0;">Agents Created Last 24 Hours</h2>
</div>
<div style="padding: 20px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<p>{len(agents)} agent(s) created in the last 24 hours:</p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
{rows}
</table>
<p style="color: #666; font-size: 0.9em;">
View full details in the AgentHub admin dashboard.
</p>
</div>
</body>
</html>
"""
async def send_daily_agent_digest():
"""Send daily digest of agents created in the last 24 hours to all admins."""
if not is_mailgun_configured():
print("Daily digest: Mailgun not configured, skipping.")
return
cutoff = datetime.utcnow() - timedelta(hours=24)
# Find agents created in last 24 hours
cursor = agents_collection.find(
{"created_at": {"$gte": cutoff}},
{"agent_name": 1, "agent_purpose": 1, "agent_description": 1, "created_by": 1}
).sort("created_at", -1)
agents = await cursor.to_list(length=None)
if not agents:
print("Daily digest: No agents created in last 24 hours, skipping.")
return
# Resolve creator emails
for agent in agents:
created_by = agent.get("created_by", "")
if created_by == "agent_collector_api":
agent["created_by_email"] = "Agent Collector API"
else:
try:
from bson import ObjectId
user = await users_collection.find_one({"_id": ObjectId(created_by)}, {"email": 1})
agent["created_by_email"] = user["email"] if user else created_by
except Exception:
agent["created_by_email"] = created_by
# Get admin emails
admin_cursor = users_collection.find(
{"is_admin": True, "is_active": True},
{"email": 1},
)
admin_emails = [doc["email"] async for doc in admin_cursor]
if not admin_emails:
print("Daily digest: No admin emails found, skipping.")
return
subject = "Agents Created Last 24 Hours"
html_body = build_daily_digest_email(agents)
try:
success = send_mailgun_email(admin_emails, subject, html_body)
print(f"Daily digest: Sent to {len(admin_emails)} admins, success={success}")
except Exception as e:
print(f"Daily digest: Failed to send: {e}")

View file

@ -38,3 +38,4 @@ uvicorn==0.35.0
msal==1.26.0
itsdangerous==2.2.0
cryptography==41.0.7
apscheduler>=3.10.0

View file

@ -25,48 +25,58 @@
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-lg-3 col-md-6 mb-3">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div class="row mb-4 align-items-stretch">
<div class="col-lg-2 col-md-4 mb-3 d-flex">
<div class="stat-card w-100">
<div class="stat-icon"><i class="fas fa-users"></i></div>
<div class="stat-info">
<h3 id="totalUsers">0</h3>
<p>Total Users</p>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="stat-card admin-card">
<div class="stat-icon">
<i class="fas fa-user-shield"></i>
</div>
<div class="stat-info">
<h3 id="adminUsers">0</h3>
<p>Admin Users</p>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="stat-card agent-card">
<div class="stat-icon">
<i class="fas fa-robot"></i>
</div>
<div class="col-lg-2 col-md-4 mb-3 d-flex">
<div class="stat-card agent-card w-100">
<div class="stat-icon"><i class="fas fa-robot"></i></div>
<div class="stat-info">
<h3 id="totalAgents">0</h3>
<p>Total Agents</p>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="stat-card active-card">
<div class="stat-icon">
<i class="fas fa-user-check"></i>
</div>
<div class="col-lg-2 col-md-4 mb-3 d-flex">
<div class="stat-card w-100">
<div class="stat-icon"><i class="fas fa-envelope"></i></div>
<div class="stat-info">
<h3 id="activeUsers">0</h3>
<p>Active Users</p>
<h3 id="totalMessages">0</h3>
<p>Total Messages</p>
</div>
</div>
</div>
<div class="col-lg-2 col-md-4 mb-3 d-flex">
<div class="stat-card w-100">
<div class="stat-icon"><i class="fas fa-coins"></i></div>
<div class="stat-info">
<h3 id="totalTokens">0</h3>
<p>Total Tokens</p>
</div>
</div>
</div>
<div class="col-lg-2 col-md-4 mb-3 d-flex">
<div class="stat-card w-100">
<div class="stat-icon"><i class="fas fa-comments"></i></div>
<div class="stat-info">
<h3 id="totalConversations">0</h3>
<p>Conversations</p>
</div>
</div>
</div>
<div class="col-lg-2 col-md-4 mb-3 d-flex">
<div class="stat-card w-100">
<div class="stat-icon"><i class="fas fa-user-friends"></i></div>
<div class="stat-info">
<h3 id="totalUniqueUsers">0</h3>
<p>Agent-User Sessions</p>
</div>
</div>
</div>
@ -79,7 +89,12 @@
<div class="card-header bg-white">
<ul class="nav nav-tabs card-header-tabs" id="adminTabs">
<li class="nav-item">
<a class="nav-link active" id="users-tab" data-bs-toggle="tab" href="#users">
<a class="nav-link active" id="analytics-tab" data-bs-toggle="tab" href="#analytics">
<i class="fas fa-chart-line me-2"></i>Analytics
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="users-tab" data-bs-toggle="tab" href="#users">
<i class="fas fa-users me-2"></i>Users Management
</a>
</li>
@ -88,16 +103,169 @@
<i class="fas fa-robot me-2"></i>Agents Management
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="verification-tab" data-bs-toggle="tab" href="#verification">
<i class="fas fa-check-circle me-2"></i>Verification
<span class="badge bg-warning ms-1" id="verificationPendingBadge" style="display:none;">0</span>
</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="adminTabsContent">
<!-- Analytics Tab -->
<div class="tab-pane fade show active" id="analytics">
<!-- Analytics Filter Bar -->
<div class="row mb-3">
<div class="col-12">
<div class="d-flex flex-wrap align-items-center gap-2 p-3 bg-light rounded">
<span class="fw-semibold text-muted me-1"><i class="fas fa-filter me-1"></i>Filters:</span>
<select id="analyticsStatusFilter" class="form-select form-select-sm" style="width:auto;min-width:150px;">
<option value="">All Statuses</option>
<option value="Active">Active</option>
<option value="Development">Development</option>
<option value="Inactive">Inactive</option>
<option value="Deprecated">Deprecated</option>
</select>
<select id="analyticsDisciplineFilter" class="form-select form-select-sm" style="width:auto;min-width:200px;">
<option value="">All Disciplines</option>
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight including delivery</option>
<option value="Optimization">Optimization</option>
<option value="Back Office including operations">Back Office including operations</option>
<option value="Pencil Agents">Pencil Agents</option>
</select>
<select id="analyticsTimeRange" class="form-select form-select-sm" style="width:auto;min-width:140px;">
<option value="30">Last 30 Days</option>
<option value="60">Last 60 Days</option>
<option value="90" selected>Last 90 Days</option>
<option value="180">Last 180 Days</option>
</select>
<button id="analyticsApplyBtn" class="btn btn-sm btn-primary">
<i class="fas fa-check me-1"></i>Apply
</button>
</div>
</div>
</div>
<div class="row mb-4">
<!-- Usage Over Time Chart -->
<div class="col-lg-8 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-chart-area me-2"></i>System Usage Over Time <span id="timeRangeLabel">(Last 90 Days)</span></h6></div>
<div class="card-body">
<div id="usageTimelineEmpty" class="text-center text-muted py-5" style="display:none;">No usage timeline data available</div>
<canvas id="usageTimelineChart"></canvas>
</div>
</div>
</div>
<!-- Token Breakdown Doughnut -->
<div class="col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-coins me-2"></i>Token Breakdown</h6></div>
<div class="card-body d-flex align-items-center justify-content-center">
<div id="tokenBreakdownEmpty" class="text-center text-muted py-5" style="display:none;">No token data available</div>
<canvas id="tokenBreakdownChart"></canvas>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<!-- Status Breakdown Doughnut -->
<div class="col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-signal me-2"></i>Agents by Status</h6></div>
<div class="card-body d-flex align-items-center justify-content-center">
<div id="statusBreakdownEmpty" class="text-center text-muted py-5" style="display:none;">No agent data available</div>
<canvas id="statusBreakdownChart"></canvas>
</div>
</div>
</div>
<!-- Discipline Breakdown Doughnut -->
<div class="col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-layer-group me-2"></i>Agents by Discipline</h6></div>
<div class="card-body d-flex align-items-center justify-content-center">
<div id="disciplineBreakdownEmpty" class="text-center text-muted py-5" style="display:none;">No discipline data available</div>
<canvas id="disciplineBreakdownChart"></canvas>
</div>
</div>
</div>
<!-- Rating Distribution Bar Chart -->
<div class="col-lg-4 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-star me-2"></i>Rating Distribution</h6></div>
<div class="card-body d-flex align-items-center justify-content-center">
<div id="ratingDistEmpty" class="text-center text-muted py-5" style="display:none;">No rating data available</div>
<canvas id="ratingDistChart"></canvas>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<!-- Top 5 Agents by Messages -->
<div class="col-lg-6 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-trophy me-2"></i>Top 5 Agents by Messages</h6></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 analytics-table">
<thead><tr><th>#</th><th>Agent</th><th>Status</th><th>Messages</th></tr></thead>
<tbody id="topMessagesTbody">
<tr><td colspan="4" class="text-center text-muted py-3">No data</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Top 5 Agents by Tokens -->
<div class="col-lg-6 mb-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-trophy me-2"></i>Top 5 Agents by Tokens</h6></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 analytics-table">
<thead><tr><th>#</th><th>Agent</th><th>Status</th><th>Tokens</th></tr></thead>
<tbody id="topTokensTbody">
<tr><td colspan="4" class="text-center text-muted py-3">No data</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<!-- Recently Active Agents -->
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-clock me-2"></i>Recently Active Agents</h6></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 analytics-table">
<thead><tr><th>Agent</th><th>Status</th><th>Discipline</th><th>Messages</th><th>Tokens</th><th>Last Used</th></tr></thead>
<tbody id="recentlyActiveTbody">
<tr><td colspan="6" class="text-center text-muted py-3">No recently active agents</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Users Management Tab -->
<div class="tab-pane fade show active" id="users">
<div class="tab-pane fade" id="users">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Users Management</h5>
<div class="d-flex gap-2">
<button class="btn btn-success" onclick="showCreateUserModal()">
<button class="btn btn-success admin-write-action" onclick="showCreateUserModal()">
<i class="fas fa-user-plus me-2"></i>Create User
</button>
<div class="input-group" style="width: 300px;">
@ -180,6 +348,45 @@
</table>
</div>
</div>
<!-- Verification Tab -->
<div class="tab-pane fade" id="verification">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="fas fa-check-circle me-2"></i>Agent Verification</h5>
<div class="d-flex gap-2">
<select id="verificationFilter" class="form-select form-select-sm" style="width:auto;" onchange="filterVerificationAgents()">
<option value="all">All</option>
<option value="needs_verification" selected>Needs Verification</option>
<option value="verified">Verified</option>
</select>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Agent Name</th>
<th>Client Name</th>
<th>Studio</th>
<th>Created By</th>
<th>Date Created</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody id="verificationTableBody">
<tr>
<td colspan="7" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@ -222,13 +429,22 @@
</div>
</div>
<div class="mb-3">
<div class="form-check">
<div class="form-check" style="display:none;">
<input class="form-check-input" type="checkbox" id="editUserIsAdmin">
<label class="form-check-label" for="editUserIsAdmin">
User has admin privileges
</label>
</div>
</div>
<div class="mb-3">
<label for="editUserRole" class="form-label">Role</label>
<select class="form-select" id="editUserRole">
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="readonly_admin">Read-Only Admin</option>
</select>
<div class="form-text">Read-Only Admin can view the admin dashboard but cannot make changes.</div>
</div>
<!-- Password Reset Section (only for local users) -->
<div class="mb-3" id="passwordResetSection" style="display: none;">
<hr>
@ -544,11 +760,11 @@
.stat-card {
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
border-radius: 16px;
padding: 1.5rem;
padding: 1.25rem;
color: white;
display: flex;
align-items: center;
margin-bottom: 1rem;
min-height: 90px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
@ -565,21 +781,31 @@
}
.stat-icon {
font-size: 2.5rem;
margin-right: 1rem;
font-size: 2rem;
margin-right: 0.75rem;
opacity: 0.8;
flex-shrink: 0;
}
.stat-info {
min-width: 0;
}
.stat-info h3 {
margin: 0;
font-size: 2rem;
font-size: 1.5rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-info p {
margin: 0;
opacity: 0.9;
font-size: 0.9rem;
font-size: 0.75rem;
white-space: normal;
line-height: 1.2;
}
.user-avatar-sm {
@ -719,17 +945,48 @@
color: white !important;
}
.analytics-table th {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 1rem;
}
.analytics-table td {
padding: 0.6rem 1rem;
vertical-align: middle;
}
.rank-badge {
width: 26px;
height: 26px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.75rem;
color: #fff;
background: linear-gradient(135deg, #f3ae3e 0%, #e8983e 100%);
}
@media (max-width: 768px) {
.stat-card {
text-align: center;
flex-direction: column;
}
.stat-icon {
margin-right: 0;
margin-bottom: 0.5rem;
}
.stat-info h3 {
font-size: 1.2rem;
}
.stat-info p {
font-size: 0.75rem;
}
.table-responsive {
font-size: 0.875rem;
}
@ -739,14 +996,24 @@
<script>
let allUsers = [];
let allAgents = [];
let verificationAgents = [];
const isReadonlyAdmin = {% if current_user and current_user.get('role') == 'readonly_admin' %}true{% else %}false{% endif %};
const isFullAdmin = {% if current_user and current_user.get('is_admin') %}true{% else %}false{% endif %};
document.addEventListener('DOMContentLoaded', function() {
loadAdminData();
loadAnalytics();
loadVerificationData();
setupEventListeners();
// Hide write-action buttons for readonly admins
if (isReadonlyAdmin && !isFullAdmin) {
document.querySelectorAll('.admin-write-action').forEach(el => el.style.display = 'none');
}
});
function setupEventListeners() {
document.getElementById('refreshBtn').addEventListener('click', loadAdminData);
document.getElementById('refreshBtn').addEventListener('click', () => { loadAdminData(); loadAnalytics(); loadVerificationData(); });
document.getElementById('userSearch').addEventListener('input', filterUsers);
document.getElementById('agentSearch').addEventListener('input', filterAgents);
document.getElementById('agentStatusFilter').addEventListener('change', filterAgents);
@ -755,6 +1022,7 @@ function setupEventListeners() {
document.getElementById('editAgentForm').addEventListener('submit', handleEditAgentSubmit);
document.getElementById('createUserForm').addEventListener('submit', handleCreateUserSubmit);
document.getElementById('resetPasswordForm').addEventListener('submit', handleResetPasswordSubmit);
document.getElementById('analyticsApplyBtn').addEventListener('click', loadAnalytics);
}
async function loadAdminData() {
@ -797,10 +1065,25 @@ async function loadAdminData() {
}
function updateStatistics() {
document.getElementById('totalUsers').textContent = allUsers.length;
document.getElementById('adminUsers').textContent = allUsers.filter(u => u.is_admin).length;
document.getElementById('totalAgents').textContent = allAgents.length;
document.getElementById('activeUsers').textContent = allUsers.filter(u => u.is_active).length;
// These two are still fed from the users/agents lists for the management tabs
// The stat cards are now primarily updated by loadAnalytics()
// Keep this as a fallback for basic counts from the existing load
}
function updateAnalyticsStats(data) {
document.getElementById('totalUsers').textContent = formatNumber(data.total_users || 0);
document.getElementById('totalAgents').textContent = formatNumber(data.total_agents || 0);
document.getElementById('totalMessages').textContent = formatNumber(data.summary.total_messages || 0);
document.getElementById('totalTokens').textContent = formatNumber(data.summary.total_tokens || 0);
document.getElementById('totalConversations').textContent = formatNumber(data.summary.conversation_count || 0);
document.getElementById('totalUniqueUsers').textContent = formatNumber(data.summary.unique_users || 0);
}
function formatNumber(n) {
if (n >= 1000000000) return (n / 1000000000).toFixed(1) + 'B';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
}
function displayUsers(users) {
@ -834,9 +1117,12 @@ function displayUsers(users) {
</span>
</td>
<td>
<span class="badge ${user.is_admin ? 'bg-danger' : 'bg-primary'}">
${user.is_admin ? 'Admin' : 'User'}
</span>
${(() => {
const role = user.role || (user.is_admin ? 'admin' : 'user');
if (role === 'admin') return '<span class="badge bg-danger">Admin</span>';
if (role === 'readonly_admin') return '<span class="badge bg-warning text-dark">Read-Only Admin</span>';
return '<span class="badge bg-primary">User</span>';
})()}
</td>
<td>
<span class="badge ${user.is_active ? 'bg-success' : 'bg-secondary'}">
@ -850,10 +1136,10 @@ function displayUsers(users) {
<button class="btn btn-outline-primary btn-sm me-1" onclick="viewUserDetails('${user.email}')" title="View">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-outline-warning btn-sm me-1" onclick="editUser('${user.email}')" title="Edit">
<button class="btn btn-outline-warning btn-sm me-1 admin-write-action" onclick="editUser('${user.email}')" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="toggleUserStatus('${user.email}')" title="${user.is_active ? 'Deactivate' : 'Activate'}">
<button class="btn btn-outline-secondary btn-sm admin-write-action" onclick="toggleUserStatus('${user.email}')" title="${user.is_active ? 'Deactivate' : 'Activate'}">
<i class="fas fa-${user.is_active ? 'ban' : 'check'}"></i>
</button>
</td>
@ -881,7 +1167,10 @@ function displayAgents(agents) {
return `
<tr>
<td>
<div class="fw-medium">${agent.agent_name}</div>
<div class="fw-medium">${agent.agent_name}
${agent.verification_status === 'needs_verification' ? ' <span class="badge bg-warning text-dark" style="font-size:0.65em;">Needs Verification</span>' : ''}
${agent.verification_status === 'verified' ? ' <span class="badge bg-success" style="font-size:0.65em;">Verified</span>' : ''}
</div>
<small class="text-muted">${agent.agent_description || 'No description'}</small>
</td>
<td>
@ -903,10 +1192,10 @@ function displayAgents(agents) {
<button class="btn btn-outline-primary btn-sm me-1" onclick="viewAgentDetails('${agent.agent_id}')">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-outline-warning btn-sm me-1" onclick="editAgentAdmin('${agent.agent_id}')">
<button class="btn btn-outline-warning btn-sm me-1 admin-write-action" onclick="editAgentAdmin('${agent.agent_id}')">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteAgentAdmin('${agent.agent_id}')">
<button class="btn btn-outline-danger btn-sm admin-write-action" onclick="deleteAgentAdmin('${agent.agent_id}')">
<i class="fas fa-trash"></i>
</button>
</td>
@ -997,12 +1286,16 @@ function editUser(email) {
const authProvider = user.auth_provider || 'local';
const isLocalAuth = authProvider === 'local';
// Determine role from user data
const userRole = user.role || (user.is_admin ? 'admin' : 'user');
// Populate the edit form
document.getElementById('editUserId').value = user.email;
document.getElementById('editUserEmail').value = user.email;
document.getElementById('editUserFullName').value = user.full_name || '';
document.getElementById('editUserIsActive').checked = user.is_active;
document.getElementById('editUserIsAdmin').checked = user.is_admin;
document.getElementById('editUserRole').value = userRole;
document.getElementById('editUserAuthProvider').value = authProvider;
// Update auth display
@ -1043,7 +1336,8 @@ async function toggleUserStatus(email) {
body: JSON.stringify({
full_name: user.full_name,
is_active: newStatus,
is_admin: user.is_admin
is_admin: user.is_admin,
role: user.role || (user.is_admin ? 'admin' : 'user')
})
});
@ -1124,12 +1418,13 @@ function showError(message) {
async function handleEditUserSubmit(e) {
e.preventDefault();
const email = document.getElementById('editUserId').value;
const fullName = document.getElementById('editUserFullName').value;
const isActive = document.getElementById('editUserIsActive').checked;
const isAdmin = document.getElementById('editUserIsAdmin').checked;
const role = document.getElementById('editUserRole').value;
const isAdmin = (role === 'admin');
try {
const response = await fetch(`{{ base_path }}/api/admin/users/${encodeURIComponent(email)}`, {
method: 'PUT',
@ -1140,7 +1435,8 @@ async function handleEditUserSubmit(e) {
body: JSON.stringify({
full_name: fullName,
is_active: isActive,
is_admin: isAdmin
is_admin: isAdmin,
role: role
})
});
@ -1431,6 +1727,363 @@ async function handleResetPasswordSubmit(e) {
}
}
// ===================== Analytics =====================
let analyticsCharts = {};
function destroyChart(key) {
if (analyticsCharts[key]) {
analyticsCharts[key].destroy();
analyticsCharts[key] = null;
}
}
const STATUS_COLORS = {
'Active': '#28a745',
'Development': '#ffc107',
'Inactive': '#dc3545',
'Deprecated': '#6c757d'
};
const DISCIPLINE_COLORS = [
'#f3ae3e', '#4e79a7', '#59a14f', '#e15759', '#76b7b2', '#edc949', '#af7aa1'
];
async function loadAnalytics() {
try {
const status = document.getElementById('analyticsStatusFilter').value;
const discipline = document.getElementById('analyticsDisciplineFilter').value;
const days = document.getElementById('analyticsTimeRange').value;
const params = new URLSearchParams();
if (status) params.set('status', status);
if (discipline) params.set('discipline', discipline);
params.set('days', days);
const qs = params.toString();
const url = '{{ base_path }}/api/admin/analytics' + (qs ? '?' + qs : '');
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
// Update time range label in chart header
const label = document.getElementById('timeRangeLabel');
if (label) label.textContent = '(Last ' + days + ' Days)';
updateAnalyticsStats(data);
renderUsageTimeline(data.usage_timeline);
renderTokenBreakdown(data.summary.prompt_tokens, data.summary.completion_tokens);
renderStatusBreakdown(data.status_breakdown);
renderDisciplineBreakdown(data.discipline_breakdown);
renderRatingDistribution(data.rating_distribution);
renderTopTable('topMessagesTbody', data.top_by_messages, 'total_messages');
renderTopTable('topTokensTbody', data.top_by_tokens, 'total_tokens');
renderRecentlyActive(data.recently_active);
} catch (err) {
console.error('Failed to load analytics:', err);
}
}
function renderUsageTimeline(timeline) {
destroyChart('usageTimeline');
const canvas = document.getElementById('usageTimelineChart');
const emptyEl = document.getElementById('usageTimelineEmpty');
if (!timeline || timeline.length === 0) {
canvas.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
canvas.style.display = 'block';
emptyEl.style.display = 'none';
const labels = timeline.map(t => t.date);
const messages = timeline.map(t => t.message_count);
const tokens = timeline.map(t => t.token_count);
const hasTokens = tokens.some(v => v > 0);
const datasets = [{
label: 'Messages',
data: messages,
borderColor: '#4e79a7',
backgroundColor: 'rgba(78,121,167,0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y'
}];
const scales = {
y: { beginAtZero: true, title: { display: true, text: 'Messages' }, position: 'left' },
x: { ticks: { maxTicksLimit: 12 } }
};
if (hasTokens) {
datasets.push({
label: 'Tokens',
data: tokens,
borderColor: '#f3ae3e',
backgroundColor: 'rgba(243,174,62,0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y1'
});
scales.y1 = { beginAtZero: true, title: { display: true, text: 'Tokens' }, position: 'right', grid: { drawOnChartArea: false } };
}
analyticsCharts.usageTimeline = new Chart(canvas, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'top' } },
scales
}
});
}
function renderTokenBreakdown(prompt, completion) {
destroyChart('tokenBreakdown');
const canvas = document.getElementById('tokenBreakdownChart');
const emptyEl = document.getElementById('tokenBreakdownEmpty');
if (!prompt && !completion) {
canvas.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
canvas.style.display = 'block';
emptyEl.style.display = 'none';
analyticsCharts.tokenBreakdown = new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Prompt (Input)', 'Completion (Output)'],
datasets: [{ data: [prompt, completion], backgroundColor: ['#4e79a7', '#f3ae3e'] }]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom' } }
}
});
}
function renderStatusBreakdown(statusData) {
destroyChart('statusBreakdown');
const canvas = document.getElementById('statusBreakdownChart');
const emptyEl = document.getElementById('statusBreakdownEmpty');
const entries = Object.entries(statusData || {}).filter(([, v]) => v > 0);
if (entries.length === 0) {
canvas.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
canvas.style.display = 'block';
emptyEl.style.display = 'none';
analyticsCharts.statusBreakdown = new Chart(canvas, {
type: 'doughnut',
data: {
labels: entries.map(([k]) => k || 'Unknown'),
datasets: [{
data: entries.map(([, v]) => v),
backgroundColor: entries.map(([k]) => STATUS_COLORS[k] || '#adb5bd')
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom' } }
}
});
}
function renderDisciplineBreakdown(disciplineData) {
destroyChart('disciplineBreakdown');
const canvas = document.getElementById('disciplineBreakdownChart');
const emptyEl = document.getElementById('disciplineBreakdownEmpty');
const entries = Object.entries(disciplineData || {}).filter(([, v]) => v > 0);
if (entries.length === 0) {
canvas.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
canvas.style.display = 'block';
emptyEl.style.display = 'none';
analyticsCharts.disciplineBreakdown = new Chart(canvas, {
type: 'doughnut',
data: {
labels: entries.map(([k]) => k),
datasets: [{
data: entries.map(([, v]) => v),
backgroundColor: entries.map((_, i) => DISCIPLINE_COLORS[i % DISCIPLINE_COLORS.length])
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom' } }
}
});
}
function renderRatingDistribution(ratingData) {
destroyChart('ratingDist');
const canvas = document.getElementById('ratingDistChart');
const emptyEl = document.getElementById('ratingDistEmpty');
const values = [1, 2, 3, 4, 5].map(k => (ratingData || {})[k] || 0);
if (values.every(v => v === 0)) {
canvas.style.display = 'none';
emptyEl.style.display = 'block';
return;
}
canvas.style.display = 'block';
emptyEl.style.display = 'none';
analyticsCharts.ratingDist = new Chart(canvas, {
type: 'bar',
data: {
labels: ['1 Star', '2 Stars', '3 Stars', '4 Stars', '5 Stars'],
datasets: [{
label: 'Agents',
data: values,
backgroundColor: '#f3ae3e',
borderRadius: 6
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
}
}
});
}
function renderTopTable(tbodyId, agents, field) {
const tbody = document.getElementById(tbodyId);
if (!agents || agents.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">No data</td></tr>';
return;
}
tbody.innerHTML = agents.map((a, i) => `
<tr>
<td><span class="rank-badge">${i + 1}</span></td>
<td class="fw-medium">${a.agent_name}</td>
<td><span class="badge status-${a.agent_status || 'Development'}">${a.agent_status || 'Development'}</span></td>
<td class="fw-bold">${formatNumber(a[field] || 0)}</td>
</tr>
`).join('');
}
function renderRecentlyActive(agents) {
const tbody = document.getElementById('recentlyActiveTbody');
if (!agents || agents.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No recently active agents</td></tr>';
return;
}
tbody.innerHTML = agents.map(a => `
<tr>
<td class="fw-medium">${a.agent_name}</td>
<td><span class="badge status-${a.agent_status || 'Development'}">${a.agent_status || 'Development'}</span></td>
<td>${a.discipline || '<span class="text-muted">-</span>'}</td>
<td>${formatNumber(a.total_messages || 0)}</td>
<td>${formatNumber(a.total_tokens || 0)}</td>
<td>${formatDate(a.last_used)}</td>
</tr>
`).join('');
}
// ===== Verification Tab Functions =====
async function loadVerificationData() {
try {
const response = await fetch('{{ base_path }}/api/admin/agents/pending-verification', {
credentials: 'include'
});
if (!response.ok) return;
verificationAgents = await response.json();
// Update badge count
const pending = verificationAgents.filter(a => a.verification_status === 'needs_verification');
const badge = document.getElementById('verificationPendingBadge');
if (pending.length > 0) {
badge.textContent = pending.length;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
filterVerificationAgents();
} catch (error) {
console.error('Error loading verification data:', error);
}
}
function filterVerificationAgents() {
const filter = document.getElementById('verificationFilter').value;
let filtered = verificationAgents;
if (filter !== 'all') {
filtered = verificationAgents.filter(a => a.verification_status === filter);
}
displayVerificationAgents(filtered);
}
function displayVerificationAgents(agents) {
const tbody = document.getElementById('verificationTableBody');
if (!agents.length) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No agents to display</td></tr>';
return;
}
tbody.innerHTML = agents.map(agent => {
const statusBadge = agent.verification_status === 'verified'
? '<span class="badge bg-success">Verified</span>'
: '<span class="badge bg-warning text-dark">Needs Verification</span>';
const actionBtn = agent.verification_status === 'needs_verification' && !isReadonlyAdmin
? `<button class="btn btn-success btn-sm admin-write-action" onclick="approveAgent('${agent.agent_id}')">
<i class="fas fa-check me-1"></i>Approve
</button>`
: (agent.verified_by ? `<small class="text-muted">by ${agent.verified_by}<br>${formatDate(agent.verified_date)}</small>` : '');
return `<tr>
<td class="fw-medium">${agent.agent_name || 'N/A'}</td>
<td>${agent.client_name || 'N/A'}</td>
<td>${agent.studio_name || 'N/A'}</td>
<td>${agent.created_by || 'N/A'}</td>
<td>${formatDate(agent.created_at)}</td>
<td>${statusBadge}</td>
<td>${actionBtn}</td>
</tr>`;
}).join('');
}
async function approveAgent(agentId) {
if (!confirm('Are you sure you want to verify this agent?')) return;
try {
const response = await fetch(`{{ base_path }}/api/admin/agents/${agentId}/verify`, {
method: 'PUT',
credentials: 'include'
});
if (response.ok) {
showSuccess('Agent verified successfully');
await loadVerificationData();
await loadAdminData();
} else {
const error = await response.json();
showError(error.detail || 'Failed to verify agent');
}
} catch (error) {
showError('Failed to verify agent');
}
}
</script>
{% endblock %}

View file

@ -27,34 +27,21 @@
<label for="agentName" class="form-label">
<i class="fas fa-tag me-2"></i>Agent Name *
</label>
<input type="text"
name="agent_name"
class="form-control form-control-lg"
id="agentName"
<input type="text"
name="agent_name"
class="form-control form-control-lg"
id="agentName"
placeholder="Enter agent name"
required>
</div>
<div class="mb-4">
<label for="agentTool" class="form-label">
<i class="fas fa-tools me-2"></i>Tool *
</label>
<input type="text"
name="agent_tool"
class="form-control form-control-lg"
id="agentTool"
placeholder="e.g., chat-sandbox (LibreChat), Copilot, Custom"
required>
<div class="form-text">The platform or environment where this agent operates</div>
</div>
<div class="mb-4">
<label for="agentDescription" class="form-label">
<i class="fas fa-align-left me-2"></i>Description
</label>
<textarea name="agent_description"
class="form-control"
id="agentDescription"
<textarea name="agent_description"
class="form-control"
id="agentDescription"
rows="3"
maxlength="300"
placeholder="Describe what this agent does"></textarea>
@ -65,24 +52,72 @@
<label for="agentPurpose" class="form-label">
<i class="fas fa-bullseye me-2"></i>Purpose
</label>
<input type="text"
name="agent_purpose"
class="form-control"
id="agentPurpose"
<input type="text"
name="agent_purpose"
class="form-control"
id="agentPurpose"
maxlength="200"
placeholder="What is the main purpose of this agent?">
<div class="form-text">Maximum 200 characters</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<label for="agentClient" class="form-label">
<i class="fas fa-handshake me-2"></i>Client *
</label>
<select name="client" class="form-select" id="agentClient" required onchange="toggleClientName()">
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
<div class="form-text">Is this agent for a client?</div>
</div>
<div class="col-md-6 mb-4" id="clientNameSection" style="display: none;">
<label for="agentClientName" class="form-label">
<i class="fas fa-building me-2"></i>Client Name *
</label>
<input type="text"
name="client_name"
class="form-control"
id="agentClientName"
placeholder="Enter client name">
</div>
</div>
<div class="mb-4">
<label for="agentStudioName" class="form-label">
<i class="fas fa-film me-2"></i>Studio Name
</label>
<input type="text"
name="studio_name"
class="form-control"
id="agentStudioName"
placeholder="Enter studio name (optional)">
</div>
<div class="mb-4">
<label for="agentTool" class="form-label">
<i class="fas fa-tools me-2"></i>Tool *
</label>
<input type="text"
name="agent_tool"
class="form-control form-control-lg"
id="agentTool"
placeholder="e.g., chat-sandbox (LibreChat), Copilot, Custom"
required>
<div class="form-text">The platform or environment where this agent operates</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<label for="agentVersion" class="form-label">
<i class="fas fa-code-branch me-2"></i>Version
</label>
<input type="text"
name="agent_version"
class="form-control"
id="agentVersion"
<input type="text"
name="agent_version"
class="form-control"
id="agentVersion"
placeholder="e.g., 1.0.0">
</div>
<div class="col-md-6 mb-4">
@ -301,11 +336,26 @@
// Simple form validation and duplicate submission prevention
let formSubmitted = false;
function toggleClientName() {
const clientSelect = document.getElementById('agentClient');
const clientNameSection = document.getElementById('clientNameSection');
const clientNameInput = document.getElementById('agentClientName');
if (clientSelect.value === 'yes') {
clientNameSection.style.display = 'block';
clientNameInput.required = true;
} else {
clientNameSection.style.display = 'none';
clientNameInput.required = false;
clientNameInput.value = '';
}
}
function toggleRiskFactor() {
const qualityAuditStatus = document.getElementById('qualityAuditStatus');
const riskFactorSection = document.getElementById('riskFactorSection');
const riskFactor = document.getElementById('riskFactor');
if (qualityAuditStatus.checked) {
riskFactorSection.style.display = 'block';
riskFactor.required = true;
@ -319,21 +369,37 @@ function toggleRiskFactor() {
document.getElementById('agentForm').addEventListener('submit', function(e) {
const agentName = document.getElementById('agentName').value.trim();
const agentTool = document.getElementById('agentTool').value.trim();
const clientSelect = document.getElementById('agentClient');
const qualityAuditStatus = document.getElementById('qualityAuditStatus');
const riskFactor = document.getElementById('riskFactor');
if (!agentName) {
e.preventDefault();
alert('Agent name is required!');
return false;
}
if (!agentTool) {
e.preventDefault();
alert('Agent tool is required!');
return false;
}
if (!clientSelect.value) {
e.preventDefault();
alert('Client selection is required!');
return false;
}
if (clientSelect.value === 'yes') {
const clientName = document.getElementById('agentClientName').value.trim();
if (!clientName) {
e.preventDefault();
alert('Client Name is required when Client is set to Yes!');
return false;
}
}
const discipline = document.getElementById('agentDiscipline').value;
if (!discipline) {
e.preventDefault();
@ -347,22 +413,22 @@ document.getElementById('agentForm').addEventListener('submit', function(e) {
alert('Risk Factor is required when Quality Audit is checked!');
return false;
}
// Prevent duplicate submissions
if (formSubmitted) {
e.preventDefault();
return false;
}
formSubmitted = true;
// Disable the submit button to prevent multiple clicks
const submitBtn = e.target.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Registering...';
}
return true;
});
</script>