Web dashboard v2 step 3: dashboard restyle
Restyle every template under src/web/templates/dashboard/ plus the shared partials _candidate_row.html, _signals_table.html and _pagination.html with Tailwind utilities and the design system step 2 built, so the dashboard reads as the sans-serif instrument-panel half of the same publication as the reader pages: a page header on every page, stat tiles, compact tables with sticky heads inside .scroll-x, tinted badges, one inline filter form per page, settings groups with a sticky save bar, job cards, a scrolling journal block and a rating widget that fits inside a table cell. tailwind.css gains the dashboard vocabulary (.page-head/.page-desc/ .page-actions, .tiles/.tile*, .cell-wrap, .scroll-x.tall, .filters, .pager/.tabs, .setting*/.save-bar, .disclosure, .sparklines/.spark-figure, td .rating overrides, tr.superseded) plus a styled <meter> and a disabled control state. Renames class="inline" to form-inline: Tailwind emits an .inline display utility that was beating form.inline's layout. Three small backend changes: job cards show the status and time of their last run (attach_last_runs, no extra query, unit-tested); the Stats page passes "stats" as its nav key so the admin nav no longer highlights Overview; and the settings load-error banner drops its "! " prefix, with its assertion updated. cargo test: 477 passed. npm run css:check clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -45,6 +45,12 @@ struct JobCard {
|
||||
/// The `generate` card carries the date input for `generate-YYYY-MM-DD`.
|
||||
dated: bool,
|
||||
lock: Option<&'static str>,
|
||||
/// Status of the most recent job started from this card, when there is one.
|
||||
last_status: Option<String>,
|
||||
/// When that job was requested, for the card's "last run" line.
|
||||
last_requested: Option<String>,
|
||||
/// Its id, so the card links straight to the job page.
|
||||
last_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -95,10 +101,29 @@ fn cards() -> Vec<JobCard> {
|
||||
dangerous: job.dangerous(),
|
||||
dated: matches!(job, Job::Generate { .. }),
|
||||
lock: job.takes_lock(),
|
||||
last_status: None,
|
||||
last_requested: None,
|
||||
last_id: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `rows` is newest first, so the first row whose name is the card's name (or
|
||||
/// `<name>-<date>` for the dated `generate` card) is that card's last run.
|
||||
fn attach_last_runs(cards: &mut [JobCard], rows: &[JobLine]) {
|
||||
for card in cards.iter_mut() {
|
||||
let prefix = format!("{}-", card.name);
|
||||
if let Some(row) = rows
|
||||
.iter()
|
||||
.find(|row| row.name == card.name || row.name.starts_with(&prefix))
|
||||
{
|
||||
card.last_status = Some(row.status.clone());
|
||||
card.last_requested = Some(row.requested.clone());
|
||||
card.last_id = Some(row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn job_line(row: &JobRow, config: &Config) -> JobLine {
|
||||
JobLine {
|
||||
id: row.id,
|
||||
@@ -140,11 +165,14 @@ async fn jobs_template(
|
||||
.date()
|
||||
.to_string()
|
||||
});
|
||||
let jobs: Vec<JobLine> = rows.iter().map(|row| job_line(row, &config)).collect();
|
||||
let mut cards = cards();
|
||||
attach_last_runs(&mut cards, &jobs);
|
||||
Ok(JobsTemplate {
|
||||
page,
|
||||
jobs_enabled: config.server.jobs_enabled,
|
||||
cards: cards(),
|
||||
jobs: rows.iter().map(|row| job_line(row, &config)).collect(),
|
||||
cards,
|
||||
jobs,
|
||||
today,
|
||||
})
|
||||
}
|
||||
@@ -352,6 +380,47 @@ mod tests {
|
||||
use crate::web::MockRunner;
|
||||
use crate::web::dashboard::tests::{assert_admin_only, get, login_cookie, response_text};
|
||||
|
||||
fn job_line_named(id: i64, name: &str, status: &str) -> JobLine {
|
||||
JobLine {
|
||||
id,
|
||||
name: name.into(),
|
||||
requested_by: "admin".into(),
|
||||
requested: "2026-09-03 01:00".into(),
|
||||
started: String::new(),
|
||||
finished: String::new(),
|
||||
duration: String::new(),
|
||||
status: status.into(),
|
||||
message: None,
|
||||
run_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_take_their_last_run_from_the_newest_matching_job() {
|
||||
let mut cards = cards();
|
||||
// Newest first, the way `jobs::list` returns them.
|
||||
let rows = vec![
|
||||
job_line_named(4, "generate-2026-09-03", "running"),
|
||||
job_line_named(3, "profile-rebuild", "ok"),
|
||||
job_line_named(2, "generate-2026-09-02", "ok"),
|
||||
];
|
||||
attach_last_runs(&mut cards, &rows);
|
||||
|
||||
let generate = cards.iter().find(|card| card.name == "generate").unwrap();
|
||||
assert_eq!(generate.last_id, Some(4), "the dated job matches its card");
|
||||
assert_eq!(generate.last_status.as_deref(), Some("running"));
|
||||
let rebuild = cards
|
||||
.iter()
|
||||
.find(|card| card.name == "profile-rebuild")
|
||||
.unwrap();
|
||||
assert_eq!(rebuild.last_id, Some(3));
|
||||
let never = cards
|
||||
.iter()
|
||||
.find(|card| card.last_id.is_none())
|
||||
.expect("a catalogue job with no recorded run");
|
||||
assert!(never.last_status.is_none());
|
||||
}
|
||||
|
||||
async fn app_with_runner(
|
||||
config: Config,
|
||||
runner: Arc<dyn crate::web::JobRunner>,
|
||||
|
||||
@@ -2495,7 +2495,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
page.contains("! config.toml on disk does not load:"),
|
||||
page.contains("config.toml on disk does not load:"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(page.contains("value=\"31\""), "{page}");
|
||||
|
||||
@@ -406,7 +406,7 @@ async fn stats(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut page = Page::new("Stats", viewer, "dashboard");
|
||||
let mut page = Page::new("Stats", viewer, "stats");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(StatsTemplate {
|
||||
page,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+144
-15
@@ -64,29 +64,47 @@
|
||||
h1, h2, h3 { @apply text-ink; font-optical-sizing:auto; }
|
||||
button, input, select, textarea { font:inherit; @apply rounded-sm border border-rule bg-paper px-2.5 py-1.5 text-ink transition-colors duration-150 placeholder:text-muted focus:border-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||
button { @apply cursor-pointer; }
|
||||
button:disabled, input:disabled, select:disabled, textarea:disabled { @apply cursor-not-allowed border-rule bg-paper-2 text-muted; }
|
||||
textarea { @apply min-h-28; }
|
||||
summary { @apply cursor-pointer; }
|
||||
table { @apply w-full border-collapse font-sans text-sm leading-snug; font-variant-numeric:tabular-nums; }
|
||||
thead { @apply sticky top-0 z-10 bg-paper; }
|
||||
th, td { @apply border-b border-rule px-2 py-2 text-left align-top; }
|
||||
th { @apply text-[0.72rem] font-semibold uppercase tracking-[0.1em] text-muted; }
|
||||
tbody tr { @apply transition-colors duration-150 hover:bg-paper-2; }
|
||||
code, pre { @apply font-mono; }
|
||||
meter { @apply block h-2 w-full rounded-sm; -webkit-appearance:none; appearance:none; background:var(--rule); }
|
||||
meter::-webkit-meter-bar { background:var(--rule); border:0; border-radius:2px; height:0.5rem; }
|
||||
meter::-webkit-meter-optimum-value, meter::-webkit-meter-suboptimum-value, meter::-webkit-meter-even-less-good-value { background:var(--good); border-radius:2px; }
|
||||
meter.over::-webkit-meter-optimum-value, meter.over::-webkit-meter-suboptimum-value, meter.over::-webkit-meter-even-less-good-value { background:var(--down); }
|
||||
meter::-moz-meter-bar { background:var(--good); border-radius:2px; }
|
||||
meter.over::-moz-meter-bar { background:var(--down); }
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
|
||||
}
|
||||
|
||||
|
||||
@layer components {
|
||||
/* ---------- shared controls ---------- */
|
||||
.btn, .button { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-rule-strong bg-transparent px-3 py-1.5 font-sans text-sm font-medium text-ink no-underline transition-colors duration-150 hover:bg-ink hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||
.btn-primary { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-ink bg-ink px-3 py-1.5 font-sans text-sm font-medium text-paper no-underline transition-colors duration-150 hover:border-accent hover:bg-accent hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
|
||||
.badge { @apply inline-flex items-center rounded-full px-2 py-0.5 font-sans text-xs font-medium; }
|
||||
.btn-quiet { @apply inline-flex min-h-9 items-center gap-1 rounded-sm border-0 bg-transparent px-2 py-1 font-sans text-sm text-muted underline decoration-rule underline-offset-4 hover:text-ink hover:decoration-accent; }
|
||||
.btn:disabled, .btn-primary:disabled, .btn-danger:disabled { @apply cursor-not-allowed border-rule bg-paper-2 text-muted hover:bg-paper-2 hover:text-muted; }
|
||||
.btn-danger, button.danger { @apply inline-flex min-h-9 items-center justify-center rounded-sm border border-down bg-transparent px-2.5 py-1 font-sans text-sm font-medium text-down transition-colors duration-150 hover:bg-down hover:text-paper; }
|
||||
|
||||
.badge { @apply inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 font-sans text-xs font-medium leading-5; color:var(--ink-2); background:color-mix(in oklab, var(--ink) 8%, transparent); }
|
||||
.badge.selected, .badge.loved, .badge.ok { color:var(--loved); background:color-mix(in oklab, var(--loved) 14%, transparent); }
|
||||
.badge.good, .badge.assessed, .badge.triaged, .badge.degraded { color:var(--good); background:color-mix(in oklab, var(--good) 14%, transparent); }
|
||||
.badge.down, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
|
||||
.badge.shortlisted, .badge.admitted, .badge.eligible, .badge.cleared, .badge.reason, .badge.running, .badge.requested { color:var(--muted); background:color-mix(in oklab, var(--muted) 14%, transparent); }
|
||||
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1; }
|
||||
.kv dt { @apply text-muted; }
|
||||
.kv dd { @apply m-0 min-w-0; overflow-wrap:anywhere; }
|
||||
.badge.restart, .badge.warn { color:var(--warn); background:color-mix(in oklab, var(--warn) 16%, transparent); }
|
||||
|
||||
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1.5; }
|
||||
.kv dt { @apply text-sm text-muted; }
|
||||
.kv dd { @apply m-0 min-w-0 text-sm; overflow-wrap:anywhere; }
|
||||
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
|
||||
.scroll-x > table { min-width:max-content; }
|
||||
.scroll-x.tall { @apply max-h-[70vh] overflow-y-auto; overscroll-behavior-block:contain; }
|
||||
|
||||
.prose-body { @apply text-[1.0625rem] leading-[1.72] text-ink; }
|
||||
.prose-body > * + * { @apply mt-5; }
|
||||
.prose-body h2 { @apply mt-10 border-t border-rule pt-4 text-2xl font-semibold leading-[1.15] tracking-[-0.01em]; }
|
||||
@@ -100,32 +118,143 @@
|
||||
.prose-body ol { @apply list-decimal pl-6; }
|
||||
.prose-body li::marker { color:var(--muted); }
|
||||
.prose-body li + li { @apply mt-2; }
|
||||
|
||||
.rating { @apply my-5 flex flex-wrap items-center font-sans text-sm; }
|
||||
.rating-prompt { @apply mr-3 text-[0.72rem] uppercase tracking-[0.1em] text-muted; }
|
||||
.rating button[data-label]:not(.clear) { @apply -ml-px min-h-10 rounded-none border-rule bg-transparent px-3 py-1.5 text-ink first:ml-0 first:rounded-l-sm last:rounded-r-sm hover:z-10 hover:border-ink hover:bg-paper-2; }
|
||||
.rating button[data-label]:not(.clear).active { @apply z-10 border-ink bg-ink text-paper; }
|
||||
.rating button.clear { @apply ml-2 min-h-10 border-0 bg-transparent px-2 text-muted underline decoration-rule underline-offset-4 hover:text-ink; }
|
||||
.rating button.clear.active { @apply font-semibold text-ink; }
|
||||
.rating-note { @apply mt-2 flex basis-full items-center gap-2 text-muted; }
|
||||
.rating-note { @apply mt-2 flex w-full max-w-md basis-full items-center gap-2 text-muted; }
|
||||
.rating-note input { @apply flex-1; }
|
||||
|
||||
.flash { @apply my-4 border-l-4 border-accent bg-paper-2 px-4 py-3 font-sans text-sm; }
|
||||
.error { @apply border-l-4 border-down bg-paper-2 px-4 py-3 font-sans text-sm text-down; }
|
||||
.notice { @apply border-l-4 border-warn bg-paper-2 px-4 py-3 font-sans text-sm text-ink-2; }
|
||||
.dashboard { @apply mx-auto my-8 max-w-7xl px-4 font-sans text-base leading-normal sm:px-6; }
|
||||
.dashboard h1 { @apply text-2xl font-semibold; } .dashboard h2 { @apply mt-7 text-lg font-semibold; } .dashboard h3 { @apply font-semibold; }
|
||||
.cards { @apply my-4 grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(20rem,100%),1fr)); }
|
||||
ul.error, ul.notice { @apply list-disc pl-8; }
|
||||
|
||||
/* ---------- dashboard shell ---------- */
|
||||
.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 font-sans text-base leading-normal sm:px-6; }
|
||||
.dashboard > * + * { @apply mt-4; }
|
||||
.dashboard h1 { @apply text-2xl font-semibold leading-tight tracking-[-0.01em]; }
|
||||
.dashboard h2 { @apply text-lg font-semibold; }
|
||||
.dashboard h3 { @apply text-sm font-semibold; }
|
||||
.dashboard > h2 { @apply mt-9 border-t border-rule pt-3 text-[0.78rem] font-semibold uppercase tracking-[0.14em] text-muted; }
|
||||
.dashboard > p, .dashboard > ul, .dashboard > ol { @apply text-sm; }
|
||||
.dashboard code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.85em]; }
|
||||
.dashboard pre code { @apply bg-transparent p-0; }
|
||||
.dashboard table a { @apply text-ink underline decoration-rule underline-offset-2 hover:text-ink hover:decoration-accent; }
|
||||
|
||||
.page-head { @apply flex flex-wrap items-end justify-between gap-x-6 gap-y-3 border-b border-rule pb-4; }
|
||||
.page-head > div:first-child { @apply min-w-0; }
|
||||
.dashboard > .page-head + * { @apply mt-6; }
|
||||
.page-desc { @apply mt-1.5 max-w-[74ch] text-sm text-muted; }
|
||||
.page-desc a { @apply text-accent; }
|
||||
.page-actions { @apply flex flex-wrap items-center gap-2 font-sans text-sm; }
|
||||
.page-eyebrow { @apply text-[0.7rem] uppercase tracking-[0.14em] text-muted; }
|
||||
|
||||
.tiles { @apply grid gap-3; grid-template-columns:repeat(auto-fit,minmax(min(10.5rem,100%),1fr)); }
|
||||
.tile { @apply min-w-0 border border-rule px-3.5 py-3; }
|
||||
.tile-label { @apply block text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted; }
|
||||
.tile-num { @apply mt-1 block text-[1.55rem] font-semibold leading-tight tracking-[-0.01em]; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.tile-delta { @apply mt-1 block text-xs text-muted; overflow-wrap:anywhere; }
|
||||
|
||||
.cards { @apply grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(20rem,100%),1fr)); }
|
||||
.card { @apply min-w-0 border border-rule p-4; }
|
||||
.muted, .meta { @apply text-muted; } .num { @apply whitespace-nowrap text-right tabular-nums; }
|
||||
.filters, form.filters, form.inline { @apply my-4 flex flex-wrap items-end gap-3 font-sans text-sm; }
|
||||
.filters label, form.filters label { @apply grid gap-1; }
|
||||
.card > * + * { @apply mt-3; }
|
||||
.card h2 { @apply text-base font-semibold; }
|
||||
.card h3 { @apply text-base font-semibold; }
|
||||
.card > h2:first-child, .card > h3:first-child { @apply mt-0; }
|
||||
.card p, .card li { @apply text-sm; }
|
||||
|
||||
.muted, .meta { @apply text-muted; }
|
||||
.jobs a, .picks > li > a:first-child { @apply text-ink no-underline hover:text-accent; }
|
||||
.num { @apply whitespace-nowrap text-right tabular-nums; }
|
||||
.cell-wrap { @apply min-w-[10rem] max-w-[24rem] whitespace-normal; overflow-wrap:anywhere; }
|
||||
.cell-tight { @apply whitespace-nowrap; }
|
||||
|
||||
.filters { @apply flex flex-wrap items-end gap-x-4 gap-y-3 border-y border-rule py-3 font-sans text-sm; }
|
||||
.filters label { @apply grid gap-1 text-[0.66rem] font-medium uppercase tracking-[0.1em] text-muted; }
|
||||
.filters input, .filters select, .filters textarea { @apply text-sm normal-case tracking-normal text-ink; }
|
||||
.filters select, .filters input[type="date"] { @apply min-w-32; }
|
||||
.filters .filter-actions { @apply flex items-center gap-3; }
|
||||
.form-inline { @apply flex flex-wrap items-center gap-3 font-sans text-sm; }
|
||||
.form-inline label { @apply flex items-center gap-2 text-sm; }
|
||||
.table-filter { @apply my-2 w-80 max-w-full font-sans text-sm; }
|
||||
.pager, .run-nav, .tabs { @apply my-4 flex flex-wrap items-center gap-4 font-sans text-sm; }
|
||||
pre.block, pre.preview, pre.journal { @apply overflow-auto whitespace-pre-wrap rounded-sm bg-paper-2 p-3 font-mono text-sm; overflow-wrap:anywhere; }
|
||||
.funnel > * { @apply my-0.5 min-w-px bg-good; }
|
||||
.spark { @apply h-auto max-w-full; }
|
||||
|
||||
.pager { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-1 font-sans text-sm text-muted; }
|
||||
.pager a { @apply text-ink no-underline hover:text-accent; }
|
||||
.run-nav { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 font-sans text-sm; }
|
||||
.run-nav a { @apply text-ink no-underline hover:text-accent; }
|
||||
.tabs { @apply flex flex-wrap items-center gap-x-6 border-b border-rule font-sans text-[0.72rem] font-medium uppercase tracking-[0.12em]; }
|
||||
.tabs a { @apply -mb-px flex min-h-10 items-center border-b-2 border-transparent text-muted no-underline hover:text-ink; }
|
||||
.tabs a.active { @apply border-accent text-ink; }
|
||||
.pagination { @apply font-sans text-sm text-muted; }
|
||||
|
||||
tr.superseded > td { @apply text-muted; }
|
||||
tr.superseded a { @apply text-muted; }
|
||||
|
||||
/* the rating widget has to fit inside a table cell */
|
||||
td .rating, .picks .rating { @apply my-1.5 max-w-[15rem] gap-y-1 text-xs; }
|
||||
td .rating-prompt, .picks .rating-prompt { @apply hidden; }
|
||||
td .rating button[data-label]:not(.clear), .picks .rating button[data-label]:not(.clear) { @apply min-h-8 px-2.5 py-1 text-xs; }
|
||||
td .rating button.clear, .picks .rating button.clear { @apply ml-2 min-h-8 px-0 text-xs; }
|
||||
td .rating-note { @apply mt-1 max-w-[15rem] gap-1.5 text-[0.7rem]; }
|
||||
td .rating-note input { @apply px-1.5 py-0.5 text-xs; }
|
||||
.picks .rating { @apply max-w-none; }
|
||||
|
||||
pre.block, pre.preview, pre.journal { @apply overflow-auto whitespace-pre-wrap rounded-sm bg-paper-2 p-3 font-mono text-[0.8125rem] leading-relaxed; overflow-wrap:anywhere; }
|
||||
pre.block { @apply max-h-64; }
|
||||
pre.preview { @apply max-h-56; }
|
||||
pre.journal { @apply max-h-[34rem] p-4; }
|
||||
pre.prompt, pre.learned { @apply max-h-[32rem]; }
|
||||
td > pre.preview { @apply max-h-24 max-w-md bg-transparent p-0 text-xs text-muted; }
|
||||
|
||||
details.disclosure { @apply border border-rule; }
|
||||
details.disclosure > summary { @apply px-4 py-3 font-sans text-sm font-medium; }
|
||||
details.disclosure[open] > summary { @apply border-b border-rule; }
|
||||
details.disclosure > .disclosure-body { @apply px-4 py-3 text-sm; }
|
||||
details.disclosure > .disclosure-body > * + * { @apply mt-3; }
|
||||
details.signals > summary { @apply min-w-0; }
|
||||
.signals-body { @apply mt-2 max-w-2xl border-l-2 border-rule pl-3 text-xs; }
|
||||
.signals-body > * + * { @apply mt-2; }
|
||||
.signals-body ul { @apply list-disc pl-5; }
|
||||
|
||||
.funnel { @apply w-full min-w-[6rem] rounded-sm bg-paper-2; }
|
||||
.funnel > * { @apply block min-w-px rounded-sm bg-good; }
|
||||
.funnel-cell { @apply w-[14rem] min-w-[8rem] align-middle; }
|
||||
|
||||
.sparklines { @apply grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(18rem,100%),1fr)); }
|
||||
.spark-figure { @apply m-0 min-w-0 border border-rule p-3; }
|
||||
.spark-figure figcaption { @apply mb-2 text-[0.7rem] font-semibold uppercase tracking-[0.1em] text-ink; }
|
||||
.spark-figure figcaption .muted { @apply font-normal normal-case tracking-normal; }
|
||||
.spark { @apply h-auto w-full max-w-full; }
|
||||
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--ink); } .spark .line { stroke:var(--accent); }
|
||||
.spark-axis { @apply mt-1 flex justify-between text-[0.66rem] uppercase tracking-[0.08em]; }
|
||||
.spark-legend { @apply mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted; }
|
||||
.spark-legend li { @apply flex items-center gap-1.5; }
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
.settings-form > * + * { @apply mt-5; }
|
||||
.setting { @apply grid gap-2 border-t border-rule py-3 md:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] md:gap-6; }
|
||||
.setting:first-of-type { @apply border-t-0 pt-1; }
|
||||
.setting-label { @apply min-w-0; }
|
||||
.setting-label label { @apply inline-block; }
|
||||
.setting-label code { @apply bg-transparent p-0 text-[0.85rem] font-medium text-ink; overflow-wrap:anywhere; }
|
||||
.setting-input { @apply min-w-0; }
|
||||
.setting-input > input, .setting-input > select, .setting-input > textarea { @apply w-full max-w-lg; }
|
||||
.help, .default { @apply mt-1 text-xs text-muted; overflow-wrap:anywhere; }
|
||||
.setting-label .help { @apply max-w-[42ch]; }
|
||||
.secret { @apply text-sm text-ink-2; }
|
||||
code.lines { @apply mt-1 block whitespace-pre-wrap; }
|
||||
button.reset { @apply ml-1 inline min-h-0 rounded-none border-0 bg-transparent p-0 text-xs text-muted underline decoration-rule underline-offset-2 hover:text-ink; }
|
||||
.save-bar { @apply sticky bottom-0 z-20 -mx-4 mt-6 flex flex-wrap items-center justify-end gap-x-4 gap-y-2 border-t border-rule bg-paper px-4 py-3 sm:-mx-6 sm:px-6; box-shadow:0 -10px 18px -14px color-mix(in oklab, var(--ink) 55%, transparent); }
|
||||
.save-bar .meta { @apply mr-auto text-xs; }
|
||||
|
||||
@media (max-width:40rem) {
|
||||
.kv { @apply block; } .kv dt { @apply mt-2; }
|
||||
.rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-2; }
|
||||
.filters label { @apply w-full; }
|
||||
.filters input, .filters select { @apply w-full; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +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="cell-wrap"><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
|
||||
<td class="cell-tight text-muted">{{ 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="cell-tight"><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
|
||||
<td class="cell-tight">{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
|
||||
<td class="cell-tight text-muted">{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted text-xs">({{ 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>
|
||||
<td class="cell-tight">{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
|
||||
<td class="cell-wrap text-muted">{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %}</td>
|
||||
</tr>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{% if pagination.pages() > 1 %}<nav class="pagination">Page {{ pagination.page }} of {{ pagination.pages() }}</nav>{% endif %}
|
||||
{% if pagination.pages() > 1 %}<nav class="pagination" aria-label="Pagination">Page <span class="text-ink">{{ pagination.page }}</span> of {{ pagination.pages() }}</nav>{% endif %}
|
||||
|
||||
@@ -1,8 +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">
|
||||
{% if signals.empty %}<p class="muted text-xs">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
|
||||
<div class="scroll-x"><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></div>
|
||||
<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 %}
|
||||
<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 %}<span class="muted">absent</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
|
||||
<p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% 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 class="page-eyebrow">Top interests</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 class="page-eyebrow">Nearest rated neighbours</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 %}
|
||||
|
||||
@@ -1 +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 %}
|
||||
{% if pager.pages > 1 %}<nav class="pager" aria-label="Pagination"><span>Page <span class="text-ink">{{ pager.page }}</span> of {{ pager.pages }} · {{ pager.total }} rows</span><span class="flex items-center gap-2">{% if let Some(href) = pager.prev_href %}<a class="btn" href="{{ href }}">← Previous</a>{% endif %}{% if let Some(href) = pager.next_href %}<a class="btn" href="{{ href }}">Next →</a>{% endif %}</span></nav>{% else %}<p class="pager">{{ pager.total }} row{% if pager.total != 1 %}s{% endif %}</p>{% endif %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<figure class="spark-figure"><figcaption>{{ spark.title }}{% if !spark.caption.is_empty() %} <span class="muted">· {{ spark.caption }}</span>{% endif %}</figcaption>
|
||||
{% if spark.empty %}<p class="muted">No data yet.</p>{% else %}<svg class="spark" viewBox="0 0 {{ spark.width }} {{ spark.height }}" width="{{ spark.width }}" height="{{ spark.height }}" role="img" aria-label="{{ spark.title }}">
|
||||
{% if spark.empty %}<p class="muted text-sm">No data yet.</p>{% else %}<svg class="spark" viewBox="0 0 {{ spark.width }} {{ spark.height }}" width="{{ spark.width }}" height="{{ spark.height }}" role="img" aria-label="{{ spark.title }}">
|
||||
{% for bar in spark.bars %}<rect class="s{{ bar.series }}" x="{{ bar.x }}" y="{{ bar.y }}" width="{{ bar.w }}" height="{{ bar.h }}"><title>{{ bar.label }}</title></rect>
|
||||
{% endfor %}{% if !spark.points.is_empty() %}<polyline class="line" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points="{{ spark.points }}"/>{% endif %}
|
||||
</svg>
|
||||
<div class="spark-axis muted"><span>{{ spark.first_label }}</span><span>{{ spark.last_label }}</span></div>
|
||||
{% if !spark.legend.is_empty() %}<ul class="spark-legend">{% for item in spark.legend %}<li><svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect class="s{{ item.series }}" width="10" height="10"/></svg> {{ item.name }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% if !spark.legend.is_empty() %}<ul class="spark-legend">{% for item in spark.legend %}<li><svg class="spark h-2.5 w-2.5 shrink-0" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect class="s{{ item.series }}" width="10" height="10"/></svg> {{ item.name }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% endif %}</figure>
|
||||
|
||||
@@ -1,69 +1,81 @@
|
||||
{% 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>
|
||||
<header class="page-head"><div>
|
||||
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/articles">Articles</a> › {{ id }}</p>
|
||||
<h1 class="mt-1"><a class="text-ink no-underline hover:text-accent" href="{{ url }}">{{ title }}</a></h1>
|
||||
<p class="page-desc"><a href="/dashboard/articles?feed={{ feed_id }}">{{ feed }}</a>{% if let Some(category) = category %} · {{ category }}{% endif %}{% if let Some(author) = author %} · {{ author }}{% endif %}</p>
|
||||
</div><div class="page-actions">{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="badge">unrated</span>{% endif %}<a class="btn" href="{{ url }}">Open source</a></div></header>
|
||||
|
||||
<h2>Article</h2>
|
||||
<div class="cards">
|
||||
<section class="card"><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>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>
|
||||
</section>
|
||||
<section class="card"><h2>Provenance</h2>
|
||||
<dl class="kv">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2>Your verdict</h2>
|
||||
{% include "_rating_widget.html" %}
|
||||
{% if let Some(text) = explain %}<details class="explain"><summary>Explain (text)</summary><pre class="block">{{ text }}</pre></details>{% endif %}
|
||||
{% if let Some(text) = explain %}<details class="explain disclosure" id="article-explain"><summary>Explain (text)</summary><div class="disclosure-body"><pre class="block">{{ text }}</pre></div></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 %}
|
||||
{% if assessments.is_empty() %}<p class="muted text-sm">No LLM assessment stored.</p>{% else %}<div class="cards">{% for assessment in assessments %}<section class="card">
|
||||
<h3 class="flex flex-wrap items-center gap-2">{{ 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 text-xs">{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · {{ assessment.assessed_at }}</p>{% else %}<dl class="kv">
|
||||
{% if assessment.stage == "deep" %}<dt>Quality</dt><dd class="tabular-nums">{{ assessment.score }}</dd><dt>Fit</dt><dd class="tabular-nums">{{ 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 class="tabular-nums">{{ 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 %}
|
||||
{% 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 class="cell-wrap">{{ facet.value }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% endif %}
|
||||
</section>{% endfor %}</div>{% 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">
|
||||
{% if history.is_empty() %}<p class="muted text-sm">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="cell-wrap"><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 class="cell-tight text-muted">{{ row.date }}</td>
|
||||
<td class="cell-tight"><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
|
||||
<td class="cell-tight text-muted">{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
|
||||
<td class="cell-tight text-muted">{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted text-xs">({{ 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>
|
||||
<td class="cell-wrap text-muted">{% 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">
|
||||
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">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 %}
|
||||
<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 class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
|
||||
</div>{% endif %}{% else %}<p class="muted text-sm">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 %}
|
||||
{% if let Some(embedding) = embedding %}<dl class="kv"><dt>Model</dt><dd>{{ embedding.model }}</dd><dt>Dimension</dt><dd class="tabular-nums">{{ 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 text-sm">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">
|
||||
{% if events.is_empty() %}<p class="muted text-sm">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="cell-tight text-muted">{{ event.event_at }}</td>
|
||||
<td class="cell-tight"><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>
|
||||
<td class="cell-tight text-muted">{{ event.kind }}</td>
|
||||
<td class="cell-tight text-muted">{{ event.source }}</td>
|
||||
<td class="cell-tight">{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
<td class="cell-tight">{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||
<td class="cell-wrap text-muted">{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Articles</h1>
|
||||
<p class="page-desc">Every article the crawler has ever seen, with the last stage it reached, its scores and its verdict.</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/ratings">Ratings</a><a class="btn" href="/dashboard/runs">Runs</a></div></header>
|
||||
<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>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></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>
|
||||
@@ -11,24 +14,24 @@
|
||||
<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>
|
||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
|
||||
</form>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
<div class="scroll-x"><table class="articles" data-filter>
|
||||
<div class="scroll-x tall"><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="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
|
||||
<td class="cell-tight">{% 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="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
|
||||
<td class="cell-tight text-muted">{% 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>
|
||||
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
|
||||
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
|
||||
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="12" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard"{% if refresh %} data-refresh="5"{% endif %}>
|
||||
<h1><a href="/dashboard/jobs">Jobs</a> › Job {{ job.id }} · {{ job.name }} <span class="badge {{ job.status }}">{{ job.status }}</span></h1>
|
||||
{% if !description.is_empty() %}<p class="muted">{{ description }}</p>{% endif %}
|
||||
<header class="page-head"><div>
|
||||
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/jobs">Jobs</a> › Job {{ job.id }}</p>
|
||||
<h1 class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1"><span class="font-mono">{{ job.name }}</span> <span class="badge {{ job.status }}">{{ job.status }}</span></h1>
|
||||
{% if !description.is_empty() %}<p class="page-desc">{{ description }}</p>{% endif %}
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/jobs">All jobs</a>{% if let Some(run_id) = job.run_id %}<a class="btn" href="/dashboard/runs/{{ run_id }}">Run {{ run_id }}</a>{% endif %}</div></header>
|
||||
<div class="cards">
|
||||
<section class="card"><h2>Job</h2>
|
||||
<dl class="kv">
|
||||
@@ -12,7 +15,7 @@
|
||||
{% if let Some(message) = job.message %}<dt>Message</dt><dd class="message">{{ message }}</dd>{% endif %}
|
||||
{% if let Some(run_id) = job.run_id %}<dt>Run</dt><dd><a href="/dashboard/runs/{{ run_id }}">Run {{ run_id }}</a></dd>{% endif %}
|
||||
</dl>
|
||||
{% if refresh %}<p class="muted">This page reloads every 5 seconds while the job is requested or running.</p>{% endif %}
|
||||
{% if refresh %}<p class="muted text-xs">This page reloads every 5 seconds while the job is requested or running.</p>{% endif %}
|
||||
</section>
|
||||
<section class="card"><h2>Unit</h2>
|
||||
{% if let Some(status) = status %}<dl class="kv">
|
||||
@@ -22,11 +25,11 @@
|
||||
{% if let Some(exited) = status.exited %}<dt>Main exited</dt><dd>{{ exited }}</dd>{% endif %}
|
||||
</dl>{% endif %}
|
||||
{% if let Some(error) = status_error %}<p class="error">Unit status unavailable: {{ error }}</p>{% endif %}
|
||||
<p class="muted">From <code>systemctl show {{ unit }}</code>.</p>
|
||||
<p class="muted text-xs">From <code>systemctl show {{ unit }}</code>.</p>
|
||||
</section>
|
||||
</div>
|
||||
<h2>Log</h2>
|
||||
<h2>Journal</h2>
|
||||
{% if let Some(error) = log_error %}<p class="error">Journal unavailable: {{ error }}</p>{% endif %}
|
||||
<p class="muted">Last {{ log_lines }} lines of <code>journalctl -u {{ unit }}</code>.</p>
|
||||
<p class="muted text-sm">Last {{ log_lines }} lines of <code>journalctl -u {{ unit }}</code>.</p>
|
||||
<pre class="preview journal">{{ log }}</pre>
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Jobs</h1>
|
||||
{% if jobs_enabled %}<p class="muted">Each job starts <code>daily-epub-job@<name>.service</code> through systemd; the unit runs <code>daily-epub job run <name></code> and records itself here. Jobs that take the run lock wait for nothing: a second <code>generate</code> while one is running fails immediately.</p>{% else %}<p class="notice">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run the commands by hand instead.</p>{% endif %}
|
||||
<div class="cards job-cards">{% for card in cards %}<section class="card">
|
||||
<h2>{{ card.name }}</h2>
|
||||
<p>{{ card.description }}</p>
|
||||
{% if let Some(lock) = card.lock %}<p class="muted">Takes the <code>{{ lock }}</code> lock.</p>{% endif %}
|
||||
<form method="post" action="/dashboard/jobs/{{ card.name }}" class="inline"{% if card.dangerous %} data-confirm="Start {{ card.name }}? This republishes the issue of that date."{% endif %}>
|
||||
{% if card.dated %}<label>Date <input type="date" name="date" value="{{ today }}"></label>{% endif %}
|
||||
<button type="submit"{% if !jobs_enabled %} disabled{% endif %}>Start</button>
|
||||
<p class="page-desc">{% if jobs_enabled %}Each job starts <code>daily-epub-job@<name>.service</code> through systemd; the unit runs <code>daily-epub job run <name></code> and records itself here.{% else %}Jobs are disabled on this server; the catalogue below is read-only.{% endif %}</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/runs">Runs</a><a class="btn" href="/dashboard/settings">Settings</a></div></header>
|
||||
{% if jobs_enabled %}<p class="muted text-sm">Jobs that take the run lock wait for nothing: a second <code>generate</code> while one is running fails immediately.</p>{% else %}<p class="notice">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run the commands by hand instead.</p>{% endif %}
|
||||
<div class="cards job-cards">{% for card in cards %}<section class="card flex flex-col">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<h2 class="font-mono text-base">{{ card.name }}</h2>
|
||||
{% if let Some(status) = card.last_status %}<span class="badge {{ status }}">{{ status }}</span>{% else %}<span class="muted text-xs uppercase tracking-[0.1em]">never run</span>{% endif %}
|
||||
</div>
|
||||
<p class="text-muted">{{ card.description }}</p>
|
||||
<p class="text-xs text-muted">{% if let Some(id) = card.last_id %}Last started <a href="/dashboard/jobs/{{ id }}">{% if let Some(when) = card.last_requested %}{{ when }}{% else %}job {{ id }}{% endif %}</a>{% else %}No run recorded yet.{% endif %}{% if let Some(lock) = card.lock %} · takes the <code class="whitespace-nowrap">{{ lock }}</code> lock{% endif %}</p>
|
||||
<form method="post" action="/dashboard/jobs/{{ card.name }}" class="form-inline mt-auto pt-1"{% if card.dangerous %} data-confirm="Start {{ card.name }}? This republishes the issue of that date."{% endif %}>
|
||||
{% if card.dated %}<label class="text-xs uppercase tracking-[0.1em] text-muted">Date <input type="date" name="date" value="{{ today }}" class="text-sm normal-case tracking-normal text-ink"></label>{% endif %}
|
||||
<button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Start</button>
|
||||
</form>
|
||||
</section>{% endfor %}</div>
|
||||
<h2>History</h2>
|
||||
{% if jobs.is_empty() %}<p class="muted">No jobs recorded yet.</p>{% else %}<div class="scroll-x"><table data-filter>
|
||||
<thead><tr><th>job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th>run</th></tr></thead>
|
||||
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
|
||||
<tbody>{% for job in jobs %}<tr>
|
||||
<td><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
||||
<td>{{ job.name }}</td>
|
||||
<td>{{ job.requested_by }}</td>
|
||||
<td>{{ job.requested }}</td>
|
||||
<td>{{ job.started }}</td>
|
||||
<td>{{ job.finished }}</td>
|
||||
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
||||
<td class="cell-tight font-mono text-xs">{{ job.name }}</td>
|
||||
<td class="cell-tight text-muted">{{ job.requested_by }}</td>
|
||||
<td class="cell-tight text-muted">{{ job.requested }}</td>
|
||||
<td class="cell-tight text-muted">{{ job.started }}</td>
|
||||
<td class="cell-tight text-muted">{{ job.finished }}</td>
|
||||
<td class="num">{{ job.duration }}</td>
|
||||
<td><span class="badge {{ job.status }}">{{ job.status }}</span></td>
|
||||
<td class="message">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
|
||||
<td>{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
|
||||
<td class="cell-tight"><span class="badge {{ job.status }}">{{ job.status }}</span></td>
|
||||
<td class="message cell-wrap text-muted">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
|
||||
<td class="num">{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,41 +1,55 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Overview</h1>
|
||||
<p class="page-desc">The state of the paper right now: the last run, today's spend, this week's verdicts and anything waiting on you.</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/runs">Runs</a><a class="btn" href="/dashboard/jobs">Jobs</a><a class="btn" href="/dashboard/stats">Stats</a></div></header>
|
||||
|
||||
<div class="tiles">
|
||||
<div class="tile"><span class="tile-label">Last run</span>{% if let Some(run) = last_run %}<span class="tile-num">{{ run.date }}</span><span class="tile-delta"><span class="badge {{ run.status }}">{{ run.status }}</span> · {{ run.duration }}</span>{% else %}<span class="tile-num text-muted">—</span><span class="tile-delta">no runs yet</span>{% endif %}</div>
|
||||
<div class="tile"><span class="tile-label">Warnings</span>{% if let Some(run) = last_run %}<span class="tile-num{% if run.warnings > 0 %} text-warn{% endif %}">{{ run.warnings }}</span><span class="tile-delta">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">on run {{ run.id }}</a>{% else %}clean run{% endif %}</span>{% else %}<span class="tile-num text-muted">—</span><span class="tile-delta">no runs yet</span>{% endif %}</div>
|
||||
<div class="tile"><span class="tile-label">Verdicts, 7 days</span><span class="tile-num">{{ ratings_total }}</span><span class="tile-delta"><a href="/dashboard/ratings">rating history</a></span></div>
|
||||
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
|
||||
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="muted text-xs">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 run.warnings > 0 %}<p class="text-sm"><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>
|
||||
{% if budget.is_empty() %}<p class="muted">No providers configured.</p>{% else %}<dl class="kv budget grid-cols-1 gap-y-3">
|
||||
{% for line in budget %}<div><div class="flex flex-wrap items-baseline justify-between gap-2"><dt class="font-medium text-ink">{{ line.provider }}</dt><dd class="text-muted">{{ line.spent }} of {{ line.ceiling }}{% if line.over %} · <strong class="text-down">ceiling reached</strong>{% endif %}</dd></div><dd class="mt-1"><meter value="{{ line.value }}" max="{{ line.max }}"{% if line.over %} class="over"{% endif %}></meter></dd></div>
|
||||
{% endfor %}</dl>{% endif %}
|
||||
<p class="muted">Spend on the current UTC day across every recorded run.</p>
|
||||
<p class="muted text-xs">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>
|
||||
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the last seven days.</p>{% else %}<p class="flex flex-wrap items-center gap-x-3 gap-y-1.5">{{ ratings_total }} verdict{% if ratings_total != 1 %}s{% endif %}{% for count in ratings %}<span class="inline-flex items-center gap-1.5"><span class="badge {{ count.label }}">{{ count.label }}</span> <span class="tabular-nums">{{ count.count }}</span></span>{% endfor %}</p>{% endif %}
|
||||
<p class="text-sm"><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 %}
|
||||
{% if !active_jobs.is_empty() %}<ul class="jobs divide-y divide-rule border-y border-rule">{% for job in active_jobs %}<li class="flex flex-wrap items-baseline gap-x-2 py-1.5"><a class="font-mono text-xs" href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted text-xs">requested {{ job.requested }}</span></li>{% endfor %}</ul>{% endif %}
|
||||
{% if !finished_jobs.is_empty() %}<p class="page-eyebrow">Recently finished</p><ul class="jobs divide-y divide-rule border-y border-rule">{% for job in finished_jobs %}<li class="flex flex-wrap items-baseline gap-x-2 py-1.5"><a class="font-mono text-xs" href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted text-xs">{{ job.finished }}</span>{% if let Some(message) = job.message %} <span class="muted text-xs">— {{ message }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
|
||||
{% endif %}
|
||||
<p><a href="/dashboard/jobs">All jobs</a></p>
|
||||
<p class="text-sm"><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>
|
||||
<ul class="warnings error list-disc pl-5">{% for line in config_warnings %}<li>{{ line }}</li>{% endfor %}</ul>
|
||||
<p class="text-sm"><a href="/dashboard/settings">Settings</a></p>
|
||||
</section>{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Last 30 runs</h2>
|
||||
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
|
||||
|
||||
<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>
|
||||
{% if unrated.is_empty() %}<p class="muted text-sm">Every pick from the last three issues has a verdict.</p>{% else %}<p class="muted text-sm">Picks from the last three issues without a verdict yet.</p>
|
||||
<ul class="picks divide-y divide-rule border-y border-rule">{% for pick in unrated %}<li class="py-3"><a class="font-medium text-ink no-underline hover:text-accent" href="{{ pick.issue_href }}">{{ pick.title }}</a> <span class="muted text-xs">· {{ pick.feed }} · {{ pick.issue_date }}</span>
|
||||
{% let widget = pick.widget %}{% include "_rating_widget.html" %}</li>{% endfor %}</ul>{% endif %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard profile">
|
||||
<header class="page-head"><div>
|
||||
<h1>Profile</h1>
|
||||
<p class="page-desc">The standing taste file the curator reads before every run: what you like, what the OPML declares, and what the editor model has learned from your verdicts.</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/ratings">Ratings</a><a class="btn" href="/dashboard/settings#curation.feedback">Feedback settings</a></div></header>
|
||||
|
||||
<div class="profile-grid">
|
||||
<div class="profile-editor">
|
||||
<h2>profile.md</h2>
|
||||
<p class="meta"><code>{{ path }}</code>{% if !exists %} — <strong>missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is parsed one interest per line; everything else goes into the system prompt verbatim.</p>
|
||||
<form method="post" action="/dashboard/profile" class="profile-form">
|
||||
<textarea name="content" rows="28" spellcheck="true" required>{{ content }}</textarea>
|
||||
<div><button type="submit">Save</button> <span class="meta">The next run rebuilds the system prompt from the saved file.</span></div>
|
||||
<div class="profile-grid grid gap-6 lg:grid-cols-2">
|
||||
<div class="profile-editor min-w-0">
|
||||
<h3 class="font-mono text-base">profile.md</h3>
|
||||
<p class="meta mt-1 text-xs"><code>{{ path }}</code>{% if !exists %} — <strong class="text-down">missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is parsed one interest per line; everything else goes into the system prompt verbatim.</p>
|
||||
<form method="post" action="/dashboard/profile" class="profile-form mt-3">
|
||||
<textarea name="content" rows="24" spellcheck="true" required class="w-full font-mono text-sm">{{ content }}</textarea>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3"><button class="btn-primary" type="submit">Save</button> <span class="meta text-xs">The next run rebuilds the system prompt from the saved file.</span></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="profile-preview">
|
||||
<h2>What the loader parses</h2>
|
||||
<h3>Passthrough sections</h3>
|
||||
{% if preview_body.trim().is_empty() %}<p class="meta">Nothing passes through — the file is empty or only has an Interests section.</p>{% else %}<pre class="preview">{{ preview_body }}</pre>{% endif %}
|
||||
<h3>Extracted <code>## Interests</code> lines</h3>
|
||||
{% if preview_interests.is_empty() %}<p class="meta">None. The prompt uses the OPML interests alone.</p>{% else %}<ul>{% for interest in preview_interests %}<li>{{ interest }}</li>{% endfor %}</ul>{% endif %}
|
||||
<div class="profile-preview min-w-0">
|
||||
<h3 class="text-base">What the loader parses</h3>
|
||||
<p class="meta mt-1 text-xs">A live read of the text on the left, exactly as <code>curate::profile</code> splits it.</p>
|
||||
<h4 class="mt-4 page-eyebrow">Passthrough sections</h4>
|
||||
{% if preview_body.trim().is_empty() %}<p class="meta mt-1 text-sm">Nothing passes through — the file is empty or only has an Interests section.</p>{% else %}<pre class="preview mt-2">{{ preview_body }}</pre>{% endif %}
|
||||
<h4 class="mt-5 page-eyebrow">Extracted <code>## Interests</code> lines</h4>
|
||||
{% if preview_interests.is_empty() %}<p class="meta mt-1 text-sm">None. The prompt uses the OPML interests alone.</p>{% else %}<ul class="mt-2 list-disc space-y-1 pl-5 text-sm marker:text-muted">{% for interest in preview_interests %}<li>{{ interest }}</li>{% endfor %}</ul>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>History</h2>
|
||||
{% if versions.is_empty() %}<p class="meta">No saved versions yet. The first save records the current file as version 1.</p>{% else %}
|
||||
<p class="meta">Each row is the text that was replaced by a save (or a restore). Restore writes it back and records what is on disk now as a new version.</p>
|
||||
<div class="scroll-x"><table class="versions">
|
||||
<thead><tr><th>Version</th><th>Replaced at</th><th>By</th><th>Size</th><th>Preview</th><th></th></tr></thead>
|
||||
{% if versions.is_empty() %}<p class="meta text-sm">No saved versions yet. The first save records the current file as version 1.</p>{% else %}
|
||||
<p class="meta text-sm">Each row is the text that was replaced by a save (or a restore). Restore writes it back and records what is on disk now as a new version.</p>
|
||||
<div class="scroll-x tall"><table class="versions">
|
||||
<thead><tr><th class="num">Version</th><th>Replaced at</th><th>By</th><th class="num">Size</th><th>Preview</th><th></th></tr></thead>
|
||||
<tbody>{% for version in versions %}<tr>
|
||||
<td>#{{ version.id }}</td>
|
||||
<td>{{ version.saved_at }}</td>
|
||||
<td>{{ version.saved_by }}</td>
|
||||
<td>{{ version.bytes }} bytes</td>
|
||||
<td class="num">#{{ version.id }}</td>
|
||||
<td class="cell-tight text-muted">{{ version.saved_at }}</td>
|
||||
<td class="cell-tight">{{ version.saved_by }}</td>
|
||||
<td class="num text-muted">{{ version.bytes }} bytes</td>
|
||||
<td><pre class="preview">{{ version.preview }}</pre></td>
|
||||
<td><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button type="submit">Restore</button></form></td>
|
||||
<td class="cell-tight"><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button class="btn" type="submit">Restore</button></form></td>
|
||||
</tr>{% endfor %}</tbody></table></div>
|
||||
{% endif %}
|
||||
|
||||
<h2>Standing interests</h2>
|
||||
<p class="meta"><code>{{ opml_path }}</code> · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the <code>## Interests</code> lines above is what the prompt uses; edit the OPML file to change them.</p>
|
||||
{% if !opml_error.is_empty() %}<p class="error">! {{ opml_error }}</p>{% endif %}
|
||||
{% if !themes.is_empty() %}<dl class="kv themes">{% for theme in themes %}<dt>{{ theme.name }} <span class="meta">({{ theme.count }})</span></dt><dd>{{ theme.members }}</dd>{% endfor %}</dl>{% endif %}
|
||||
<p class="meta text-sm"><code>{{ opml_path }}</code> · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the <code>## Interests</code> lines above is what the prompt uses; edit the OPML file to change them.</p>
|
||||
{% if !opml_error.is_empty() %}<p class="error">{{ opml_error }}</p>{% endif %}
|
||||
{% if !themes.is_empty() %}<dl class="kv themes">{% for theme in themes %}<dt class="text-ink">{{ theme.name }} <span class="meta">({{ theme.count }})</span></dt><dd class="text-muted">{{ theme.members }}</dd>{% endfor %}</dl>{% endif %}
|
||||
|
||||
<h2>Learned adjustments</h2>
|
||||
<p class="meta">Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}<strong>A rebuild is due</strong> — the next run performs it, or start it now.{% else %}The next scheduled rebuild is at least {{ rebuild_interval_days }} days after the last one; the next run performs it when due.{% endif %}</p>
|
||||
{% if learned.trim().is_empty() %}<p class="meta">No learned adjustments stored yet.</p>{% else %}<pre class="preview learned">{{ learned }}</pre>{% endif %}
|
||||
<form method="post" action="/dashboard/jobs/profile-rebuild" class="inline" data-confirm="Rebuild the learned adjustments now? This calls the editor model."><button type="submit"{% if !jobs_enabled %} disabled{% endif %}>Rebuild profile now</button>{% if !jobs_enabled %} <span class="meta">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub profile rebuild</code> instead.</span>{% else %} <span class="meta">Starts the <code>profile-rebuild</code> job through systemd.</span>{% endif %}</form>
|
||||
<p class="meta text-sm">Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}<strong class="text-warn">A rebuild is due</strong> — the next run performs it, or start it now.{% else %}The next scheduled rebuild is at least {{ rebuild_interval_days }} days after the last one; the next run performs it when due.{% endif %}</p>
|
||||
{% if learned.trim().is_empty() %}<p class="meta text-sm">No learned adjustments stored yet.</p>{% else %}<pre class="preview learned">{{ learned }}</pre>{% endif %}
|
||||
<form method="post" action="/dashboard/jobs/profile-rebuild" class="form-inline" data-confirm="Rebuild the learned adjustments now? This calls the editor model."><button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Rebuild profile now</button>{% if !jobs_enabled %} <span class="meta text-xs">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub profile rebuild</code> instead.</span>{% else %} <span class="meta text-xs">Starts the <code>profile-rebuild</code> job through systemd.</span>{% endif %}</form>
|
||||
|
||||
<h2>System prompt</h2>
|
||||
<p class="meta">Stored after the last run: version {{ prompt_version }}, {{ prompt_chars }} characters, {{ prompt_verdicts }} verdict lines. Every LLM call of a run receives exactly this text.</p>
|
||||
{% if prompt.is_empty() %}<p class="meta">No prompt has been built yet.</p>{% else %}<details id="profile-prompt"><summary>Show the system prompt</summary><pre class="preview prompt">{{ prompt }}</pre></details>{% endif %}
|
||||
<p class="meta text-sm">Stored after the last run: version {{ prompt_version }}, {{ prompt_chars }} characters, {{ prompt_verdicts }} verdict lines. Every LLM call of a run receives exactly this text.</p>
|
||||
{% if prompt.is_empty() %}<p class="meta text-sm">No prompt has been built yet.</p>{% else %}<details id="profile-prompt" class="disclosure"><summary>Show the system prompt</summary><div class="disclosure-body"><pre class="preview prompt">{{ prompt }}</pre></div></details>{% endif %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard ratings">
|
||||
<header class="page-head"><div>
|
||||
<h1>Ratings</h1>
|
||||
<p class="preference-summary">{{ summary_line }}</p>
|
||||
<p class="page-desc">{{ summary_line }}</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/profile">Profile</a><a class="btn" href="/dashboard/articles?rated=unrated">Unrated articles</a></div></header>
|
||||
{% if no_embedding_count > 0 %}<p class="notice">{{ no_embedding_count }} rated articles have no embedding and cannot act as neighbours — run the <code>features-backfill</code> job (<code>features backfill --rated-only</code>).</p>{% endif %}
|
||||
<details id="ratings-how" class="how">
|
||||
<details id="ratings-how" class="how disclosure">
|
||||
<summary>How ratings enter the algorithm</summary>
|
||||
<ol>
|
||||
<div class="disclosure-body">
|
||||
<ol class="list-decimal space-y-3 pl-5 marker:text-muted">
|
||||
<li><strong>Prompt verdict block.</strong> The {{ how.verdicts_in_prompt }} most recent non-cleared verdicts, newest first, are written into every LLM call's system prompt as one line each (<code>LOVED | title | feed | summary</code>). Only the rank matters here — a verdict never ages out of this block, it is pushed out by newer ones. Tune <a href="/dashboard/settings#curation.feedback"><code>curation.feedback.verdicts_in_prompt</code></a>.</li>
|
||||
<li><strong>Weekly learned adjustments.</strong> Every {{ how.rebuild_interval_days }} days the editor model rewrites the profile's "Learned adjustments" bullets from the {{ how.max_ratings_in_rebuild }} most recent non-cleared verdicts, including notes and deep-assessment facets. See the <a href="/dashboard/profile">Profile</a> page.</li>
|
||||
<li><strong>Rated-neighbour signal.</strong> Each verdict with an embedding is an example with weight <code>value × 0.5^(age / {{ how.half_life_days }} days)</code>, where loved = {{ how.loved }}, good = {{ how.good }}, not for me = {{ how.not_for_me }}; ratings older than {{ how.lookback_days }} days are not loaded. A candidate's signal is the weighted mean cosine to its {{ how.neighbour_k }} nearest positive examples minus {{ how.negative_coefficient }} × the same over its nearest negative ones. The signal's preliminary weight ({{ how.knn_weight }}) is scaled by a gate that opens above {{ how.knn_floor }} embedded verdicts and is fully open at {{ how.knn_full }}. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.rating_half_life_days</code>, <code>knn_floor</code>, <code>knn_full</code>, <code>neighbour_k</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.knn</code></a>.</li>
|
||||
<li><strong>Feed affinity.</strong> The same decayed weight is credited to the rated article's direct feeds, split evenly; each feed's Beta-smoothed rate <code>(up + 1) / (up + down + 2)</code> becomes a candidate's signal (the mean over its rated direct feeds). Its weight ({{ how.feed_weight }}) is gated between {{ how.feed_floor }} and {{ how.feed_full }} attributable ratings. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.feed_floor</code>, <code>feed_full</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.feed</code></a>.</li>
|
||||
</ol>
|
||||
<p>Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.</p>
|
||||
<p class="muted">Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<nav class="tabs"><a href="{{ current_href }}"{% if tab == "current" %} class="active"{% endif %}>Current ({{ current_total }})</a> <a href="{{ events_href }}"{% if tab == "events" %} class="active"{% endif %}>Events</a></nav>
|
||||
<nav class="tabs" aria-label="Ratings view"><a href="{{ current_href }}"{% if tab == "current" %} class="active" aria-current="page"{% endif %}>Current ({{ current_total }})</a> <a href="{{ events_href }}"{% if tab == "events" %} class="active" aria-current="page"{% endif %}>Events</a></nav>
|
||||
|
||||
{% if tab == "events" %}
|
||||
<form class="filters" method="get" action="/dashboard/ratings">
|
||||
@@ -23,27 +27,27 @@
|
||||
<label>User <select name="user"><option value="">any</option>{% for username in usernames %}<option value="{{ username }}"{% if filter_user == username.as_str() %} selected{% endif %}>{{ username }}</option>{% endfor %}</select></label>
|
||||
<label>From <input type="date" name="from" value="{{ filter_from }}"></label>
|
||||
<label>To <input type="date" name="to" value="{{ filter_to }}"></label>
|
||||
<button type="submit">Filter</button>
|
||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ events_href }}">Reset</a></div>
|
||||
</form>
|
||||
<p class="meta">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
|
||||
<div class="scroll-x"><table>
|
||||
<thead><tr><th>Event</th><th>Verdict</th><th>Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
|
||||
<p class="meta text-sm">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
|
||||
<div class="scroll-x tall"><table>
|
||||
<thead><tr><th class="num">Event</th><th>Verdict</th><th class="num">Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
|
||||
<td>#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
|
||||
<td><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
|
||||
<td>{{ event.value }}</td>
|
||||
<td><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
|
||||
<td>{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
|
||||
<td>{{ event.when }}</td>
|
||||
<td>{{ event.source }}</td>
|
||||
<td>{{ event.username }}</td>
|
||||
<td>{{ event.note }}</td>
|
||||
<td class="num">#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
|
||||
<td class="cell-tight"><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
|
||||
<td class="num">{{ event.value }}</td>
|
||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
|
||||
<td class="cell-tight">{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
|
||||
<td class="cell-tight text-muted">{{ event.when }}</td>
|
||||
<td class="cell-tight text-muted">{{ event.source }}</td>
|
||||
<td class="cell-tight text-muted">{{ event.username }}</td>
|
||||
<td class="cell-wrap text-muted">{{ event.note }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if events.is_empty() %}<tr><td colspan="9">No rating events match.</td></tr>{% endif %}
|
||||
{% if events.is_empty() %}<tr><td colspan="9" class="text-muted">No rating events match.</td></tr>{% endif %}
|
||||
</tbody></table></div>
|
||||
<div class="pager">{% if !prev_href.is_empty() %}<a href="{{ prev_href }}">← Newer</a>{% endif %} {% include "_pagination.html" %} {% if !next_href.is_empty() %}<a href="{{ next_href }}">Older →</a>{% endif %}</div>
|
||||
<nav class="pager" aria-label="Pagination">{% include "_pagination.html" %}<span class="flex items-center gap-2">{% if !prev_href.is_empty() %}<a class="btn" href="{{ prev_href }}">← Newer</a>{% endif %}{% if !next_href.is_empty() %}<a class="btn" href="{{ next_href }}">Older →</a>{% endif %}</span></nav>
|
||||
{% else %}
|
||||
<form class="filters" method="get" action="/dashboard/ratings">
|
||||
<input type="hidden" name="tab" value="current">
|
||||
@@ -51,26 +55,26 @@
|
||||
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
|
||||
<label>Feed <select name="feed"><option value="">any</option>{% for feed in feeds %}<option value="{{ feed.id }}"{% if filter_feed == feed.id.to_string() %} selected{% endif %}>{{ feed.title }}</option>{% endfor %}</select></label>
|
||||
<label>Title <input type="search" name="q" value="{{ filter_q }}" placeholder="contains…"></label>
|
||||
<button type="submit">Filter</button>
|
||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ current_href }}">Reset</a></div>
|
||||
</form>
|
||||
<p class="meta">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
|
||||
<div class="scroll-x"><table class="contributions">
|
||||
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th>Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
|
||||
<p class="meta text-sm">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
|
||||
<div class="scroll-x tall"><table class="contributions">
|
||||
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th class="num">Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in current %}<tr>
|
||||
<td><span class="badge {{ row.badge }}">{{ row.verdict }}</span>{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
||||
<td><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
|
||||
<td>{{ row.when }}<br><span class="meta">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
|
||||
<td>{{ row.note }}</td>
|
||||
<td>{{ row.age_days }} d → {{ row.decay }}</td>
|
||||
<td>{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta">no embedding</span>{% endif %}</td>
|
||||
<td>{% for credit in row.feed_credits %}{{ credit.title }} {{ credit.credit }}{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
|
||||
<td>{% if row.in_prompt %}✓{% endif %}</td>
|
||||
<td>{% if row.in_rebuild %}✓{% endif %}</td>
|
||||
<td>{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
|
||||
<td class="min-w-[13rem]"><span class="badge {{ row.badge }}">{{ row.verdict }}</span>{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
||||
<td class="cell-wrap"><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta text-xs">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
|
||||
<td class="cell-tight">{{ row.when }}<br><span class="meta text-xs">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
|
||||
<td class="cell-wrap text-muted">{{ row.note }}</td>
|
||||
<td class="cell-tight tabular-nums">{{ row.age_days }} d → {{ row.decay }}</td>
|
||||
<td class="num">{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta text-xs">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta text-xs">no embedding</span>{% endif %}</td>
|
||||
<td class="cell-tight text-muted">{% for credit in row.feed_credits %}{{ credit.title }} <span class="tabular-nums text-ink">{{ credit.credit }}</span>{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
|
||||
<td class="text-center">{% if row.in_prompt %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||
<td class="text-center">{% if row.in_rebuild %}<span class="text-loved">✓</span>{% endif %}</td>
|
||||
<td class="cell-tight tabular-nums">{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if current.is_empty() %}<tr><td colspan="10">No current verdicts match.</td></tr>{% endif %}
|
||||
{% if current.is_empty() %}<tr><td colspan="10" class="text-muted">No current verdicts match.</td></tr>{% endif %}
|
||||
</tbody></table></div>
|
||||
{% endif %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,42 +1,51 @@
|
||||
{% 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>
|
||||
<header class="page-head"><div>
|
||||
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/runs">Runs</a> › {{ run.date }}</p>
|
||||
<h1 class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">Run {{ run.id }} <span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="badge">dry run</span>{% endif %}</h1>
|
||||
<p class="page-desc">Started {{ run.started }} · finished {{ run.finished }}.</p>
|
||||
</div><nav class="page-actions" aria-label="Run navigation">{% if let Some(prev) = run.prev_id %}<a class="btn" href="/dashboard/runs/{{ prev }}">← Run {{ prev }}</a>{% endif %}{% if let Some(href) = run.issue_href %}<a class="btn" href="{{ href }}">Issue {{ run.date }}</a>{% else %}<span class="muted text-sm">No issue for {{ run.date }}</span>{% endif %}{% if let Some(next) = run.next_id %}<a class="btn" href="/dashboard/runs/{{ next }}">Run {{ next }} →</a>{% endif %}</nav></header>
|
||||
|
||||
<div class="tiles">
|
||||
<div class="tile"><span class="tile-label">Issue date</span><span class="tile-num">{{ run.date }}</span><span class="tile-delta">run {{ run.id }}</span></div>
|
||||
<div class="tile"><span class="tile-label">Duration</span><span class="tile-num">{{ run.duration }}</span><span class="tile-delta">wall clock</span></div>
|
||||
<div class="tile"><span class="tile-label">Token cost</span><span class="tile-num">{{ run.total_cost }}</span><span class="tile-delta">every provider</span></div>
|
||||
<div class="tile"><span class="tile-label">Warnings</span><span class="tile-num{% if !warnings.is_empty() %} text-warn{% endif %}">{{ warnings.len() }}</span><span class="tile-delta">{% if warnings.is_empty() %}clean{% else %}<a href="#warnings">see below</a>{% endif %}</span></div>
|
||||
</div>
|
||||
{% 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="cell-tight align-middle"><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>
|
||||
<td class="num align-middle">{{ stage.reached }}</td>
|
||||
<td class="num align-middle text-muted">{{ stage.stopped }}</td>
|
||||
<td class="reasons cell-wrap align-middle text-muted">{% for reason in stage.reasons %}{{ reason.reason }} <span class="tabular-nums text-ink">{{ reason.count }}</span>{% 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>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 class="tabular-nums">{{ 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>
|
||||
<dt>rated with embeddings</dt><dd class="tabular-nums">{{ preference.rated_with_embeddings }}</dd>
|
||||
<dt>knn gate</dt><dd class="tabular-nums">{{ preference.knn_gate }}</dd>
|
||||
<dt>feed gate</dt><dd class="tabular-nums">{{ preference.feed_gate }}</dd>
|
||||
<dt>verdicts in prompt</dt><dd class="tabular-nums">{{ 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" id="warnings"><h2>Warnings</h2>{% if warnings.is_empty() %}<p class="muted">None.</p>{% else %}<ul class="list-disc pl-5 text-warn">{% 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 %}
|
||||
</div>{% else %}<p class="muted text-sm">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 %}
|
||||
{% if let Some(against) = diff_against %}{% if config_diff.is_empty() %}<p class="muted text-sm">No settings changed since <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>{% else %}<p class="muted text-sm">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 cell-wrap text-muted">{{ row.before }}</td><td class="after cell-wrap text-ink">{{ row.after }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted text-sm">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 %}
|
||||
{% if near_misses.is_empty() %}<p class="muted text-sm">None recorded.</p>{% else %}<div class="scroll-x"><table>
|
||||
<thead><tr><th class="num">#</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 class="num text-muted">{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td class="cell-wrap"><a href="{{ miss.href }}">{{ miss.title }}</a></td><td class="cell-tight text-muted">{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td class="cell-tight"><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td class="cell-wrap text-muted">{% 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 }}">
|
||||
@@ -44,13 +53,13 @@
|
||||
<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>Title <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></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>
|
||||
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
|
||||
</form>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
<div class="scroll-x"><table class="candidates" data-filter>
|
||||
<div class="scroll-x tall"><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>
|
||||
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="14" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Runs</h1>
|
||||
<p class="page-desc">Every recorded pipeline run, newest first: how the funnel narrowed, what it cost and whether it finished clean.</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/stats">Stats</a><a class="btn" href="/dashboard/jobs">Jobs</a></div></header>
|
||||
<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>
|
||||
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
|
||||
</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>
|
||||
<div class="scroll-x tall"><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 class="num">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"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||
<td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
|
||||
<td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted text-xs">dry</span>{% endif %}</td>
|
||||
<td class="cell-tight text-muted">{{ run.started }}</td>
|
||||
<td class="num text-muted">{{ run.duration }}</td>
|
||||
<td class="cell-tight tabular-nums">{{ run.funnel }}</td>
|
||||
<td class="text-muted">{{ 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>
|
||||
<td class="num">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }}</a>{% else %}<span class="text-muted">0</span>{% endif %}</td>
|
||||
</tr>{% endfor %}{% if runs.is_empty() %}<tr><td colspan="9" class="text-muted">No runs match this filter.</td></tr>{% endif %}</tbody></table></div>
|
||||
{% include "dashboard/_pager.html" %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard settings">
|
||||
<header class="page-head"><div>
|
||||
<h1>Settings</h1>
|
||||
<p class="meta">{% match config_path %}{% when Some with (path) %}Editing <code>{{ path }}</code>. Saved settings apply to the next run; a hand edit on disk shows up on the next page view.{% when None %}No config file is configured; start the server with <code>--config</code> to edit settings here. The values below are the built-in defaults plus the environment.{% endmatch %} · <a href="/dashboard/settings/history">History</a></p>
|
||||
{% match load_error %}{% when Some with (error) %}<p class="error banner">! config.toml on disk does not load: {{ error }} — the previous configuration stays live.</p>{% when None %}{% endmatch %}
|
||||
<p class="page-desc">{% match config_path %}{% when Some with (path) %}Editing <code>{{ path }}</code>. Saved settings apply to the next run; a hand edit on disk shows up on the next page view.{% when None %}No config file is configured; start the server with <code>--config</code> to edit settings here. The values below are the built-in defaults plus the environment.{% endmatch %}</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/settings/history">History</a><a class="btn" href="#add-provider">Add provider</a></div></header>
|
||||
{% match load_error %}{% when Some with (error) %}<p class="error banner">config.toml on disk does not load: {{ error }} — the previous configuration stays live.</p>{% when None %}{% endmatch %}
|
||||
{% if !errors.is_empty() %}<ul class="error banner">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
|
||||
<nav class="flex flex-wrap gap-x-3 gap-y-1 border-y border-rule py-3 font-mono text-xs" aria-label="Settings groups">{% for group in groups %}<a class="text-muted no-underline hover:text-accent" href="#{{ group.id }}">{{ group.id }}</a>{% endfor %}</nav>
|
||||
<form method="post" action="/dashboard/settings" class="settings-form" id="settings-form">
|
||||
{% for group in groups %}<section class="card" id="{{ group.id }}">
|
||||
<h2>{{ group.title }}</h2>
|
||||
{% match group.provider %}{% when Some with (provider) %}<p class="meta">Key: <code>{{ crate::config::ProviderConfig::api_key_env_var(provider.name) }}</code> in the env file. {% if provider.removable() %}Not named by any <code>[llm]</code> role. <button type="submit" form="remove-{{ provider.name }}" class="danger">Remove provider</button>{% else if provider.built_in %}Built in: declared by the defaults, so it can be edited and left unreferenced but not removed.{% else %}Named by <code>llm.{{ provider.roles() }}</code>; reassign the role before removing it.{% endif %}</p>{% when None %}{% endmatch %}
|
||||
{% if group.is_weights() %}<p class="meta">Weights need not sum to 1; they are renormalized over the signals present for each article.</p>{% endif %}
|
||||
{% for group in groups %}<section class="card scroll-mt-4" id="{{ group.id }}">
|
||||
<div>
|
||||
<p class="page-eyebrow font-mono normal-case tracking-normal">{{ group.id }}</p>
|
||||
<h2 class="mt-0.5">{{ group.title }}</h2>
|
||||
</div>
|
||||
{% match group.provider %}{% when Some with (provider) %}<p class="meta text-xs">Key: <code>{{ crate::config::ProviderConfig::api_key_env_var(provider.name) }}</code> in the env file. {% if provider.removable() %}Not named by any <code>[llm]</code> role. <button type="submit" form="remove-{{ provider.name }}" class="danger">Remove provider</button>{% else if provider.built_in %}Built in: declared by the defaults, so it can be edited and left unreferenced but not removed.{% else %}Named by <code>llm.{{ provider.roles() }}</code>; reassign the role before removing it.{% endif %}</p>{% when None %}{% endmatch %}
|
||||
{% if group.is_weights() %}<p class="meta text-xs">Weights need not sum to 1; they are renormalized over the signals present for each article.</p>{% endif %}
|
||||
{% for field in group.fields %}<div class="setting kind-{{ field.kind_name() }}">
|
||||
<div class="setting-label"><label for="{{ field.input_id() }}"><code>{{ field.key }}</code></label>{% if field.restart_required %} <span class="badge">restart</span>{% endif %}{% match field.help %}{% when Some with (help) %}<p class="help">{{ help }}</p>{% when None %}{% endmatch %}</div>
|
||||
<div class="setting-label"><label for="{{ field.input_id() }}"><code>{{ field.key }}</code></label>{% if field.restart_required %} <span class="badge restart">restart</span>{% endif %}{% if field.is_env() %} <span class="badge">locked</span>{% endif %}{% match field.help %}{% when Some with (help) %}<p class="help">{{ help }}</p>{% when None %}{% endmatch %}</div>
|
||||
<div class="setting-input">
|
||||
{% if field.is_secret() %}<p class="secret">{% if field.is_set() %}set{% if field.is_env() %} (from <code>{{ field.env_var() }}</code>){% else if field.is_file() %} (in the config file — move it to the env file){% endif %}{% else %}not set — set <code>{{ field.env_var() }}</code> in the env file{% endif %}</p>
|
||||
{% else if field.is_env() %}{% if field.is_text_list() %}<textarea id="{{ field.input_id() }}" disabled>{{ field.current }}</textarea>{% else %}<input id="{{ field.input_id() }}" type="text" value="{{ field.current }}" disabled>{% endif %}<p class="help">locked by <code>{{ field.env_var() }}</code> in the env file</p>
|
||||
@@ -25,17 +31,17 @@
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
<div class="actions">{% match config_path %}{% when Some with (_path) %}<button type="submit">Save settings</button>{% when None %}<button type="submit" disabled>Save settings</button>{% endmatch %}</div>
|
||||
<div class="actions save-bar">{% match config_path %}{% when Some with (path) %}<span class="meta">Writes <code>{{ path }}</code>.</span><button class="btn-primary" type="submit">Save settings</button>{% when None %}<span class="meta">No config file: settings are read-only.</span><button class="btn-primary" type="submit" disabled>Save settings</button>{% endmatch %}</div>
|
||||
</form>
|
||||
{% for group in groups %}{% match group.provider %}{% when Some with (provider) %}{% if provider.removable() %}<form method="post" action="/dashboard/settings/providers" id="remove-{{ provider.name }}" class="inline" data-confirm="Remove [providers.{{ provider.name }}] from config.toml?"><input type="hidden" name="action" value="remove"><input type="hidden" name="name" value="{{ provider.name }}"></form>{% endif %}{% when None %}{% endmatch %}{% endfor %}
|
||||
<section class="card" id="add-provider">
|
||||
{% for group in groups %}{% match group.provider %}{% when Some with (provider) %}{% if provider.removable() %}<form method="post" action="/dashboard/settings/providers" id="remove-{{ provider.name }}" class="form-inline" data-confirm="Remove [providers.{{ provider.name }}] from config.toml?"><input type="hidden" name="action" value="remove"><input type="hidden" name="name" value="{{ provider.name }}"></form>{% endif %}{% when None %}{% endmatch %}{% endfor %}
|
||||
<section class="card scroll-mt-4" id="add-provider">
|
||||
<h2>Add provider</h2>
|
||||
<form method="post" action="/dashboard/settings/providers" class="add-provider">
|
||||
<form method="post" action="/dashboard/settings/providers" class="add-provider filters border-y-0 py-0">
|
||||
<input type="hidden" name="action" value="add">
|
||||
<label>Name <input name="name" type="text" pattern="[a-z0-9_]+" placeholder="mistral" required{% if config_path.is_none() %} disabled{% endif %}></label>
|
||||
<label>Kind <select name="kind">{% for kind in provider_kinds %}<option value="{{ kind }}">{{ kind }}</option>{% endfor %}</select></label>
|
||||
<p class="help">Inserts <code>[providers.<name>]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__<NAME>__API_KEY</code> in the env file.</p>
|
||||
<button type="submit"{% if config_path.is_none() %} disabled{% endif %}>Add provider</button>
|
||||
<div class="filter-actions"><button class="btn" type="submit"{% if config_path.is_none() %} disabled{% endif %}>Add provider</button></div>
|
||||
</form>
|
||||
<p class="help">Inserts <code>[providers.<name>]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__<NAME>__API_KEY</code> in the env file.</p>
|
||||
</section>
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Settings history</h1>
|
||||
<p class="meta"><a href="/dashboard/settings">Settings</a> · every change made through the dashboard, newest first.</p>
|
||||
{% if changes.is_empty() %}<p>No settings have been changed through the dashboard yet.</p>{% else %}<div class="scroll-x"><table class="history">
|
||||
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
|
||||
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
|
||||
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}<div class="scroll-x tall"><table class="history" data-filter>
|
||||
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
|
||||
<tbody>{% for change in changes %}<tr>
|
||||
<td>{{ change.changed_at }}</td>
|
||||
<td>{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em>removed user</em>{% endmatch %}</td>
|
||||
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
||||
<td class="cell-tight">{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em class="text-muted">removed user</em>{% endmatch %}</td>
|
||||
<td><code>{{ change.key }}</code></td>
|
||||
<td>{% match change.old_value %}{% when Some with (value) %}<pre>{{ value }}</pre>{% when None %}<em>absent</em>{% endmatch %}</td>
|
||||
<td>{% match change.new_value %}{% when Some with (value) %}<pre>{{ value }}</pre>{% when None %}<em>removed</em>{% endmatch %}</td>
|
||||
<td class="cell-wrap">{% match change.old_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">absent</em>{% endmatch %}</td>
|
||||
<td class="cell-wrap">{% match change.new_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">removed</em>{% endmatch %}</td>
|
||||
</tr>{% endfor %}</tbody>
|
||||
</table></div>
|
||||
{% include "_pagination.html" %}
|
||||
{% if pagination.page < pagination.pages() %}<p><a href="/dashboard/settings/history?page={{ pagination.page + 1 }}">Older</a></p>{% endif %}
|
||||
{% if pagination.page > 1 %}<p><a href="/dashboard/settings/history?page={{ pagination.page - 1 }}">Newer</a></p>{% endif %}
|
||||
{% if pagination.pages() > 1 %}<nav class="pager" aria-label="Pagination">{% include "_pagination.html" %}<span class="flex items-center gap-2">{% if pagination.page > 1 %}<a class="btn" href="/dashboard/settings/history?page={{ pagination.page - 1 }}">← Newer</a>{% endif %}{% if pagination.page < pagination.pages() %}<a class="btn" href="/dashboard/settings/history?page={{ pagination.page + 1 }}">Older →</a>{% endif %}</span></nav>{% endif %}
|
||||
{% endif %}
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Stats</h1>
|
||||
<p class="muted">Last {{ days }} days ({{ since_date }} → {{ today }}) · window: {% for window in windows %}{% if *window == days %}<strong>{{ window }}</strong>{% else %}<a href="/dashboard/stats?days={{ window }}">{{ window }}</a>{% endif %}{% if !loop.last %} · {% endif %}{% endfor %} days · the same figures as <code>daily-epub stats --days {{ days }}</code>.</p>
|
||||
<p class="page-desc">The last {{ days }} days ({{ since_date }} → {{ today }}) — the same figures as <code>daily-epub stats --days {{ days }}</code>.</p>
|
||||
</div><nav class="page-actions" aria-label="Window">{% for window in windows %}{% if *window == days %}<span class="btn-primary" aria-current="true">{{ window }} days</span>{% else %}<a class="btn" href="/dashboard/stats?days={{ window }}">{{ window }} days</a>{% endif %}{% endfor %}</nav></header>
|
||||
|
||||
<div class="tiles">{% for line in summary %}<div class="tile"><span class="tile-label">{{ line.key }}</span><span class="tile-num">{{ line.value }}</span></div>{% endfor %}</div>
|
||||
|
||||
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
|
||||
|
||||
<div class="cards">
|
||||
<section class="card"><h2>Issues and ratings</h2>
|
||||
<dl class="kv">{% for line in summary %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
|
||||
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the window.</p>{% else %}<p>{% for line in ratings %}<span class="badge {{ line.key }}">{{ line.key }}</span> {{ line.value }}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}
|
||||
<section class="card"><h2>Verdicts in the window</h2>
|
||||
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the window.</p>{% else %}<p class="flex flex-wrap items-center gap-x-3 gap-y-1.5">{% for line in ratings %}<span class="inline-flex items-center gap-1.5"><span class="badge {{ line.key }}">{{ line.key }}</span> <span class="tabular-nums">{{ line.value }}</span></span>{% endfor %}</p>{% endif %}
|
||||
<p class="muted text-xs">Explicit verdicts only; cleared ones still count as events.</p>
|
||||
</section>
|
||||
<section class="card"><h2>Retriever yield</h2>
|
||||
{% if retrievers.is_empty() %}<p class="muted">No rated picks by admitting retriever in the window.</p>{% else %}<div class="scroll-x"><table>
|
||||
<thead><tr><th>admitted by</th><th class="num">rated</th><th class="num">up</th><th class="num">down</th><th class="num">ratio</th></tr></thead>
|
||||
<tbody>{% for line in retrievers %}<tr><td>{{ line.retriever }}</td><td class="num">{{ line.rated }}</td><td class="num">{{ line.up }}</td><td class="num">{{ line.down }}</td><td class="num">{{ line.ratio }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
<h3>Exploration</h3>
|
||||
<dl class="kv">{% for line in exploration %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
|
||||
<tbody>{% for line in retrievers %}<tr><td>{{ line.retriever }}</td><td class="num">{{ line.rated }}</td><td class="num text-loved">{{ line.up }}</td><td class="num text-down">{{ line.down }}</td><td class="num">{{ line.ratio }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
<h3 class="text-sm">Exploration</h3>
|
||||
<dl class="kv">{% for line in exploration %}<dt>{{ line.key }}</dt><dd class="tabular-nums">{{ line.value }}</dd>{% endfor %}</dl>
|
||||
</section>
|
||||
<section class="card"><h2>Cost per day</h2>
|
||||
<dl class="kv">{% for line in cost_per_day %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
|
||||
<p class="muted">Window spend divided by {{ days }} days, per provider.</p>
|
||||
<dl class="kv">{% for line in cost_per_day %}<dt>{{ line.key }}</dt><dd class="tabular-nums">{{ line.value }}</dd>{% endfor %}</dl>
|
||||
<p class="muted text-xs">Window spend divided by {{ days }} days, per provider.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h2>Spend by day</h2>
|
||||
{% if cost_rows.is_empty() %}<p class="muted">No provider costs recorded in the window.</p>{% else %}<div class="scroll-x"><table data-filter>
|
||||
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
|
||||
<tbody>{% for row in cost_rows %}<tr><td>{{ row.date }}</td>{% for cell in row.cells %}<td class="num">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
|
||||
<h2>Runs in the window</h2>
|
||||
{% if runs.is_empty() %}<p class="muted">No finished runs in the window.</p>{% else %}<div class="scroll-x"><table data-filter>
|
||||
<thead><tr><th>run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
|
||||
<tbody>{% for run in runs %}<tr><td><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
<details class="explain"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><pre class="preview">{{ text }}</pre></details>
|
||||
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
|
||||
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
|
||||
<details class="explain disclosure" id="stats-text"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><div class="disclosure-body"><pre class="preview">{{ text }}</pre></div></details>
|
||||
</section>{% endblock %}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Users</h1>
|
||||
<p class="muted">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
|
||||
<div class="scroll-x"><table data-filter>
|
||||
<p class="page-desc">Every account on this server, with its role and open sessions.</p>
|
||||
</div></header>
|
||||
<p class="muted text-sm">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
|
||||
<div class="scroll-x tall"><table data-filter>
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
||||
<tbody>{% for user in users %}<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td class="font-medium text-ink">{{ user.username }}</td>
|
||||
<td><span class="badge">{{ user.role }}</span></td>
|
||||
<td>{% if user.disabled %}<span class="badge down">disabled</span>{% else %}<span class="badge loved">enabled</span>{% endif %}</td>
|
||||
<td>{{ user.created }}</td>
|
||||
<td>{{ user.last_login }}</td>
|
||||
<td class="cell-tight text-muted">{{ user.created }}</td>
|
||||
<td class="cell-tight text-muted">{{ user.last_login }}</td>
|
||||
<td class="num">{{ user.open_sessions }}</td>
|
||||
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6">No users. Bootstrap an admin with <code>daily-epub users add <username> --admin</code>.</td></tr>{% endif %}</tbody>
|
||||
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6" class="text-muted">No users. Bootstrap an admin with <code>daily-epub users add <username></code> <code>--admin</code>.</td></tr>{% endif %}</tbody>
|
||||
</table></div>
|
||||
</section>{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user