diff --git a/.sqlx/query-add2e5cf104f82da6a5e3796d0b8439abc71a73ba896dc9a36553c9aee2c4801.json b/.sqlx/query-add2e5cf104f82da6a5e3796d0b8439abc71a73ba896dc9a36553c9aee2c4801.json new file mode 100644 index 0000000..c0304ff --- /dev/null +++ b/.sqlx/query-add2e5cf104f82da6a5e3796d0b8439abc71a73ba896dc9a36553c9aee2c4801.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM deliveries WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "add2e5cf104f82da6a5e3796d0b8439abc71a73ba896dc9a36553c9aee2c4801" +} diff --git a/README.md b/README.md index 9181023..5bbadb5 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,28 @@ Requires `Authorization: Bearer `. | `POST` | `/api/admin/models` | Create model with optional HF link | | `PUT` | `/api/admin/models/{id}` | Update a model's HF link | | `DELETE` | `/api/admin/models/{id}` | Delete a model and its votes | +| `DELETE` | `/api/admin/deliveries/{id}` | Delete an abliterated model delivery | | `DELETE` | `/api/admin/prune` | Delete all zero-vote models | | `POST` | `/api/admin/deliver` | Record an abliterated model delivery | +### Admin UI + +Available at `/admin/login`. Enter the `ADMIN_TOKEN` to access the dashboard. + +| Path | Description | +|------|-------------| +| `/admin/login` | Login form (public) | +| `/admin` | Dashboard — manage models and deliveries (requires auth) | + +The admin dashboard provides: +- **Stats overview** — model count, total votes, deliveries +- **Model management** — view leaderboard, delete models, prune zero-vote models +- **Delivery management** — record new deliveries, view/delete existing ones +- **Auto-refresh** — model list refreshes every 10s, deliveries every 30s + +Auth uses `localStorage`: the token is stored client-side after login and sent as +a `Bearer` header on every subsequent request. + ### Rate Limits - **Voting:** 3 votes per IP per hour, 1 vote per model per UUID @@ -70,7 +89,7 @@ Requires `Authorization: Bearer `. ```bash make help # list all targets make run # run locally (ADMIN_TOKEN=dev-secret) -make test # run all 81 tests +make test # run all tests make lint # clippy make ci # lint + test make sqlx-prepare # regenerate offline query cache @@ -85,6 +104,6 @@ make clean # remove artifacts make test ``` -81 tests covering queries, extractors, HF validation, DB migrations, all API endpoints, rate limiting, auth, pagination, and edge cases. +88 tests covering queries, extractors, HF validation, DB migrations, all API endpoints (including admin delivery deletion), rate limiting, auth, pagination, and edge cases. See [DEVELOPMENT.md](DEVELOPMENT.md) for test architecture, security hardening details, and development workflow. diff --git a/src/lib.rs b/src/lib.rs index 4e0d1f0..56f4def 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,11 @@ pub fn app(state: AppState) -> Router { .route("/model-select", get(pages::model_select)) .route("/vote", post(pages::vote_page)) .route("/add-model", post(pages::create_model_page)) + // Admin UI + .route("/admin", get(admin::admin_page)) + .route("/admin/login", get(admin::admin_login_page).post(admin::admin_login)) + .route("/admin/models", get(admin::admin_models)) + .route("/admin/deliveries", get(admin::admin_deliveries)) // JSON API .route("/api/leaderboard", get(api::get_leaderboard)) .route("/api/deliveries", get(api::get_deliveries)) @@ -41,6 +46,10 @@ pub fn app(state: AppState) -> Router { ) .route("/api/admin/prune", delete(api::admin_prune_models)) .route("/api/admin/deliver", post(api::admin_deliver)) + .route( + "/api/admin/deliveries/{delivery_id}", + delete(api::admin_delete_delivery), + ) // Static files .nest_service("/static", ServeDir::new("static")) .with_state(state) diff --git a/src/queries.rs b/src/queries.rs index 05852d5..ab81526 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -349,6 +349,19 @@ pub async fn prune_zero_vote_models( Ok(result.rows_affected() as i64) } +/// Delete a delivery by ID. +/// +/// Returns `true` if the delivery existed and was deleted. +pub async fn delete_delivery( + pool: &sqlx::SqlitePool, + delivery_id: i64, +) -> Result { + let result = sqlx::query!("DELETE FROM deliveries WHERE id = ?", delivery_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -593,4 +606,31 @@ mod tests { let pruned = prune_zero_vote_models(&pool).await.unwrap(); assert_eq!(pruned, 0); } + + #[sqlx::test] + async fn delete_delivery_removes_row(pool: SqlitePool) { + insert_delivery( + &pool, "test/del-delivery", 5, "https://hf.co/test", &None, + 0.02, 1, 128, + ) + .await + .unwrap(); + + let deliveries = fetch_deliveries(&pool, 100, 0, DeliverySort::Date).await.unwrap(); + let ours = deliveries.iter().find(|d| d.model_id == "test/del-delivery").unwrap(); + let id = ours.id; + + let deleted = delete_delivery(&pool, id).await.unwrap(); + assert!(deleted); + + // Verify it's gone + let deliveries = fetch_deliveries(&pool, 100, 0, DeliverySort::Date).await.unwrap(); + assert!(deliveries.iter().all(|d| d.model_id != "test/del-delivery")); + } + + #[sqlx::test] + async fn delete_delivery_nonexistent_returns_false(pool: SqlitePool) { + let deleted = delete_delivery(&pool, 999999).await.unwrap(); + assert!(!deleted); + } } diff --git a/src/routes/admin.rs b/src/routes/admin.rs new file mode 100644 index 0000000..976f092 --- /dev/null +++ b/src/routes/admin.rs @@ -0,0 +1,128 @@ +use askama::Template; +use axum::extract::State; +use axum::response::Html; + +use crate::error::AppError; +use crate::extractors::AdminAuth; +use crate::models::{DeliveryEntry, LeaderboardEntry}; +use crate::queries; +use crate::state::AppState; + +// --------------------------------------------------------------------------- +// Template types +// --------------------------------------------------------------------------- + +#[derive(Template)] +#[template(path = "admin.html")] +struct AdminTemplate { + models: Vec, + deliveries: Vec, + total_deliveries: i64, + total_votes: i64, +} + +#[derive(Template)] +#[template(path = "admin_models.html")] +struct AdminModelsTemplate { + models: Vec, +} + +#[derive(Template)] +#[template(path = "admin_deliveries.html")] +struct AdminDeliveriesTemplate { + deliveries: Vec, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// GET /admin +/// +/// Admin dashboard. Requires `AdminAuth` extractor (401 if unauthenticated). +pub async fn admin_page( + State(state): State, + _auth: AdminAuth, +) -> Result, AppError> { + let models = queries::fetch_leaderboard(&state.db).await?; + let total_deliveries = queries::count_deliveries(&state.db).await?; + let deliveries = queries::fetch_deliveries( + &state.db, 100, 0, queries::DeliverySort::Date, + ).await?; + let total_votes = models.iter().map(|m| m.vote_count).sum::(); + + let tpl = AdminTemplate { + models, + deliveries, + total_deliveries, + total_votes, + }; + Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?)) +} + +/// GET /admin/login +/// +/// Public login form. No authentication required. +pub async fn admin_login_page() -> Result, AppError> { + let tpl = AdminLoginTemplate; + Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?)) +} + +/// POST /admin/login +/// +/// Validates the admin token via `AdminAuth` extractor. +/// Returns the full dashboard HTML. Client stores token in localStorage +/// for subsequent HTMX requests. +pub async fn admin_login( + State(state): State, + _auth: AdminAuth, +) -> Result, AppError> { + let models = queries::fetch_leaderboard(&state.db).await?; + let total_deliveries = queries::count_deliveries(&state.db).await?; + let deliveries = queries::fetch_deliveries( + &state.db, 100, 0, queries::DeliverySort::Date, + ).await?; + let total_votes = models.iter().map(|m| m.vote_count).sum::(); + + let tpl = AdminTemplate { + models, + deliveries, + total_deliveries, + total_votes, + }; + Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?)) +} + +/// GET /admin/models +/// +/// HTMX partial: model rows for the admin table. Requires `AdminAuth`. +pub async fn admin_models( + State(state): State, + _auth: AdminAuth, +) -> Result, AppError> { + let models = queries::fetch_leaderboard(&state.db).await?; + let tpl = AdminModelsTemplate { models }; + Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?)) +} + +/// GET /admin/deliveries +/// +/// HTMX partial: delivery rows for the admin table. Requires `AdminAuth`. +pub async fn admin_deliveries( + State(state): State, + _auth: AdminAuth, +) -> Result, AppError> { + let deliveries = queries::fetch_deliveries( + &state.db, 100, 0, queries::DeliverySort::Date, + ).await?; + let tpl = AdminDeliveriesTemplate { deliveries }; + Ok(Html(tpl.render().map_err(|e| AppError::Internal(e.to_string()))?)) +} + +// --------------------------------------------------------------------------- +// Login form template +// --------------------------------------------------------------------------- + +#[derive(Template)] +#[template(path = "admin_login.html")] +struct AdminLoginTemplate; \ No newline at end of file diff --git a/src/routes/api.rs b/src/routes/api.rs index f0f8c9f..0eb8b23 100644 --- a/src/routes/api.rs +++ b/src/routes/api.rs @@ -419,3 +419,23 @@ pub async fn admin_prune_models( "deleted": deleted, }))) } + +/// DELETE /api/admin/deliveries/:delivery_id +/// +/// Admin endpoint to delete an abliterated model delivery. +pub async fn admin_delete_delivery( + State(state): State, + Path(delivery_id): Path, + _auth: AdminAuth, +) -> Result, AppError> { + let deleted = queries::delete_delivery(&state.db, delivery_id).await?; + if !deleted { + return Err(AppError::NotFound("Delivery not found".to_string())); + } + + tracing::info!(delivery_id = delivery_id, "Admin deleted delivery"); + Ok(Json(serde_json::json!({ + "message": "Delivery deleted successfully", + "delivery_id": delivery_id, + }))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 22bfe40..23c6f33 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,2 +1,3 @@ +pub mod admin; pub mod api; pub mod pages; diff --git a/static/style.css b/static/style.css index 2bac29e..c351a78 100644 --- a/static/style.css +++ b/static/style.css @@ -394,6 +394,37 @@ body { @keyframes toast-in { from { opacity: 0; transform: translateY(-12px) scale(0.96); } } @keyframes toast-out { to { opacity: 0; transform: translateY(-8px) scale(0.97); } } +/* -- Admin actions (inline in card header) -- */ +.admin-actions { + display: flex; align-items: center; gap: 0.375rem; +} + +/* -- Admin model list -- */ +.admin-model-list { list-style: none; } +.admin-model-row { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.75rem 1.25rem; transition: background 0.15s; +} +.admin-model-row:hover { background: var(--surface-2); } +.admin-model-row + .admin-model-row { border-top: 1px solid var(--border-subtle); } +.admin-model-rank { + width: 28px; height: 28px; border-radius: var(--radius-sm); + display: grid; place-items: center; font-size: 0.75rem; font-weight: 700; + flex-shrink: 0; background: var(--surface-2); color: var(--text-3); +} +.admin-model-info { flex: 1; min-width: 0; } +.admin-model-name { font-weight: 600; font-size: 0.875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.admin-model-hf { font-size: 0.7rem; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } +.admin-model-hf a { color: var(--accent); text-decoration: none; } +.admin-model-hf a:hover { text-decoration: underline; } +.admin-model-votes { + font-variant-numeric: tabular-nums; font-weight: 700; font-size: 0.85rem; + color: var(--accent); flex-shrink: 0; min-width: 60px; text-align: right; +} +.admin-model-votes small { font-size: 0.6rem; font-weight: 500; color: var(--text-3); margin-left: 2px; } +.btn-danger { background: var(--red-dim); color: var(--red); border: 1px solid rgba(248,113,113,0.15); } +.btn-danger:hover { background: rgba(248,113,113,0.15); } + /* ================================================================ Mobile responsive (<= 540px) ================================================================ */ @@ -441,4 +472,12 @@ body { /* Toasts full-width on mobile */ #toasts { left: 1rem; right: 1rem; } .toast { max-width: none; } + + /* Admin compact on mobile */ + .card-head { flex-direction: column; align-items: stretch; gap: 0.75rem; } + .admin-actions { justify-content: flex-end; } + .admin-model-row { padding: 0.625rem 1rem; gap: 0.5rem; } + .admin-model-rank { width: 24px; height: 24px; font-size: 0.68rem; } + .admin-model-name { font-size: 0.78rem; } + .admin-model-votes { min-width: 50px; font-size: 0.78rem; } } diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..22b0bd2 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,230 @@ + + + + + + Admin — Votablit + + + + + + + + +
+
+

Votablit Admin

+ +
+ +
+
{{ models.len() }}
Models
+
{{ total_votes }}
Votes
+
{{ total_deliveries }}
Delivered
+
+ +
+
+

Models

+
+ + +
+
+
+
+ {% include "admin_models.html" %} +
+
+
+ +
+
+

Deliveries

+
+
+
+ {% include "admin_deliveries.html" %} +
+
+
+
+ + + +
+ + + + \ No newline at end of file diff --git a/templates/admin_deliveries.html b/templates/admin_deliveries.html new file mode 100644 index 0000000..15ba193 --- /dev/null +++ b/templates/admin_deliveries.html @@ -0,0 +1,38 @@ +{% if deliveries.is_empty() %} +
+
📦
+

No deliveries

+

Abliterated models will appear here

+
+{% else %} +
+ + + + + + + + + + + + + + {% for d in deliveries %} + + + + + + + + + + {% endfor %} + +
ModelVotesKLRefusedDateHFAction
{{ d.model_id }}{{ d.vote_count }}{{ d.kl_divergence }}{{ d.refused }}/{{ d.total_prompts }}{{ d.delivered_at }} + +
+
+{% endif %} \ No newline at end of file diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..cd29836 --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,74 @@ + + + + + + Admin Login — Votablit + + + + + + + + +
+
+

Votablit Admin

+ +
+ +
+

Admin Login

+
+
+
+ + +
+ +
+

Token from ADMIN_TOKEN environment variable

+
+
+ +
+ + +
+ + \ No newline at end of file diff --git a/templates/admin_models.html b/templates/admin_models.html new file mode 100644 index 0000000..b3ee761 --- /dev/null +++ b/templates/admin_models.html @@ -0,0 +1,23 @@ +{% if models.is_empty() %} +
+
📋
+

No models

+

Models will appear here from the leaderboard

+
+{% else %} +
    + {% for model in models %} +
  • +
    {{ loop.index }}
    +
    +
    {{ model.model_id }}
    + {% if model.hf_link.is_some() %} + + {% endif %} +
    +
    {{ model.vote_count }} votes
    + +
  • + {% endfor %} +
+{% endif %} \ No newline at end of file diff --git a/tests/integration.rs b/tests/integration.rs index c075292..7fa2ac7 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -856,6 +856,79 @@ async fn admin_prune_when_no_zero_vote_models(pool: SqlitePool) { assert_eq!(json["deleted"], 0); } +// =================================================================== +// Admin delete delivery tests +// =================================================================== + +#[sqlx::test] +async fn admin_delete_delivery_removes_delivery(pool: SqlitePool) { + let mut app = test_app(pool); + + // Create a model, then deliver it + send( + &mut app, + json_post_with_auth( + "/api/admin/models", + r#"{"model_id": "del-delivery-test", "hf_link": ""}"#, + ), + ) + .await; + + send( + &mut app, + json_post_with_auth( + "/api/admin/deliver", + r#"{"model_id": "del-delivery-test", "hf_link": "https://hf.co/test", "kl_divergence": 0.01, "refused": 0, "total_prompts": 100}"#, + ), + ) + .await; + + // Find the delivery ID + let (_, body) = send(&mut app, get("/api/deliveries?per_page=100")).await; + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let initial_total = json["total"].as_i64().unwrap(); + let items = json["items"].as_array().unwrap(); + let ours = items.iter().find(|i| i["model_id"] == "del-delivery-test").unwrap(); + let delivery_id = ours["id"].as_i64().unwrap(); + + // Delete the delivery + let path = format!("/api/admin/deliveries/{delivery_id}"); + let (status, body) = send(&mut app, json_delete_with_auth(&path)).await; + assert_eq!(status, StatusCode::OK); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["delivery_id"], delivery_id); + + // Verify it's gone + let (_, body) = send(&mut app, get("/api/deliveries?per_page=100")).await; + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["total"].as_i64().unwrap(), initial_total - 1); + let items = json["items"].as_array().unwrap(); + assert!(items.iter().all(|i| i["model_id"] != "del-delivery-test")); +} + +#[sqlx::test] +async fn admin_delete_delivery_nonexistent_returns_404(pool: SqlitePool) { + let mut app = test_app(pool); + + let (status, body) = send(&mut app, json_delete_with_auth("/api/admin/deliveries/999999")).await; + assert_eq!(status, StatusCode::NOT_FOUND); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"].as_str().unwrap().contains("not found")); +} + +#[sqlx::test] +async fn admin_delete_delivery_unauthorized(pool: SqlitePool) { + let mut app = test_app(pool); + + let req = Request::builder() + .method("DELETE") + .uri("/api/admin/deliveries/1") + .body(Body::empty()) + .unwrap(); + let (status, _) = send(&mut app, req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + // =================================================================== // HuggingFace validation tests (use test_app_with_hf for real HF checks) // ===================================================================