Web dashboard step 3: overview, runs and articles pages
Fill in the dashboard read pages of the web dashboard plan (§9): the overview card set (last run from report_json, budget today per provider, ratings this week, unrated picks with the rating widget, jobs summary, config-on-disk warnings), the runs list and run detail (funnel, admission mix, preference state, timings, provider usage, warnings, top feeds, config diff against the previous non-dry run, near misses, and the candidates table with allow-listed filters/sorts and per-row signals), and the articles list and article detail (assessments including provider rejections, run history, neighbours and interests, embedding metadata, rating events, and the text explain). Dynamic list queries go through AssertSqlSafe with SQL built only from constants and allow-listed fragments; every user value is bound. Adds the shared signals-table, candidate-row and pager partials, a CSS block and the data-filter table behaviour in app.js, plus the step 3 handoff. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
# Step 3 handoff — dashboard reads: overview, runs, articles
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
- `src/web/dashboard/mod.rs`: the overview (§9.1) — last-run card built from
|
||||||
|
`runs.report_json` via `RunReport::info_block()` (legacy-counter fallback
|
||||||
|
when the report is missing or unparseable), budget-today `<meter>` per
|
||||||
|
referenced provider plus voyage from `db::provider_spend_for_utc_day(now)`,
|
||||||
|
ratings this week by label, "Unrated picks" (last three issues' picks with
|
||||||
|
no explicit event, each with the step 2 rating widget posting back to
|
||||||
|
`/dashboard`), a jobs summary read straight from the `jobs` table (active
|
||||||
|
rows plus the last five finished; the pages themselves are step 6), and the
|
||||||
|
`! ` lines of `Config::check_report`. Sparklines are a marked HTML comment
|
||||||
|
for step 6. The module also carries the helpers the read pages share:
|
||||||
|
`Pager`, `SignalsView` (the parsed `signals_json` behind
|
||||||
|
`_signals_table.html`), `allow_listed`, `like_pattern`, `Bind`/`bind_all`/
|
||||||
|
`dynamic_query`, formatting helpers, and the `STAGES`/`REASONS`/
|
||||||
|
`RETRIEVERS` allow-lists.
|
||||||
|
- `src/web/dashboard/runs.rs` (§9.2): `/dashboard/runs` with `?status=` and
|
||||||
|
pagination (50), and `/dashboard/runs/{id}` with header (issue link when the
|
||||||
|
issue exists, previous/next run by id), the funnel from `candidate_runs`
|
||||||
|
grouped by stage and reason, admission mix, preference state, timings,
|
||||||
|
provider usage, warnings (`#warnings` anchor used by the overview and the
|
||||||
|
list), top-20 feeds, the config diff (`flatten_json` + `config_diff`)
|
||||||
|
against the previous non-dry run that recorded a config, ten near misses via
|
||||||
|
`telemetry::near_misses`, and the candidates table (100 per page) with the
|
||||||
|
`stage`/`reason`/`admitted_by`/`q`/`flag` filters and `utility` (default)/
|
||||||
|
`rank`/`triage`/`quality`/`fit`/`title` sorts. Each row's title is a
|
||||||
|
`<details>` that expands `_signals_table.html`.
|
||||||
|
- `src/web/dashboard/articles.rs` (§9.3): `/dashboard/articles` (50 per page)
|
||||||
|
joining the best entry, the latest `candidate_runs` row through the
|
||||||
|
`MAX(run_id)` subquery (the index `idx_candidate_runs_article_run` is in
|
||||||
|
migration 0004 — confirmed), both assessments, the current explicit rating
|
||||||
|
and the latest publication; every listed filter and sort;
|
||||||
|
`/dashboard/articles/{id}` with the six blocks in the plan's order,
|
||||||
|
`provider_rejected` rows called out as such, run history rows expanding
|
||||||
|
their signals, neighbours/interests from the latest row, embedding metadata
|
||||||
|
only (never the vector), all rating events with source/user/note/value, the
|
||||||
|
rating widget with `show_note = true`, and "Explain (text)" wrapping
|
||||||
|
`telemetry::render_explain` verbatim in `<pre>`.
|
||||||
|
- Templates: `dashboard/overview.html`, `runs.html`, `run.html`,
|
||||||
|
`articles.html`, `article.html`, partials `_signals_table.html`,
|
||||||
|
`_candidate_row.html` and `dashboard/_pager.html`. Every table sits in
|
||||||
|
`.scroll-x`; badges per stage/reason/label/status; tables carry
|
||||||
|
`data-filter`.
|
||||||
|
- `app.css` / `app.js`: one appended block each (`/* step 3 … */`): cards,
|
||||||
|
filters, funnel bar, pager, signals details, diff colours, and the
|
||||||
|
filter-as-you-type behaviour for `table[data-filter]` (inserts a search
|
||||||
|
input before the table's `.scroll-x` wrapper, hides non-matching rows on
|
||||||
|
the current page only).
|
||||||
|
|
||||||
|
## Deviations and notes
|
||||||
|
|
||||||
|
- **sqlx 0.9 dynamic SQL.** `sqlx::query` only accepts `&'static str` or
|
||||||
|
`AssertSqlSafe`, so the list queries go through `dashboard::dynamic_query`,
|
||||||
|
which wraps the assembled string in `AssertSqlSafe`. The audit holds
|
||||||
|
because the string is built only from constants and allow-listed fragments
|
||||||
|
(`CANDIDATE_SORTS`/`ARTICLE_SORTS` map names to fixed `ORDER BY` text);
|
||||||
|
every user value is bound through `bind_all`. An unknown sort or filter
|
||||||
|
value falls back to the default / is dropped, never an error.
|
||||||
|
- **Funnel semantics.** `stage` records where a row stopped, so the bars
|
||||||
|
show the cumulative "reached" count (rows at this stage or a later one, so
|
||||||
|
the first bar is everything considered) with "stopped here" and the reason
|
||||||
|
breakdown beside it. Widths are `<svg width="N%">` children of `.funnel`
|
||||||
|
because the CSP (`style-src 'self'`) forbids inline `style` attributes;
|
||||||
|
the budget card uses `<meter>` for the same reason.
|
||||||
|
- **Prev/next run** links go to the neighbouring run ids overall (the
|
||||||
|
header already links the date's issue); a rerun of the same date is
|
||||||
|
therefore the immediate neighbour.
|
||||||
|
- **Extract method** is not shown on the article page: `db::article_from_row`
|
||||||
|
hard-codes `ExtractMethod::Miniflux` for every loaded article, so the value
|
||||||
|
would be meaningless. `excerpt_only` is shown instead. Source `kind`s render
|
||||||
|
via their `Debug` names (`Feed`, `Scour`, …).
|
||||||
|
- **`admitted_by` filter** is allow-listed against the six retriever names
|
||||||
|
and still applied as the plan's prefix `LIKE` on
|
||||||
|
`json_extract(admitted_by, '$[0]')`.
|
||||||
|
- The `_pagination.html` partial from step 1 (text only) was left alone; the
|
||||||
|
dashboard uses its own `dashboard/_pager.html` with prev/next links that
|
||||||
|
preserve the other query parameters.
|
||||||
|
- Only additive edits outside my files: two appended blocks in `app.css` and
|
||||||
|
`app.js`. No changes to `web/mod.rs`, `db.rs`, `rate.rs` or `telemetry.rs`
|
||||||
|
(a private `first_retriever` in telemetry was reimplemented as
|
||||||
|
`admitted_by_parts` rather than made `pub`).
|
||||||
|
|
||||||
|
## Left for later steps
|
||||||
|
|
||||||
|
- Step 6: overview sparklines (placeholder comment in `overview.html`), and
|
||||||
|
the `/dashboard/jobs` pages the overview's job links point at.
|
||||||
|
- Step 4: `/dashboard/ratings`, linked from the overview's ratings card.
|
||||||
|
- The overview's ratings-this-week card counts all explicit events in the
|
||||||
|
last seven days by label (a `cleared` is listed but not counted in the
|
||||||
|
total).
|
||||||
|
|
||||||
|
## Verification (outside the sandbox)
|
||||||
|
|
||||||
|
- `cargo fmt`: pass.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`: pass.
|
||||||
|
- `cargo test web::dashboard`: **11 passed, 0 failed**.
|
||||||
|
- `cargo test` (full suite): **387 lib tests passed, 0 failed**, plus every
|
||||||
|
integration test binary green (7, 2, 3, 4, 7, 9, 2), nothing skipped.
|
||||||
@@ -1,10 +1,932 @@
|
|||||||
//! Dashboard: articles pages. Filled in by web dashboard plan step 3.
|
//! Dashboard: articles list and article detail (dashboard plan §9.3).
|
||||||
|
//!
|
||||||
|
//! The list joins every article to its best entry, its **latest**
|
||||||
|
//! `candidate_runs` row (via `idx_candidate_runs_article_run`), both
|
||||||
|
//! assessments, the current explicit rating and the latest publication. The
|
||||||
|
//! detail page shows everything the system knows about one article, in the
|
||||||
|
//! order of §9.3, and wraps `telemetry::render_explain` verbatim in `<pre>`.
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::civil::Date;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Bind, Pager, REASONS, STAGES, SignalsView, admitted_by_parts, allow_listed, bind_all, db_err,
|
||||||
|
dynamic_query, fmt_opt, fmt_opt_int, fmt_stored_time, like_pattern, non_empty, page_number,
|
||||||
|
widget_label,
|
||||||
|
};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::curate::telemetry;
|
||||||
|
use crate::curate::triage::{PROVIDER_REJECTED, TRIAGE_KINDS};
|
||||||
|
use crate::db::Db;
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
use crate::types::ArticleId;
|
||||||
|
use crate::web::rate::RatingWidget;
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Html, Page, Pagination, WebError, take_flash};
|
||||||
|
|
||||||
|
const ARTICLES_PER_PAGE: u32 = 50;
|
||||||
|
|
||||||
/// Routes contributed by this page group (merged by `dashboard::router`).
|
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/dashboard/articles", get(list))
|
||||||
|
.route("/dashboard/articles/{id}", get(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Articles list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct ArticlesQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub feed: Option<String>,
|
||||||
|
pub stage: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub rated: Option<String>,
|
||||||
|
pub published: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub to: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const RATED: [&str; 6] = ["any", "loved", "good", "down", "cleared", "none"];
|
||||||
|
const PUBLISHED: [&str; 2] = ["yes", "no"];
|
||||||
|
|
||||||
|
const ARTICLE_SORTS: [(&str, &str); 7] = [
|
||||||
|
("first_seen", "x.first_seen DESC, x.id DESC"),
|
||||||
|
("utility", "x.utility DESC, x.id DESC"),
|
||||||
|
("quality", "x.quality DESC, x.id DESC"),
|
||||||
|
("fit", "x.fit DESC, x.id DESC"),
|
||||||
|
("triage", "x.triage DESC, x.id DESC"),
|
||||||
|
("words", "x.word_count DESC, x.id DESC"),
|
||||||
|
("title", "x.title COLLATE NOCASE ASC, x.id ASC"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Validated article filters; see [`CandidateFilters`](super::runs::CandidateFilters).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ArticleFilters {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub feed: Option<i64>,
|
||||||
|
pub stage: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub rated: Option<String>,
|
||||||
|
pub published: Option<String>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub to: Option<String>,
|
||||||
|
pub kind: Option<String>,
|
||||||
|
pub sort: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArticleFilters {
|
||||||
|
pub fn from_query(query: &ArticlesQuery) -> Self {
|
||||||
|
let owned = |value: Option<&str>| value.map(str::to_string);
|
||||||
|
let date = |value: Option<&str>| {
|
||||||
|
non_empty(value)
|
||||||
|
.and_then(|value| value.parse::<Date>().ok())
|
||||||
|
.map(|date| date.to_string())
|
||||||
|
};
|
||||||
|
let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec();
|
||||||
|
kinds.push(PROVIDER_REJECTED);
|
||||||
|
Self {
|
||||||
|
q: owned(non_empty(query.q.as_deref())),
|
||||||
|
feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::<i64>().ok()),
|
||||||
|
stage: owned(allow_listed(query.stage.as_deref(), &STAGES)),
|
||||||
|
reason: owned(allow_listed(query.reason.as_deref(), &REASONS)),
|
||||||
|
rated: owned(allow_listed(query.rated.as_deref(), &RATED)),
|
||||||
|
published: owned(allow_listed(query.published.as_deref(), &PUBLISHED)),
|
||||||
|
from: date(query.from.as_deref()),
|
||||||
|
to: date(query.to.as_deref()),
|
||||||
|
kind: owned(allow_listed(query.kind.as_deref(), &kinds)),
|
||||||
|
sort: ARTICLE_SORTS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| Some(*name) == query.sort.as_deref())
|
||||||
|
.map(|(name, _)| *name)
|
||||||
|
.unwrap_or(ARTICLE_SORTS[0].0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_by(&self) -> &'static str {
|
||||||
|
ARTICLE_SORTS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == self.sort)
|
||||||
|
.map(|(_, sql)| *sql)
|
||||||
|
.unwrap_or(ARTICLE_SORTS[0].1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn where_clauses(&self) -> (String, Vec<Bind>) {
|
||||||
|
let mut sql = String::new();
|
||||||
|
let mut binds = Vec::new();
|
||||||
|
if let Some(q) = &self.q {
|
||||||
|
sql.push_str(" AND (x.title LIKE ? ESCAPE '\\' OR x.canonical_url LIKE ? ESCAPE '\\')");
|
||||||
|
binds.push(Bind::Text(like_pattern(q)));
|
||||||
|
binds.push(Bind::Text(like_pattern(q)));
|
||||||
|
}
|
||||||
|
if let Some(feed) = self.feed {
|
||||||
|
sql.push_str(" AND x.feed_id = ?");
|
||||||
|
binds.push(Bind::Int(feed));
|
||||||
|
}
|
||||||
|
if let Some(stage) = &self.stage {
|
||||||
|
sql.push_str(" AND x.stage = ?");
|
||||||
|
binds.push(Bind::Text(stage.clone()));
|
||||||
|
}
|
||||||
|
if let Some(reason) = &self.reason {
|
||||||
|
sql.push_str(" AND x.excluded_reason = ?");
|
||||||
|
binds.push(Bind::Text(reason.clone()));
|
||||||
|
}
|
||||||
|
match self.rated.as_deref() {
|
||||||
|
Some("any") => sql.push_str(" AND x.rating IS NOT NULL AND x.rating != 'cleared'"),
|
||||||
|
Some("none") => sql.push_str(" AND x.rating IS NULL"),
|
||||||
|
Some(label @ ("loved" | "good" | "cleared")) => {
|
||||||
|
sql.push_str(" AND x.rating = ?");
|
||||||
|
binds.push(Bind::Text(label.to_string()));
|
||||||
|
}
|
||||||
|
Some("down") => sql.push_str(" AND x.rating = 'not_for_me'"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match self.published.as_deref() {
|
||||||
|
Some("yes") => sql.push_str(" AND x.published IS NOT NULL"),
|
||||||
|
Some("no") => sql.push_str(" AND x.published IS NULL"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(from) = &self.from {
|
||||||
|
sql.push_str(" AND substr(x.first_seen, 1, 10) >= ?");
|
||||||
|
binds.push(Bind::Text(from.clone()));
|
||||||
|
}
|
||||||
|
if let Some(to) = &self.to {
|
||||||
|
sql.push_str(" AND substr(x.first_seen, 1, 10) <= ?");
|
||||||
|
binds.push(Bind::Text(to.clone()));
|
||||||
|
}
|
||||||
|
if let Some(kind) = &self.kind {
|
||||||
|
sql.push_str(" AND x.triage_kind = ?");
|
||||||
|
binds.push(Bind::Text(kind.clone()));
|
||||||
|
}
|
||||||
|
(sql, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params(&self) -> Vec<(&'static str, Option<String>)> {
|
||||||
|
vec![
|
||||||
|
("q", self.q.clone()),
|
||||||
|
("feed", self.feed.map(|feed| feed.to_string())),
|
||||||
|
("stage", self.stage.clone()),
|
||||||
|
("reason", self.reason.clone()),
|
||||||
|
("rated", self.rated.clone()),
|
||||||
|
("published", self.published.clone()),
|
||||||
|
("from", self.from.clone()),
|
||||||
|
("to", self.to.clone()),
|
||||||
|
("kind", self.kind.clone()),
|
||||||
|
(
|
||||||
|
"sort",
|
||||||
|
(self.sort != ARTICLE_SORTS[0].0).then(|| self.sort.to_string()),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the articles table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ArticleListRow {
|
||||||
|
pub id: ArticleId,
|
||||||
|
pub href: String,
|
||||||
|
pub title: String,
|
||||||
|
pub feed_id: Option<i64>,
|
||||||
|
pub feed: String,
|
||||||
|
pub first_seen: String,
|
||||||
|
pub words: i64,
|
||||||
|
pub stage: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub run_id: Option<i64>,
|
||||||
|
pub run_date: Option<String>,
|
||||||
|
pub utility: String,
|
||||||
|
pub triage: String,
|
||||||
|
pub quality: String,
|
||||||
|
pub fit: String,
|
||||||
|
pub rating: Option<String>,
|
||||||
|
pub rating_class: &'static str,
|
||||||
|
pub published: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARTICLE_INNER: &str =
|
||||||
|
"SELECT a.id, COALESCE(a.title, '') AS title, a.canonical_url, a.first_seen,
|
||||||
|
a.word_count, e.feed_id, COALESCE(e.feed_title, '') AS feed_title,
|
||||||
|
l.run_id, l.stage, l.excluded_reason, l.utility, r.date AS run_date,
|
||||||
|
t.score AS triage, t.kind AS triage_kind, d.score AS quality, d.fit AS fit,
|
||||||
|
(SELECT re.label FROM rating_events re
|
||||||
|
WHERE re.article_id = a.id AND re.kind = 'explicit'
|
||||||
|
ORDER BY re.event_at DESC, re.id DESC LIMIT 1) AS rating,
|
||||||
|
(SELECT ia.issue_date FROM issue_articles ia
|
||||||
|
WHERE ia.article_id = a.id ORDER BY ia.issue_date DESC LIMIT 1) AS published
|
||||||
|
FROM articles a
|
||||||
|
LEFT JOIN entries e ON e.id = a.best_entry_id
|
||||||
|
LEFT JOIN candidate_runs l ON l.article_id = a.id
|
||||||
|
AND l.run_id = (SELECT MAX(c2.run_id) FROM candidate_runs c2
|
||||||
|
WHERE c2.article_id = a.id)
|
||||||
|
LEFT JOIN runs r ON r.id = l.run_id
|
||||||
|
LEFT JOIN article_assessments t ON t.article_id = a.id AND t.stage = 'triage'
|
||||||
|
LEFT JOIN article_assessments d ON d.article_id = a.id AND d.stage = 'deep'";
|
||||||
|
|
||||||
|
pub async fn list_articles(
|
||||||
|
db: &Db,
|
||||||
|
config: &Config,
|
||||||
|
filters: &ArticleFilters,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<(Vec<ArticleListRow>, Pagination), sqlx::Error> {
|
||||||
|
let (clauses, binds) = filters.where_clauses();
|
||||||
|
let count_sql = format!("SELECT COUNT(*) FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses}");
|
||||||
|
let total: i64 = bind_all(dynamic_query(count_sql), &binds)
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await?
|
||||||
|
.get(0);
|
||||||
|
let pagination = Pagination {
|
||||||
|
page,
|
||||||
|
per_page: ARTICLES_PER_PAGE,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
let select_sql = format!(
|
||||||
|
"SELECT * FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?",
|
||||||
|
filters.order_by()
|
||||||
|
);
|
||||||
|
let rows = bind_all(dynamic_query(select_sql), &binds)
|
||||||
|
.bind(i64::from(ARTICLES_PER_PAGE))
|
||||||
|
.bind(pagination.offset())
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
let rows = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let id: ArticleId = row.get("id");
|
||||||
|
let rating: Option<String> = row.get("rating");
|
||||||
|
ArticleListRow {
|
||||||
|
id,
|
||||||
|
href: format!("/dashboard/articles/{id}"),
|
||||||
|
title: row.get("title"),
|
||||||
|
feed_id: row.get("feed_id"),
|
||||||
|
feed: row.get("feed_title"),
|
||||||
|
first_seen: fmt_stored_time(Some(&row.get::<String, _>("first_seen")), config),
|
||||||
|
words: row.get("word_count"),
|
||||||
|
stage: row.get("stage"),
|
||||||
|
reason: row.get("excluded_reason"),
|
||||||
|
run_id: row.get("run_id"),
|
||||||
|
run_date: row.get("run_date"),
|
||||||
|
utility: fmt_opt(row.get("utility"), 1),
|
||||||
|
triage: fmt_opt(row.get("triage"), 1),
|
||||||
|
quality: fmt_opt(row.get("quality"), 1),
|
||||||
|
fit: fmt_opt(row.get("fit"), 1),
|
||||||
|
rating_class: widget_label(rating.as_deref()),
|
||||||
|
rating,
|
||||||
|
published: row.get("published"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((rows, pagination))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/articles.html")]
|
||||||
|
struct ArticlesTemplate {
|
||||||
|
page: Page,
|
||||||
|
articles: Vec<ArticleListRow>,
|
||||||
|
filters: ArticleFilters,
|
||||||
|
feed_value: String,
|
||||||
|
stages: Vec<&'static str>,
|
||||||
|
reasons: Vec<&'static str>,
|
||||||
|
rated: Vec<&'static str>,
|
||||||
|
kinds: Vec<&'static str>,
|
||||||
|
sorts: Vec<&'static str>,
|
||||||
|
pager: Pager,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Query(query): Query<ArticlesQuery>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let config = state.config();
|
||||||
|
let filters = ArticleFilters::from_query(&query);
|
||||||
|
let page_no = page_number(query.page);
|
||||||
|
let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?;
|
||||||
|
let pager = Pager::new(pagination, "/dashboard/articles", &filters.params());
|
||||||
|
let mut kinds: Vec<&'static str> = TRIAGE_KINDS.to_vec();
|
||||||
|
kinds.push(PROVIDER_REJECTED);
|
||||||
|
let mut page = Page::new("Articles", viewer, "articles");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(ArticlesTemplate {
|
||||||
|
page,
|
||||||
|
articles,
|
||||||
|
feed_value: filters
|
||||||
|
.feed
|
||||||
|
.map(|feed| feed.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
filters,
|
||||||
|
stages: STAGES.to_vec(),
|
||||||
|
reasons: REASONS.to_vec(),
|
||||||
|
rated: RATED.to_vec(),
|
||||||
|
kinds,
|
||||||
|
sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(),
|
||||||
|
pager,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Article detail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Facet {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `article_assessments` row.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AssessmentView {
|
||||||
|
pub stage: String,
|
||||||
|
pub rejected: bool,
|
||||||
|
pub model: String,
|
||||||
|
pub prompt_version: i64,
|
||||||
|
pub profile_version: String,
|
||||||
|
pub score: String,
|
||||||
|
pub fit: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub rationale: String,
|
||||||
|
pub category: String,
|
||||||
|
pub paywalled: bool,
|
||||||
|
pub assessed_at: String,
|
||||||
|
pub facets: Vec<Facet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `candidate_runs` row of the article, newest first.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct HistoryRow {
|
||||||
|
pub run_id: i64,
|
||||||
|
pub run_href: String,
|
||||||
|
pub date: String,
|
||||||
|
pub status: String,
|
||||||
|
pub stage: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub admitted_first: Option<String>,
|
||||||
|
pub admitted_rest: String,
|
||||||
|
pub utility: String,
|
||||||
|
pub rank: String,
|
||||||
|
pub cluster: String,
|
||||||
|
pub editor_why: Option<String>,
|
||||||
|
pub signals: SignalsView,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EmbeddingView {
|
||||||
|
pub model: String,
|
||||||
|
pub dimension: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub input_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RatingEventView {
|
||||||
|
pub id: i64,
|
||||||
|
pub event_at: String,
|
||||||
|
pub issue_date: Option<String>,
|
||||||
|
pub kind: String,
|
||||||
|
pub source: String,
|
||||||
|
pub label: String,
|
||||||
|
pub label_class: &'static str,
|
||||||
|
pub value: String,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct InIssue {
|
||||||
|
date: String,
|
||||||
|
href: String,
|
||||||
|
section: String,
|
||||||
|
position: i64,
|
||||||
|
is_lead: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SourceLine {
|
||||||
|
kind: String,
|
||||||
|
feed: String,
|
||||||
|
category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SocialLine {
|
||||||
|
source: String,
|
||||||
|
score: i64,
|
||||||
|
comments: i64,
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/article.html")]
|
||||||
|
struct ArticleTemplate {
|
||||||
|
page: Page,
|
||||||
|
id: ArticleId,
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
canonical_url: String,
|
||||||
|
feed: String,
|
||||||
|
feed_id: i64,
|
||||||
|
category: Option<String>,
|
||||||
|
author: Option<String>,
|
||||||
|
published_at: String,
|
||||||
|
first_seen: String,
|
||||||
|
words: i64,
|
||||||
|
excerpt_only: bool,
|
||||||
|
image_count: i64,
|
||||||
|
sources: Vec<SourceLine>,
|
||||||
|
social: Vec<SocialLine>,
|
||||||
|
in_issues: Vec<InIssue>,
|
||||||
|
rating: Option<String>,
|
||||||
|
rating_class: &'static str,
|
||||||
|
widget: RatingWidget,
|
||||||
|
explain: Option<String>,
|
||||||
|
assessments: Vec<AssessmentView>,
|
||||||
|
history: Vec<HistoryRow>,
|
||||||
|
latest_signals: Option<SignalsView>,
|
||||||
|
embedding: Option<EmbeddingView>,
|
||||||
|
events: Vec<RatingEventView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assessments(
|
||||||
|
db: &Db,
|
||||||
|
article_id: ArticleId,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<AssessmentView>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT stage, model, prompt_version, profile_version, score, fit, kind, facets_json,
|
||||||
|
rationale, category, paywalled_guess, assessed_at
|
||||||
|
FROM article_assessments WHERE article_id = ? ORDER BY stage DESC",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let kind: Option<String> = row.get("kind");
|
||||||
|
let facets = row
|
||||||
|
.get::<Option<String>, _>("facets_json")
|
||||||
|
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||||
|
.and_then(|value| value.as_object().cloned())
|
||||||
|
.map(|object| {
|
||||||
|
object
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, value)| Facet {
|
||||||
|
name,
|
||||||
|
value: match value {
|
||||||
|
serde_json::Value::String(text) => text,
|
||||||
|
serde_json::Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
item.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| item.to_string())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
other => other.to_string(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
AssessmentView {
|
||||||
|
stage: row.get("stage"),
|
||||||
|
rejected: kind.as_deref() == Some(PROVIDER_REJECTED),
|
||||||
|
model: row.get("model"),
|
||||||
|
prompt_version: row.get("prompt_version"),
|
||||||
|
profile_version: fmt_opt_int(row.get("profile_version")),
|
||||||
|
score: fmt_opt(row.get("score"), 1),
|
||||||
|
fit: fmt_opt(row.get("fit"), 1),
|
||||||
|
kind: kind.unwrap_or_else(|| "—".into()),
|
||||||
|
rationale: row
|
||||||
|
.get::<Option<String>, _>("rationale")
|
||||||
|
.unwrap_or_default(),
|
||||||
|
category: row
|
||||||
|
.get::<Option<String>, _>("category")
|
||||||
|
.unwrap_or_else(|| "—".into()),
|
||||||
|
paywalled: row.get::<i64, _>("paywalled_guess") != 0,
|
||||||
|
assessed_at: fmt_stored_time(Some(&row.get::<String, _>("assessed_at")), config),
|
||||||
|
facets,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_history(db: &Db, article_id: ArticleId) -> Result<Vec<HistoryRow>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT cr.run_id, r.date, r.status, cr.stage, cr.excluded_reason, cr.admitted_by,
|
||||||
|
cr.signals_json, cr.utility, cr.rank_utility, cr.cluster_id, cr.cluster_rank,
|
||||||
|
cr.editor_why
|
||||||
|
FROM candidate_runs cr JOIN runs r ON r.id = cr.run_id
|
||||||
|
WHERE cr.article_id = ? ORDER BY cr.run_id DESC",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let run_id: i64 = row.get("run_id");
|
||||||
|
let (admitted_first, rest) =
|
||||||
|
admitted_by_parts(row.get::<Option<String>, _>("admitted_by").as_deref());
|
||||||
|
HistoryRow {
|
||||||
|
run_id,
|
||||||
|
run_href: format!("/dashboard/runs/{run_id}"),
|
||||||
|
date: row.get("date"),
|
||||||
|
status: row.get("status"),
|
||||||
|
stage: row.get("stage"),
|
||||||
|
reason: row.get("excluded_reason"),
|
||||||
|
admitted_first,
|
||||||
|
admitted_rest: rest.join(", "),
|
||||||
|
utility: fmt_opt(row.get("utility"), 1),
|
||||||
|
rank: fmt_opt_int(row.get("rank_utility")),
|
||||||
|
cluster: match (
|
||||||
|
row.get::<Option<i64>, _>("cluster_id"),
|
||||||
|
row.get::<Option<i64>, _>("cluster_rank"),
|
||||||
|
) {
|
||||||
|
(Some(id), Some(rank)) => format!("{id} · {rank}"),
|
||||||
|
(Some(id), None) => id.to_string(),
|
||||||
|
_ => "—".into(),
|
||||||
|
},
|
||||||
|
editor_why: row.get("editor_why"),
|
||||||
|
signals: SignalsView::from_json(&row.get::<String, _>("signals_json")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rating_events(
|
||||||
|
db: &Db,
|
||||||
|
article_id: ArticleId,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<RatingEventView>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT re.id, re.issue_date, re.kind, re.source, re.label, re.value, re.note,
|
||||||
|
re.event_at, u.username
|
||||||
|
FROM rating_events re LEFT JOIN users u ON u.id = re.user_id
|
||||||
|
WHERE re.article_id = ? ORDER BY re.event_at DESC, re.id DESC",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let label: String = row.get("label");
|
||||||
|
RatingEventView {
|
||||||
|
id: row.get("id"),
|
||||||
|
event_at: fmt_stored_time(Some(&row.get::<String, _>("event_at")), config),
|
||||||
|
issue_date: row.get("issue_date"),
|
||||||
|
kind: row.get("kind"),
|
||||||
|
source: row.get("source"),
|
||||||
|
label_class: widget_label(Some(&label)),
|
||||||
|
label,
|
||||||
|
value: format!("{:.2}", row.get::<f64, _>("value")),
|
||||||
|
note: row.get("note"),
|
||||||
|
user: row.get("username"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embedding(
|
||||||
|
db: &Db,
|
||||||
|
article_id: ArticleId,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Option<EmbeddingView>, sqlx::Error> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT model, dimension, created_at, input_hash FROM article_embeddings
|
||||||
|
WHERE article_id = ?",
|
||||||
|
)
|
||||||
|
.bind(article_id)
|
||||||
|
.fetch_optional(db.pool())
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| EmbeddingView {
|
||||||
|
model: row.get("model"),
|
||||||
|
dimension: row.get("dimension"),
|
||||||
|
created_at: fmt_stored_time(Some(&row.get::<String, _>("created_at")), config),
|
||||||
|
input_hash: row.get("input_hash"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Path(id): Path<ArticleId>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let config = state.config();
|
||||||
|
let db = &state.db;
|
||||||
|
let Some(article) = db.get_article(id).await? else {
|
||||||
|
return Err(WebError::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
let in_issues = sqlx::query(
|
||||||
|
"SELECT issue_date, section, position, is_lead FROM issue_articles
|
||||||
|
WHERE article_id = ? ORDER BY issue_date DESC",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let date: String = row.get("issue_date");
|
||||||
|
InIssue {
|
||||||
|
href: format!("/issues/{date}/articles/{id}"),
|
||||||
|
date,
|
||||||
|
section: row.get("section"),
|
||||||
|
position: row.get("position"),
|
||||||
|
is_lead: row.get("is_lead"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let events = rating_events(db, id, &config).await.map_err(db_err)?;
|
||||||
|
let rating = events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event.kind == "explicit")
|
||||||
|
.map(|event| event.label.clone());
|
||||||
|
let widget = RatingWidget {
|
||||||
|
article_id: id,
|
||||||
|
issue_date: in_issues
|
||||||
|
.first()
|
||||||
|
.map(|issue| issue.date.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
next: format!("/dashboard/articles/{id}"),
|
||||||
|
current: widget_label(rating.as_deref()).to_string(),
|
||||||
|
show_note: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let history = run_history(db, id).await.map_err(db_err)?;
|
||||||
|
let explain = match history.first() {
|
||||||
|
Some(latest) => match telemetry::explain_row(db, latest.run_id, id)
|
||||||
|
.await
|
||||||
|
.map_err(db_err)?
|
||||||
|
{
|
||||||
|
Some(row) => Some(telemetry::render_explain(db, &row).await.map_err(db_err)?),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let latest_signals = history
|
||||||
|
.first()
|
||||||
|
.map(|latest| latest.signals.clone())
|
||||||
|
.filter(|signals| !signals.empty);
|
||||||
|
let assessments = assessments(db, id, &config).await.map_err(db_err)?;
|
||||||
|
let embedding = embedding(db, id, &config).await.map_err(db_err)?;
|
||||||
|
|
||||||
|
let mut page = Page::new(article.title.clone(), viewer, "articles");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(ArticleTemplate {
|
||||||
|
page,
|
||||||
|
id,
|
||||||
|
title: article.title.clone(),
|
||||||
|
url: article.url.clone(),
|
||||||
|
canonical_url: article.canonical_url.clone(),
|
||||||
|
feed: article.feed_title.clone(),
|
||||||
|
feed_id: article.feed_id,
|
||||||
|
category: article.category.clone(),
|
||||||
|
author: article.author.clone(),
|
||||||
|
published_at: article
|
||||||
|
.published_at
|
||||||
|
.map(|at| crate::web::format_time(at, &config))
|
||||||
|
.unwrap_or_else(|| "—".into()),
|
||||||
|
first_seen: crate::web::format_time(article.first_seen, &config),
|
||||||
|
words: article.word_count,
|
||||||
|
excerpt_only: article.excerpt_only,
|
||||||
|
image_count: article.image_count,
|
||||||
|
sources: article
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| SourceLine {
|
||||||
|
kind: format!("{:?}", source.kind),
|
||||||
|
feed: source.feed_title.clone(),
|
||||||
|
category: source.category.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
social: article
|
||||||
|
.social
|
||||||
|
.iter()
|
||||||
|
.map(|social| SocialLine {
|
||||||
|
source: format!("{:?}", social.source).to_lowercase(),
|
||||||
|
score: social.score,
|
||||||
|
comments: social.num_comments,
|
||||||
|
url: social.item_url.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
in_issues,
|
||||||
|
rating_class: widget_label(rating.as_deref()),
|
||||||
|
rating,
|
||||||
|
widget,
|
||||||
|
explain,
|
||||||
|
assessments,
|
||||||
|
history,
|
||||||
|
latest_signals,
|
||||||
|
embedding,
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::web::dashboard::tests::{
|
||||||
|
app_with_users, assert_admin_only, get, login_cookie, seed,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters {
|
||||||
|
let mut query = ArticlesQuery::default();
|
||||||
|
f(&mut query);
|
||||||
|
ArticleFilters::from_query(&query)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn articles_list_filters_and_sorts_are_allow_listed() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let db = &seed.db;
|
||||||
|
let config = Config::default();
|
||||||
|
|
||||||
|
let all = query(|_| {});
|
||||||
|
assert_eq!(all.sort, "first_seen");
|
||||||
|
let (rows, pagination) = list_articles(db, &config, &all, 1).await.unwrap();
|
||||||
|
assert_eq!(pagination.total, 8);
|
||||||
|
assert_eq!(rows.len(), 8);
|
||||||
|
let one = rows.iter().find(|row| row.id == 1).unwrap();
|
||||||
|
assert_eq!(one.stage.as_deref(), Some("selected"), "latest run's row");
|
||||||
|
assert_eq!(one.run_id, Some(seed.run_id));
|
||||||
|
assert_eq!(one.rating.as_deref(), Some("loved"));
|
||||||
|
assert_eq!(one.published.as_deref(), Some("2026-09-02"));
|
||||||
|
assert_eq!(one.quality, "8.8");
|
||||||
|
|
||||||
|
let bogus = query(|q| {
|
||||||
|
q.sort = Some("id; DROP TABLE articles".into());
|
||||||
|
q.stage = Some("nope".into());
|
||||||
|
q.rated = Some("' OR 1=1".into());
|
||||||
|
q.from = Some("not a date".into());
|
||||||
|
q.feed = Some("abc".into());
|
||||||
|
});
|
||||||
|
assert_eq!(bogus.sort, "first_seen");
|
||||||
|
assert_eq!(bogus.stage, None);
|
||||||
|
assert_eq!(bogus.rated, None);
|
||||||
|
assert_eq!(bogus.from, None);
|
||||||
|
assert_eq!(bogus.feed, None);
|
||||||
|
let (rows, _) = list_articles(db, &config, &bogus, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 8, "unknown values fall back, never error");
|
||||||
|
|
||||||
|
let by_feed = query(|q| q.feed = Some("20".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_feed, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 4);
|
||||||
|
assert!(rows.iter().all(|row| row.feed == "Beta Weekly"));
|
||||||
|
|
||||||
|
let selected = query(|q| q.stage = Some("selected".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &selected, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
|
||||||
|
let reason = query(|q| q.reason = Some("blocked".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &reason, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 7);
|
||||||
|
|
||||||
|
let loved = query(|q| q.rated = Some("loved".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &loved, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 1);
|
||||||
|
let good = query(|q| q.rated = Some("good".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &good, 1).await.unwrap();
|
||||||
|
assert!(rows.is_empty(), "superseded events do not count");
|
||||||
|
let unrated = query(|q| q.rated = Some("none".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &unrated, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 7);
|
||||||
|
|
||||||
|
let published = query(|q| q.published = Some("yes".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &published, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
|
||||||
|
let window = query(|q| {
|
||||||
|
q.from = Some("2026-09-02".into());
|
||||||
|
q.to = Some("2026-09-02".into());
|
||||||
|
});
|
||||||
|
let (rows, _) = list_articles(db, &config, &window, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 3, "ids 1, 4, 7 were first seen on the 2nd");
|
||||||
|
|
||||||
|
let kind = query(|q| q.kind = Some("provider_rejected".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &kind, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 3);
|
||||||
|
|
||||||
|
let search = query(|q| q.q = Some("example.com/8".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &search, 1).await.unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert_eq!(rows[0].id, 8);
|
||||||
|
|
||||||
|
let by_words = query(|q| q.sort = Some("words".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_words, 1).await.unwrap();
|
||||||
|
assert_eq!(rows[0].id, 8);
|
||||||
|
let by_utility = query(|q| q.sort = Some("utility".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_utility, 1).await.unwrap();
|
||||||
|
assert_eq!(rows[0].id, 1);
|
||||||
|
assert_eq!(rows[7].utility, "—");
|
||||||
|
let by_title = query(|q| q.sort = Some("title".into()));
|
||||||
|
let (rows, _) = list_articles(db, &config, &by_title, 1).await.unwrap();
|
||||||
|
assert_eq!(rows[0].id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn article_detail_shows_assessments_run_history_and_rating_events() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let config = Config::default();
|
||||||
|
let views = assessments(&seed.db, 1, &config).await.unwrap();
|
||||||
|
assert_eq!(views.len(), 2);
|
||||||
|
assert_eq!(views[0].stage, "triage");
|
||||||
|
assert_eq!(views[0].score, "7.0");
|
||||||
|
assert_eq!(views[1].stage, "deep");
|
||||||
|
assert_eq!(views[1].fit, "6.0");
|
||||||
|
assert_eq!(views[1].category, "Top Stories");
|
||||||
|
assert_eq!(views[1].facets[0].name, "depth");
|
||||||
|
let rejected = assessments(&seed.db, 3, &config).await.unwrap();
|
||||||
|
assert!(rejected[0].rejected);
|
||||||
|
|
||||||
|
let history = run_history(&seed.db, 1).await.unwrap();
|
||||||
|
assert_eq!(history.len(), 2);
|
||||||
|
assert_eq!(history[0].run_id, seed.run_id);
|
||||||
|
assert_eq!(history[0].stage, "selected");
|
||||||
|
assert_eq!(history[0].admitted_first.as_deref(), Some("triage"));
|
||||||
|
assert_eq!(history[1].run_id, seed.earlier_run_id);
|
||||||
|
assert!(history[1].signals.empty);
|
||||||
|
|
||||||
|
let events = rating_events(&seed.db, 1, &config).await.unwrap();
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
assert_eq!(events[0].label, "loved");
|
||||||
|
assert_eq!(events[1].label, "good");
|
||||||
|
assert_eq!(events[1].note.as_deref(), Some("note good"));
|
||||||
|
|
||||||
|
let app = app_with_users(&seed.db).await;
|
||||||
|
let list = assert_admin_only(&app, "/dashboard/articles").await;
|
||||||
|
assert!(list.contains("Article 1 about prose"), "{list}");
|
||||||
|
assert!(list.contains("/dashboard/articles/1"), "{list}");
|
||||||
|
|
||||||
|
let body = assert_admin_only(&app, "/dashboard/articles/1").await;
|
||||||
|
assert!(body.contains("Careful and first-hand"), "{body}");
|
||||||
|
assert!(body.contains("A specific argument"), "{body}");
|
||||||
|
assert!(body.contains("topic_group"), "{body}");
|
||||||
|
assert!(
|
||||||
|
body.contains(&format!("/dashboard/runs/{}", seed.earlier_run_id)),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("note good"), "{body}");
|
||||||
|
assert!(body.contains("note loved"), "{body}");
|
||||||
|
assert!(body.contains("voyage-4-lite"), "{body}");
|
||||||
|
assert!(body.contains("abc123"), "{body}");
|
||||||
|
assert!(body.contains("Gaussian Splatting"), "{body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("article 1: Article 1 about prose"),
|
||||||
|
"explain: {body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("name=\"note\""), "note field: {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("value=\"loved\" data-label=\"loved\" class=\"active\""),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("Top Stories"), "{body}");
|
||||||
|
assert!(body.contains("Alpha Blog"), "{body}");
|
||||||
|
|
||||||
|
let rejected = assert_admin_only(&app, "/dashboard/articles/3").await;
|
||||||
|
assert!(rejected.contains("rejected by provider"), "{rejected}");
|
||||||
|
assert!(rejected.contains("Content Exists Risk"), "{rejected}");
|
||||||
|
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
let missing = get(&app, "/dashboard/articles/999", Some(&admin)).await;
|
||||||
|
assert_eq!(missing.status(), axum::http::StatusCode::NOT_FOUND);
|
||||||
|
let filtered = get(
|
||||||
|
&app,
|
||||||
|
"/dashboard/articles?rated=loved&sort=nope&feed=x&page=0",
|
||||||
|
Some(&admin),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(filtered.status(), axum::http::StatusCode::OK);
|
||||||
|
let filtered = crate::web::dashboard::tests::response_text(filtered).await;
|
||||||
|
assert!(filtered.contains("Article 1 about prose"), "{filtered}");
|
||||||
|
assert!(!filtered.contains("Article 2 about graphs"), "{filtered}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1100
-4
File diff suppressed because it is too large
Load Diff
+1124
-1
File diff suppressed because it is too large
Load Diff
@@ -57,3 +57,36 @@ thead { position:sticky; top:0; background:var(--bg); }
|
|||||||
.rating-prompt { margin-right:.25rem; }
|
.rating-prompt { margin-right:.25rem; }
|
||||||
.rating-note { flex-basis:100%; }
|
.rating-note { flex-basis:100%; }
|
||||||
@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } }
|
@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } }
|
||||||
|
/* step 3: dashboard reads (overview, runs, articles) */
|
||||||
|
.dashboard h1 { font-size:1.5rem; margin:.5rem 0; }
|
||||||
|
.dashboard h1 a { color:inherit; }
|
||||||
|
.dashboard h2 { font-size:1.1rem; margin:1.75rem 0 .5rem; }
|
||||||
|
.dashboard h3 { font-size:1rem; margin:.5rem 0; }
|
||||||
|
.dashboard table td { vertical-align:top; }
|
||||||
|
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(20rem,1fr)); gap:1rem; margin:1rem 0; }
|
||||||
|
.card { border:1px solid var(--rule); padding:.75rem 1rem; min-width:0; }
|
||||||
|
.card h2 { margin-top:0; }
|
||||||
|
.block { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; margin:.5rem 0; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||||
|
.filters { display:flex; flex-wrap:wrap; gap:.5rem 1rem; align-items:end; margin:1rem 0; }
|
||||||
|
.filters label { font-size:.85rem; }
|
||||||
|
.filters input,.filters select { padding:.3rem; font-size:.9rem; }
|
||||||
|
.run-nav { display:flex; flex-wrap:wrap; gap:1.5rem; margin:.5rem 0 1rem; }
|
||||||
|
.funnel-cell { min-width:12rem; width:40%; }
|
||||||
|
.funnel svg { display:block; }
|
||||||
|
.funnel-table .reasons { color:var(--muted); font-size:.8rem; }
|
||||||
|
.badge.reason,.badge.running,.badge.requested { color:var(--muted); }
|
||||||
|
.badge.ok { color:var(--loved); } .badge.degraded { color:var(--good); } .badge.failed,.badge.dry_run { color:var(--down); }
|
||||||
|
.pager { display:flex; flex-wrap:wrap; gap:1rem; margin:1rem 0; align-items:center; }
|
||||||
|
details.signals summary { cursor:pointer; }
|
||||||
|
details.signals summary a { display:inline; }
|
||||||
|
.signals-body { margin:.5rem 0 .5rem 1rem; font-size:.85rem; }
|
||||||
|
.signals-body table { width:auto; }
|
||||||
|
.signals-body tr.muted td { color:var(--muted); }
|
||||||
|
details.explain { margin:1rem 0; }
|
||||||
|
.diff .before { color:var(--down); } .diff .after { color:var(--loved); }
|
||||||
|
.picks,.jobs,.warnings { list-style:none; padding:0; }
|
||||||
|
.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
|
||||||
|
.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
|
||||||
|
.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
|
||||||
|
|||||||
@@ -39,3 +39,21 @@ document.querySelectorAll("details[id]").forEach((details) => {
|
|||||||
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
|
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
|
||||||
|
document.querySelectorAll("table[data-filter]").forEach((table) => {
|
||||||
|
const rows = table.querySelectorAll("tbody tr");
|
||||||
|
if (rows.length < 2) return;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "search";
|
||||||
|
input.className = "table-filter";
|
||||||
|
input.placeholder = "Filter rows on this page";
|
||||||
|
input.setAttribute("aria-label", "Filter rows on this page");
|
||||||
|
const host = table.closest(".scroll-x") || table;
|
||||||
|
host.parentNode.insertBefore(input, host);
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const needle = input.value.trim().toLowerCase();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<tr>
|
||||||
|
<td><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
|
<td>{{ candidate.feed }}</td>
|
||||||
|
<td class="num">{{ candidate.words }}</td>
|
||||||
|
<td><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
||||||
|
<td>{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
||||||
|
<td>{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
|
<td class="num">{{ candidate.utility }}</td>
|
||||||
|
<td class="num">{{ candidate.rank }}</td>
|
||||||
|
<td class="num">{{ candidate.cluster }}</td>
|
||||||
|
<td class="num">{{ candidate.triage }}</td>
|
||||||
|
<td class="num">{{ candidate.quality }}</td>
|
||||||
|
<td class="num">{{ candidate.fit }}</td>
|
||||||
|
<td>{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
||||||
|
<td>{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %}</td>
|
||||||
|
</tr>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% if signals.empty %}<p class="muted">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
|
||||||
|
<table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
|
||||||
|
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table>
|
||||||
|
<p class="muted">Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
|
||||||
|
{% if !signals.top_interests.is_empty() %}<p><strong>Top interests</strong></p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% if !signals.neighbours.is_empty() %}<p><strong>Nearest rated neighbours</strong></p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% if !signals.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
</div>{% endif %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% if pager.pages > 1 %}<nav class="pager">{% if let Some(href) = pager.prev_href %}<a href="{{ href }}">← Previous</a>{% endif %}<span>Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} rows</span>{% if let Some(href) = pager.next_href %}<a href="{{ href }}">Next →</a>{% endif %}</nav>{% else %}<p class="muted">{{ pager.total }} row{% if pager.total != 1 %}s{% endif %}</p>{% endif %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<h1><a href="{{ url }}">{{ title }}</a></h1>
|
||||||
|
<p class="muted">Article {{ id }} · <a href="/dashboard/articles?feed={{ feed_id }}">{{ feed }}</a>{% if let Some(category) = category %} · {{ category }}{% endif %}{% if let Some(author) = author %} · {{ author }}{% endif %}</p>
|
||||||
|
|
||||||
|
<h2>Article</h2>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Canonical URL</dt><dd><a href="{{ canonical_url }}">{{ canonical_url }}</a></dd>
|
||||||
|
<dt>Published</dt><dd>{{ published_at }}</dd>
|
||||||
|
<dt>First seen</dt><dd>{{ first_seen }}</dd>
|
||||||
|
<dt>Words</dt><dd>{{ words }}{% if excerpt_only %} <span class="badge">excerpt only</span>{% endif %}</dd>
|
||||||
|
<dt>Images</dt><dd>{{ image_count }}</dd>
|
||||||
|
<dt>Sources</dt><dd>{% if sources.is_empty() %}<span class="muted">none recorded</span>{% else %}{% for source in sources %}{{ source.kind }} · {{ source.feed }}{% if let Some(category) = source.category %} ({{ category }}){% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||||
|
<dt>Social</dt><dd>{% if social.is_empty() %}<span class="muted">none</span>{% else %}{% for item in social %}{% if let Some(url) = item.url %}<a href="{{ url }}">{{ item.source }}</a>{% else %}{{ item.source }}{% endif %} · {{ item.score }} points · {{ item.comments }} comments{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||||
|
<dt>In issues</dt><dd>{% if in_issues.is_empty() %}<span class="muted">never published</span>{% else %}{% for issue in in_issues %}<a href="{{ issue.href }}">{{ issue.date }}</a> · {{ issue.section }} · position {{ issue.position }}{% if issue.is_lead %} · lead{% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
|
||||||
|
<dt>Current rating</dt><dd>{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</dd>
|
||||||
|
</dl>
|
||||||
|
{% include "_rating_widget.html" %}
|
||||||
|
{% if let Some(text) = explain %}<details class="explain"><summary>Explain (text)</summary><pre class="block">{{ text }}</pre></details>{% endif %}
|
||||||
|
|
||||||
|
<h2>Assessments</h2>
|
||||||
|
{% if assessments.is_empty() %}<p class="muted">No LLM assessment stored.</p>{% else %}{% for assessment in assessments %}<section class="card">
|
||||||
|
<h3>{{ assessment.stage }}{% if assessment.rejected %} <span class="badge down">rejected by provider</span>{% endif %}</h3>
|
||||||
|
{% if assessment.rejected %}<p>{{ assessment.rationale }}</p><p class="muted">{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · {{ assessment.assessed_at }}</p>{% else %}<dl class="kv">
|
||||||
|
{% if assessment.stage == "deep" %}<dt>Quality</dt><dd>{{ assessment.score }}</dd><dt>Fit</dt><dd>{{ assessment.fit }}</dd><dt>Category</dt><dd>{{ assessment.category }}</dd><dt>Format</dt><dd>{{ assessment.kind }}</dd><dt>Paywalled guess</dt><dd>{% if assessment.paywalled %}yes{% else %}no{% endif %}</dd>{% else %}<dt>Interest</dt><dd>{{ assessment.score }}</dd><dt>Kind</dt><dd>{{ assessment.kind }}</dd>{% endif %}
|
||||||
|
<dt>Rationale</dt><dd>{{ assessment.rationale }}</dd>
|
||||||
|
<dt>Model</dt><dd>{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · profile v{{ assessment.profile_version }}</dd>
|
||||||
|
<dt>Assessed</dt><dd>{{ assessment.assessed_at }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if !assessment.facets.is_empty() %}<div class="scroll-x"><table class="facets"><thead><tr><th>facet</th><th>value</th></tr></thead><tbody>{% for facet in assessment.facets %}<tr><td>{{ facet.name }}</td><td>{{ facet.value }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% endif %}
|
||||||
|
</section>{% endfor %}{% endif %}
|
||||||
|
|
||||||
|
<h2>Run history</h2>
|
||||||
|
{% if history.is_empty() %}<p class="muted">Never considered by a run.</p>{% else %}<div class="scroll-x"><table class="history">
|
||||||
|
<thead><tr><th>run</th><th>date</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th>editor</th></tr></thead>
|
||||||
|
<tbody>{% for row in history %}<tr>
|
||||||
|
<td><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
|
||||||
|
<td>{{ row.date }}</td>
|
||||||
|
<td><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
||||||
|
<td>{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
||||||
|
<td>{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
|
||||||
|
<td class="num">{{ row.utility }}</td>
|
||||||
|
<td class="num">{{ row.rank }}</td>
|
||||||
|
<td class="num">{{ row.cluster }}</td>
|
||||||
|
<td>{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
|
<h2>Neighbours and interests</h2>
|
||||||
|
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
|
||||||
|
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td>{{ interest.name }}</td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
|
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||||
|
</div>{% endif %}{% else %}<p class="muted">No signals recorded.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Embedding</h2>
|
||||||
|
{% if let Some(embedding) = embedding %}<dl class="kv"><dt>Model</dt><dd>{{ embedding.model }}</dd><dt>Dimension</dt><dd>{{ embedding.dimension }}</dd><dt>Created</dt><dd>{{ embedding.created_at }}</dd><dt>Input hash</dt><dd><code>{{ embedding.input_hash }}</code></dd></dl>{% else %}<p class="muted">No embedding stored.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Rating events</h2>
|
||||||
|
{% if events.is_empty() %}<p class="muted">No rating events.</p>{% else %}<div class="scroll-x"><table class="events">
|
||||||
|
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
|
||||||
|
<tbody>{% for event in events %}<tr>
|
||||||
|
<td>{{ event.event_at }}</td>
|
||||||
|
<td><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
|
||||||
|
<td class="num">{{ event.value }}</td>
|
||||||
|
<td>{{ event.kind }}</td>
|
||||||
|
<td>{{ event.source }}</td>
|
||||||
|
<td>{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||||
|
<td>{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
|
<td>{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<h1>Articles</h1>
|
||||||
|
<form class="filters" method="get" action="/dashboard/articles">
|
||||||
|
<label>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}"></label>
|
||||||
|
<label>Feed id <input type="number" name="feed" value="{{ feed_value }}" min="1"></label>
|
||||||
|
<label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Rated <select name="rated"><option value="">all</option>{% for name in rated %}<option value="{{ name }}"{% if let Some(current) = filters.rated %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Published <select name="published"><option value="">all</option><option value="yes"{% if let Some(current) = filters.published %}{% if current == "yes" %} selected{% endif %}{% endif %}>yes</option><option value="no"{% if let Some(current) = filters.published %}{% if current == "no" %} selected{% endif %}{% endif %}>no</option></select></label>
|
||||||
|
<label>First seen from <input type="date" name="from" value="{% if let Some(from) = filters.from %}{{ from }}{% endif %}"></label>
|
||||||
|
<label>to <input type="date" name="to" value="{% if let Some(to) = filters.to %}{{ to }}{% endif %}"></label>
|
||||||
|
<label>Triage kind <select name="kind"><option value="">any</option>{% for name in kinds %}<option value="{{ name }}"{% if let Some(current) = filters.kind %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<button type="submit">Apply</button> <a href="/dashboard/articles">Reset</a>
|
||||||
|
</form>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
<div class="scroll-x"><table class="articles" data-filter>
|
||||||
|
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
|
||||||
|
<tbody>{% for article in articles %}<tr>
|
||||||
|
<td>{{ article.first_seen }}</td>
|
||||||
|
<td><a href="{{ article.href }}">{{ article.title }}</a></td>
|
||||||
|
<td>{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
|
||||||
|
<td class="num">{{ article.words }}</td>
|
||||||
|
<td>{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted">never considered</span>{% endif %}</td>
|
||||||
|
<td>{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
|
||||||
|
<td class="num">{{ article.utility }}</td>
|
||||||
|
<td class="num">{{ article.triage }}</td>
|
||||||
|
<td class="num">{{ article.quality }}</td>
|
||||||
|
<td class="num">{{ article.fit }}</td>
|
||||||
|
<td>{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
||||||
|
<td>{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -1 +1,40 @@
|
|||||||
{% extends "layout.html" %}{% block content %}<section class="dashboard"><h1>Overview</h1><p>The dashboard foundation is ready. Run and article views land in the next dashboard step.</p></section>{% endblock %}
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<h1>Overview</h1>
|
||||||
|
<div class="cards">
|
||||||
|
<section class="card"><h2>Last run</h2>
|
||||||
|
{% if let Some(run) = last_run %}<p><a href="/dashboard/runs/{{ run.id }}">Run {{ run.id }}</a> · {{ run.date }} · <span class="badge {{ run.status }}">{{ run.status }}</span></p>
|
||||||
|
<p class="muted">Started {{ run.started }} · {{ run.duration }}</p>
|
||||||
|
<pre class="block">{% for line in run.lines %}{{ line }}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
{% if run.warnings > 0 %}<p><a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }} warning{% if run.warnings != 1 %}s{% endif %}</a></p>{% endif %}
|
||||||
|
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
{% else %}<p class="muted">No runs recorded yet.</p>{% endif %}
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Budget today</h2>
|
||||||
|
{% if budget.is_empty() %}<p class="muted">No providers configured.</p>{% else %}<dl class="kv budget">
|
||||||
|
{% for line in budget %}<dt>{{ line.provider }}</dt><dd><meter value="{{ line.value }}" max="{{ line.max }}"{% if line.over %} class="over"{% endif %}></meter> {{ line.spent }} of {{ line.ceiling }}{% if line.over %} <strong class="error">ceiling reached</strong>{% endif %}</dd>
|
||||||
|
{% endfor %}</dl>{% endif %}
|
||||||
|
<p class="muted">Spend on the current UTC day across every recorded run.</p>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Ratings this week</h2>
|
||||||
|
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the last seven days.</p>{% else %}<p>{{ ratings_total }} verdict{% if ratings_total != 1 %}s{% endif %}: {% for count in ratings %}<span class="badge {{ count.label }}">{{ count.label }}</span> {{ count.count }}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}
|
||||||
|
<p><a href="/dashboard/ratings">Rating history</a></p>
|
||||||
|
</section>
|
||||||
|
<section class="card"><h2>Jobs</h2>
|
||||||
|
{% if active_jobs.is_empty() && finished_jobs.is_empty() %}<p class="muted">No jobs recorded.</p>{% else %}
|
||||||
|
{% if !active_jobs.is_empty() %}<ul class="jobs">{% for job in active_jobs %}<li><a href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted">requested {{ job.requested }}</span></li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% if !finished_jobs.is_empty() %}<p class="muted">Recently finished</p><ul class="jobs">{% for job in finished_jobs %}<li><a href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted">{{ job.finished }}</span>{% if let Some(message) = job.message %} <span class="muted">— {{ message }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<p><a href="/dashboard/jobs">All jobs</a></p>
|
||||||
|
</section>
|
||||||
|
{% if !config_warnings.is_empty() %}<section class="card"><h2>Config on disk</h2>
|
||||||
|
<ul class="warnings">{% for line in config_warnings %}<li class="error">{{ line }}</li>{% endfor %}</ul>
|
||||||
|
<p><a href="/dashboard/settings">Settings</a></p>
|
||||||
|
</section>{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- step 6: sparklines (cost per run, selected per run, generation seconds; last 30 non-dry runs) -->
|
||||||
|
<h2>Unrated picks</h2>
|
||||||
|
{% if unrated.is_empty() %}<p class="muted">Every pick from the last three issues has a verdict.</p>{% else %}<p class="muted">Picks from the last three issues without a verdict yet.</p>
|
||||||
|
<ul class="picks">{% for pick in unrated %}<li><a href="{{ pick.issue_href }}">{{ pick.title }}</a> <span class="muted">· {{ pick.feed }} · {{ pick.issue_date }}</span>
|
||||||
|
{% let widget = pick.widget %}{% include "_rating_widget.html" %}</li>{% endfor %}</ul>{% endif %}
|
||||||
|
</section>{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<h1>Run {{ run.id }} · {{ run.date }} <span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted">dry run</span>{% endif %}</h1>
|
||||||
|
<p class="muted">Started {{ run.started }} · finished {{ run.finished }} · {{ run.duration }} · {{ run.total_cost }}</p>
|
||||||
|
<nav class="run-nav">{% if let Some(prev) = run.prev_id %}<a href="/dashboard/runs/{{ prev }}">← Run {{ prev }}</a>{% endif %}{% if let Some(href) = run.issue_href %}<a href="{{ href }}">Issue {{ run.date }}</a>{% else %}<span class="muted">No issue for {{ run.date }}</span>{% endif %}{% if let Some(next) = run.next_id %}<a href="/dashboard/runs/{{ next }}">Run {{ next }} →</a>{% endif %}</nav>
|
||||||
|
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Funnel</h2>
|
||||||
|
<div class="scroll-x"><table class="funnel-table">
|
||||||
|
<thead><tr><th>stage</th><th>reached</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
|
||||||
|
<tbody>{% for stage in funnel %}<tr>
|
||||||
|
<td><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
|
||||||
|
<td class="funnel-cell"><div class="funnel"><svg width="{{ stage.percent }}%" height="12" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
|
||||||
|
<td class="num">{{ stage.reached }}</td>
|
||||||
|
<td class="num">{{ stage.stopped }}</td>
|
||||||
|
<td class="reasons">{% for reason in stage.reasons %}{{ reason.reason }} {{ reason.count }}{% if !loop.last %} · {% endif %}{% endfor %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>
|
||||||
|
|
||||||
|
{% if has_report %}<div class="cards">
|
||||||
|
<section class="card"><h2>Admission mix</h2>{% if admission.is_empty() %}<p class="muted">Nothing admitted.</p>{% else %}<dl class="kv">{% for line in admission %}<dt>{{ line.name }}</dt><dd>{{ line.count }}</dd>{% endfor %}</dl>{% endif %}</section>
|
||||||
|
<section class="card"><h2>Preference state</h2><dl class="kv">
|
||||||
|
<dt>rated with embeddings</dt><dd>{{ preference.rated_with_embeddings }}</dd>
|
||||||
|
<dt>knn gate</dt><dd>{{ preference.knn_gate }}</dd>
|
||||||
|
<dt>feed gate</dt><dd>{{ preference.feed_gate }}</dd>
|
||||||
|
<dt>verdicts in prompt</dt><dd>{{ preference.verdicts_in_prompt }}</dd>
|
||||||
|
</dl></section>
|
||||||
|
<section class="card"><h2>Timings</h2><div class="scroll-x"><table><thead><tr><th>stage</th><th class="num">seconds</th></tr></thead><tbody>{% for line in timings %}<tr><td>{{ line.stage }}</td><td class="num">{{ line.seconds }}</td></tr>{% endfor %}<tr><th>total</th><th class="num">{{ timings_total }}</th></tr></tbody></table></div></section>
|
||||||
|
<section class="card"><h2>Provider usage</h2><div class="scroll-x"><table><thead><tr><th>provider</th><th class="num">in</th><th class="num">cached</th><th class="num">cache write</th><th class="num">out</th><th class="num">cost</th></tr></thead><tbody>{% for line in providers %}<tr><td>{{ line.provider }}</td><td class="num">{{ line.input }}</td><td class="num">{{ line.cached }}</td><td class="num">{{ line.cache_write }}</td><td class="num">{{ line.output }}</td><td class="num">{{ line.cost }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||||
|
<section class="card" id="warnings"><h2>Warnings</h2>{% if warnings.is_empty() %}<p class="muted">None.</p>{% else %}<ul>{% for warning in warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}</section>
|
||||||
|
<section class="card"><h2>Feeds (top {{ feeds.len() }})</h2><div class="scroll-x"><table><thead><tr><th>feed</th><th class="num">entries</th></tr></thead><tbody>{% for line in feeds %}<tr><td>{{ line.name }}</td><td class="num">{{ line.count }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||||
|
</div>{% else %}<p class="muted">This run has no stored report; only the funnel and candidates are available.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Config diff</h2>
|
||||||
|
{% if let Some(against) = diff_against %}{% if config_diff.is_empty() %}<p class="muted">No settings changed since <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>{% else %}<p class="muted">Against the previous non-dry run, <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>
|
||||||
|
<div class="scroll-x"><table class="diff"><thead><tr><th>key</th><th>before</th><th>after</th></tr></thead><tbody>{% for row in config_diff %}<tr><td><code>{{ row.key }}</code></td><td class="before">{{ row.before }}</td><td class="after">{{ row.after }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted">No earlier non-dry run to compare against.</p>{% endif %}
|
||||||
|
|
||||||
|
<h2>Near misses</h2>
|
||||||
|
{% if near_misses.is_empty() %}<p class="muted">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
||||||
|
<thead><tr><th>#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
|
||||||
|
<tbody>{% for miss in near_misses %}<tr><td>{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td><a href="{{ miss.href }}">{{ miss.title }}</a></td><td>{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td>{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
|
|
||||||
|
<h2>Candidates</h2>
|
||||||
|
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
|
||||||
|
<label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Admitted by <select name="admitted_by"><option value="">any</option>{% for name in retrievers %}<option value="{{ name }}"{% if let Some(current) = filters.admitted_by %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<label>Flag <select name="flag"><option value="">any</option><option value="exploration"{% if let Some(current) = filters.flag %}{% if current == "exploration" %} selected{% endif %}{% endif %}>exploration</option><option value="auto"{% if let Some(current) = filters.flag %}{% if current == "auto" %} selected{% endif %}{% endif %}>auto-include</option></select></label>
|
||||||
|
<label>Title <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}"></label>
|
||||||
|
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<button type="submit">Apply</button> <a href="/dashboard/runs/{{ run.id }}">Reset</a>
|
||||||
|
</form>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
<div class="scroll-x"><table class="candidates" data-filter>
|
||||||
|
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th><th>editor</th></tr></thead>
|
||||||
|
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}</tbody></table></div>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<h1>Runs</h1>
|
||||||
|
<form class="filters" method="get" action="/dashboard/runs">
|
||||||
|
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<div class="scroll-x"><table data-filter>
|
||||||
|
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th>warnings</th></tr></thead>
|
||||||
|
<tbody>{% for run in runs %}<tr>
|
||||||
|
<td><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||||
|
<td><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
||||||
|
<td><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted">dry</span>{% endif %}</td>
|
||||||
|
<td>{{ run.started }}</td>
|
||||||
|
<td>{{ run.duration }}</td>
|
||||||
|
<td>{{ run.funnel }}</td>
|
||||||
|
<td>{{ run.costs }}</td>
|
||||||
|
<td class="num">{{ run.total }}</td>
|
||||||
|
<td class="num">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }}</a>{% else %}0{% endif %}</td>
|
||||||
|
</tr>{% endfor %}</tbody></table></div>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
</section>{% endblock %}
|
||||||
Reference in New Issue
Block a user