feat: add job runs chart and scrollable reading lists on dashboard
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
- Add multi-line chart showing job runs over time by type (scan, rebuild, thumbnails, other) with the same day/week/month toggle - Limit currently reading and recently read lists to 3 visible items with a scrollbar for overflow - Fix NUMERIC→BIGINT cast for SUM/COALESCE in jobs SQL queries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,15 @@ pub struct MonthlyReading {
|
|||||||
pub books_read: i64,
|
pub books_read: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct JobTimePoint {
|
||||||
|
pub label: String,
|
||||||
|
pub scan: i64,
|
||||||
|
pub rebuild: i64,
|
||||||
|
pub thumbnail: i64,
|
||||||
|
pub other: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct StatsResponse {
|
pub struct StatsResponse {
|
||||||
pub overview: StatsOverview,
|
pub overview: StatsOverview,
|
||||||
@@ -118,6 +127,7 @@ pub struct StatsResponse {
|
|||||||
pub by_library: Vec<LibraryStats>,
|
pub by_library: Vec<LibraryStats>,
|
||||||
pub top_series: Vec<TopSeries>,
|
pub top_series: Vec<TopSeries>,
|
||||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||||
|
pub jobs_over_time: Vec<JobTimePoint>,
|
||||||
pub metadata: MetadataStats,
|
pub metadata: MetadataStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +565,125 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Jobs over time (with gap filling, grouped by type category)
|
||||||
|
let jobs_rows = match period {
|
||||||
|
"day" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||||
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
finished_at::date AS dt,
|
||||||
|
CASE
|
||||||
|
WHEN type = 'scan' THEN 'scan'
|
||||||
|
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||||
|
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS cat,
|
||||||
|
COUNT(*) AS c
|
||||||
|
FROM index_jobs
|
||||||
|
WHERE status IN ('success', 'failed')
|
||||||
|
AND finished_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
|
GROUP BY finished_at::date, cat
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
GROUP BY d.dt
|
||||||
|
ORDER BY label ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS label,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
|
DATE_TRUNC('week', NOW()),
|
||||||
|
'1 week'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('week', finished_at) AS dt,
|
||||||
|
CASE
|
||||||
|
WHEN type = 'scan' THEN 'scan'
|
||||||
|
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||||
|
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS cat,
|
||||||
|
COUNT(*) AS c
|
||||||
|
FROM index_jobs
|
||||||
|
WHERE status IN ('success', 'failed')
|
||||||
|
AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', finished_at), cat
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
GROUP BY d.dt
|
||||||
|
ORDER BY label ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS label,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail,
|
||||||
|
COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other
|
||||||
|
FROM generate_series(
|
||||||
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
|
DATE_TRUNC('month', NOW()),
|
||||||
|
'1 month'
|
||||||
|
) AS d(dt)
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('month', finished_at) AS dt,
|
||||||
|
CASE
|
||||||
|
WHEN type = 'scan' THEN 'scan'
|
||||||
|
WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild'
|
||||||
|
WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail'
|
||||||
|
ELSE 'other'
|
||||||
|
END AS cat,
|
||||||
|
COUNT(*) AS c
|
||||||
|
FROM index_jobs
|
||||||
|
WHERE status IN ('success', 'failed')
|
||||||
|
AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', finished_at), cat
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
GROUP BY d.dt
|
||||||
|
ORDER BY label ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let jobs_over_time: Vec<JobTimePoint> = jobs_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| JobTimePoint {
|
||||||
|
label: r.get("label"),
|
||||||
|
scan: r.get("scan"),
|
||||||
|
rebuild: r.get("rebuild"),
|
||||||
|
thumbnail: r.get("thumbnail"),
|
||||||
|
other: r.get("other"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(StatsResponse {
|
Ok(Json(StatsResponse {
|
||||||
overview,
|
overview,
|
||||||
reading_status,
|
reading_status,
|
||||||
@@ -566,6 +695,7 @@ pub async fn get_stats(
|
|||||||
by_library,
|
by_library,
|
||||||
top_series,
|
top_series,
|
||||||
additions_over_time,
|
additions_over_time,
|
||||||
|
jobs_over_time,
|
||||||
metadata,
|
metadata,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
AreaChart, Area,
|
AreaChart, Area, Line, LineChart,
|
||||||
Legend,
|
Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
@@ -186,3 +186,46 @@ export function RcHorizontalBar({
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multi-line chart (jobs over time)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function RcMultiLineChart({
|
||||||
|
data,
|
||||||
|
lines,
|
||||||
|
noDataLabel,
|
||||||
|
}: {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
lines: { key: string; label: string; color: string }[];
|
||||||
|
noDataLabel?: string;
|
||||||
|
}) {
|
||||||
|
const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0));
|
||||||
|
if (data.length === 0 || !hasData)
|
||||||
|
return <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{lines.map((l) => (
|
||||||
|
<Line
|
||||||
|
key={l.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={l.key}
|
||||||
|
name={l.label}
|
||||||
|
stroke={l.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: l.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
|
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui";
|
||||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar } from "./components/DashboardCharts";
|
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "./components/DashboardCharts";
|
||||||
import { PeriodToggle } from "./components/PeriodToggle";
|
import { PeriodToggle } from "./components/PeriodToggle";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -88,7 +88,7 @@ export default async function DashboardPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, metadata } = stats;
|
const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, jobs_over_time = [], metadata } = stats;
|
||||||
|
|
||||||
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||||
const formatColors = [
|
const formatColors = [
|
||||||
@@ -136,7 +136,7 @@ export default async function DashboardPage({
|
|||||||
{currently_reading.length === 0 ? (
|
{currently_reading.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
|
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||||
{currently_reading.slice(0, 8).map((book) => {
|
{currently_reading.slice(0, 8).map((book) => {
|
||||||
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
@@ -176,7 +176,7 @@ export default async function DashboardPage({
|
|||||||
{recently_read.length === 0 ? (
|
{recently_read.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
|
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
|
||||||
{recently_read.map((book) => (
|
{recently_read.map((book) => (
|
||||||
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||||
<Image
|
<Image
|
||||||
@@ -391,6 +391,32 @@ export default async function DashboardPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Jobs over time – multi-line chart */}
|
||||||
|
<Card hover={false}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
|
||||||
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RcMultiLineChart
|
||||||
|
noDataLabel={noDataLabel}
|
||||||
|
data={jobs_over_time.map((j) => ({
|
||||||
|
label: formatChartLabel(j.label, period, locale),
|
||||||
|
scan: j.scan,
|
||||||
|
rebuild: j.rebuild,
|
||||||
|
thumbnail: j.thumbnail,
|
||||||
|
other: j.other,
|
||||||
|
}))}
|
||||||
|
lines={[
|
||||||
|
{ key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" },
|
||||||
|
{ key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" },
|
||||||
|
{ key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" },
|
||||||
|
{ key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Quick links */}
|
{/* Quick links */}
|
||||||
<QuickLinks t={t} />
|
<QuickLinks t={t} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -570,6 +570,14 @@ export type MonthlyReading = {
|
|||||||
books_read: number;
|
books_read: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JobTimePoint = {
|
||||||
|
label: string;
|
||||||
|
scan: number;
|
||||||
|
rebuild: number;
|
||||||
|
thumbnail: number;
|
||||||
|
other: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StatsResponse = {
|
export type StatsResponse = {
|
||||||
overview: StatsOverview;
|
overview: StatsOverview;
|
||||||
reading_status: ReadingStatusStats;
|
reading_status: ReadingStatusStats;
|
||||||
@@ -581,6 +589,7 @@ export type StatsResponse = {
|
|||||||
by_library: LibraryStatsItem[];
|
by_library: LibraryStatsItem[];
|
||||||
top_series: TopSeriesItem[];
|
top_series: TopSeriesItem[];
|
||||||
additions_over_time: MonthlyAdditions[];
|
additions_over_time: MonthlyAdditions[];
|
||||||
|
jobs_over_time: JobTimePoint[];
|
||||||
metadata: MetadataStats;
|
metadata: MetadataStats;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.byFormat": "By format",
|
"dashboard.byFormat": "By format",
|
||||||
"dashboard.byLibrary": "By library",
|
"dashboard.byLibrary": "By library",
|
||||||
"dashboard.booksAdded": "Books added",
|
"dashboard.booksAdded": "Books added",
|
||||||
|
"dashboard.jobsOverTime": "Job runs",
|
||||||
|
"dashboard.jobScan": "Scan",
|
||||||
|
"dashboard.jobRebuild": "Rebuild",
|
||||||
|
"dashboard.jobThumbnail": "Thumbnails",
|
||||||
|
"dashboard.jobOther": "Other",
|
||||||
"dashboard.periodDay": "Day",
|
"dashboard.periodDay": "Day",
|
||||||
"dashboard.periodWeek": "Week",
|
"dashboard.periodWeek": "Week",
|
||||||
"dashboard.periodMonth": "Month",
|
"dashboard.periodMonth": "Month",
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ const fr = {
|
|||||||
"dashboard.byFormat": "Par format",
|
"dashboard.byFormat": "Par format",
|
||||||
"dashboard.byLibrary": "Par bibliothèque",
|
"dashboard.byLibrary": "Par bibliothèque",
|
||||||
"dashboard.booksAdded": "Livres ajoutés",
|
"dashboard.booksAdded": "Livres ajoutés",
|
||||||
|
"dashboard.jobsOverTime": "Exécutions de jobs",
|
||||||
|
"dashboard.jobScan": "Scan",
|
||||||
|
"dashboard.jobRebuild": "Rebuild",
|
||||||
|
"dashboard.jobThumbnail": "Thumbnails",
|
||||||
|
"dashboard.jobOther": "Autre",
|
||||||
"dashboard.periodDay": "Jour",
|
"dashboard.periodDay": "Jour",
|
||||||
"dashboard.periodWeek": "Semaine",
|
"dashboard.periodWeek": "Semaine",
|
||||||
"dashboard.periodMonth": "Mois",
|
"dashboard.periodMonth": "Mois",
|
||||||
|
|||||||
Reference in New Issue
Block a user