Compare commits
10 Commits
3a25e42a20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e26219989f | |||
| 5d33a35407 | |||
| d53572dc33 | |||
| cf1953d11f | |||
| 6f663eaee7 | |||
| ee65c6263a | |||
| 691b6b22ab | |||
| 11c80a16a3 | |||
| c366b44c54 | |||
| 92f80542e6 |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1233,7 +1233,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexer"
|
name = "indexer"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1667,7 +1667,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notifications"
|
name = "notifications"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1786,7 +1786,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parsers"
|
name = "parsers"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2923,7 +2923,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stripstream-core"
|
name = "stripstream-core"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "1.24.0"
|
version = "1.27.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Julien Froidefond
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
12
README.md
12
README.md
@@ -110,6 +110,12 @@ The backoffice will be available at http://localhost:7082
|
|||||||
- Batch auto-matching and scheduled metadata refresh
|
- Batch auto-matching and scheduled metadata refresh
|
||||||
- Field locking to protect manual edits from sync
|
- Field locking to protect manual edits from sync
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- **Telegram**: real-time notifications via Telegram Bot API
|
||||||
|
- 12 granular event toggles (scans, thumbnails, conversions, metadata)
|
||||||
|
- Book thumbnail images included in notifications where applicable
|
||||||
|
- Test connection from settings
|
||||||
|
|
||||||
### External Integrations
|
### External Integrations
|
||||||
- **Komga**: import reading progress
|
- **Komga**: import reading progress
|
||||||
- **Prowlarr**: search for missing volumes
|
- **Prowlarr**: search for missing volumes
|
||||||
@@ -130,9 +136,11 @@ The backoffice will be available at http://localhost:7082
|
|||||||
- Rate limiting, token expiration and revocation
|
- Rate limiting, token expiration and revocation
|
||||||
|
|
||||||
### Web UI (Backoffice)
|
### Web UI (Backoffice)
|
||||||
- Dashboard with statistics, charts, and reading progress
|
- Dashboard with statistics, interactive charts (recharts), and reading progress
|
||||||
|
- Currently reading & recently read sections
|
||||||
- Library, book, series, author management
|
- Library, book, series, author management
|
||||||
- Live job monitoring, metadata search modals, settings panel
|
- Live job monitoring, metadata search modals, settings panel
|
||||||
|
- Notification settings with per-event toggle configuration
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -279,4 +287,4 @@ volumes:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Your License Here]
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
|
|||||||
@@ -631,12 +631,17 @@ pub async fn get_thumbnail(
|
|||||||
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
crate::pages::render_book_page_1(&state, book_id, 300, 80).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let etag_value = format!("\"{}_{:x}\"", book_id, data.len());
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||||
headers.insert(
|
headers.insert(
|
||||||
header::CACHE_CONTROL,
|
header::CACHE_CONTROL,
|
||||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||||
);
|
);
|
||||||
|
if let Ok(v) = HeaderValue::from_str(&etag_value) {
|
||||||
|
headers.insert(header::ETAG, v);
|
||||||
|
}
|
||||||
|
|
||||||
Ok((StatusCode::OK, headers, Body::from(data)))
|
Ok((StatusCode::OK, headers, Body::from(data)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{
|
||||||
use serde::Serialize;
|
extract::{Query, State},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct StatsQuery {
|
||||||
|
/// Granularity: "day", "week" or "month" (default: "month")
|
||||||
|
pub period: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct StatsOverview {
|
pub struct StatsOverview {
|
||||||
pub total_books: i64,
|
pub total_books: i64,
|
||||||
@@ -97,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,
|
||||||
@@ -109,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +136,7 @@ pub struct StatsResponse {
|
|||||||
get,
|
get,
|
||||||
path = "/stats",
|
path = "/stats",
|
||||||
tag = "stats",
|
tag = "stats",
|
||||||
|
params(StatsQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = StatsResponse),
|
(status = 200, body = StatsResponse),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
@@ -125,7 +145,9 @@ pub struct StatsResponse {
|
|||||||
)]
|
)]
|
||||||
pub async fn get_stats(
|
pub async fn get_stats(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<StatsQuery>,
|
||||||
) -> Result<Json<StatsResponse>, ApiError> {
|
) -> Result<Json<StatsResponse>, ApiError> {
|
||||||
|
let period = query.period.as_deref().unwrap_or("month");
|
||||||
// Overview + reading status in one query
|
// Overview + reading status in one query
|
||||||
let overview_row = sqlx::query(
|
let overview_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
@@ -285,20 +307,74 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Additions over time (last 12 months)
|
// Additions over time (with gap filling)
|
||||||
let additions_rows = sqlx::query(
|
let additions_rows = match period {
|
||||||
r#"
|
"day" => {
|
||||||
SELECT
|
sqlx::query(
|
||||||
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
r#"
|
||||||
COUNT(*) AS books_added
|
SELECT
|
||||||
FROM books
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
GROUP BY DATE_TRUNC('month', created_at)
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
ORDER BY month ASC
|
LEFT JOIN (
|
||||||
"#,
|
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||||
)
|
FROM books
|
||||||
.fetch_all(&state.pool)
|
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
.await?;
|
GROUP BY created_at::date
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
|
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', created_at) AS dt, COUNT(*) AS books_added
|
||||||
|
FROM books
|
||||||
|
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', created_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
|
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', created_at) AS dt, COUNT(*) AS books_added
|
||||||
|
FROM books
|
||||||
|
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', created_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
let additions_over_time: Vec<MonthlyAdditions> = additions_rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -409,21 +485,77 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Reading activity over time (last 12 months)
|
// Reading activity over time (with gap filling)
|
||||||
let reading_time_rows = sqlx::query(
|
let reading_time_rows = match period {
|
||||||
r#"
|
"day" => {
|
||||||
SELECT
|
sqlx::query(
|
||||||
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month,
|
r#"
|
||||||
COUNT(*) AS books_read
|
SELECT
|
||||||
FROM book_reading_progress brp
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
WHERE brp.status = 'read'
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
LEFT JOIN (
|
||||||
ORDER BY month ASC
|
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
|
||||||
"#,
|
FROM book_reading_progress brp
|
||||||
)
|
WHERE brp.status = 'read'
|
||||||
.fetch_all(&state.pool)
|
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
.await?;
|
GROUP BY brp.last_read_at::date
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
"week" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
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', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
|
GROUP BY DATE_TRUNC('week', brp.last_read_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
|
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', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
||||||
|
FROM book_reading_progress brp
|
||||||
|
WHERE brp.status = 'read'
|
||||||
|
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
||||||
|
) cnt ON cnt.dt = d.dt
|
||||||
|
ORDER BY month ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
|
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -433,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,
|
||||||
@@ -444,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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,16 @@ export async function GET(
|
|||||||
const response = await fetch(apiUrl.toString(), {
|
const response = await fetch(apiUrl.toString(), {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return new NextResponse(`Failed to fetch image: ${response.status}`, {
|
return new NextResponse(`Failed to fetch image: ${response.status}`, {
|
||||||
status: response.status
|
status: response.status
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le content-type et les données
|
|
||||||
const contentType = response.headers.get("content-type") || "image/webp";
|
const contentType = response.headers.get("content-type") || "image/webp";
|
||||||
const imageBuffer = await response.arrayBuffer();
|
|
||||||
|
return new NextResponse(response.body, {
|
||||||
// Retourner l'image avec le bon content-type
|
|
||||||
return new NextResponse(imageBuffer, {
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=300",
|
"Cache-Control": "public, max-age=300",
|
||||||
|
|||||||
@@ -6,28 +6,46 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ bookId: string }> }
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
) {
|
) {
|
||||||
const { bookId } = await params;
|
const { bookId } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { baseUrl, token } = config();
|
const { baseUrl, token } = config();
|
||||||
|
const ifNoneMatch = request.headers.get("if-none-match");
|
||||||
|
|
||||||
|
const fetchHeaders: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
if (ifNoneMatch) {
|
||||||
|
fetchHeaders["If-None-Match"] = ifNoneMatch;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
const response = await fetch(`${baseUrl}/books/${bookId}/thumbnail`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: fetchHeaders,
|
||||||
|
next: { revalidate: 86400 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Forward 304 Not Modified as-is
|
||||||
|
if (response.status === 304) {
|
||||||
|
return new NextResponse(null, { status: 304 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
return new NextResponse(`Failed to fetch thumbnail: ${response.status}`, {
|
||||||
status: response.status
|
status: response.status
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type") || "image/webp";
|
const contentType = response.headers.get("content-type") || "image/webp";
|
||||||
const imageBuffer = await response.arrayBuffer();
|
const etag = response.headers.get("etag");
|
||||||
|
|
||||||
return new NextResponse(imageBuffer, {
|
const headers: Record<string, string> = {
|
||||||
headers: {
|
"Content-Type": contentType,
|
||||||
"Content-Type": contentType,
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
};
|
||||||
},
|
if (etag) {
|
||||||
});
|
headers["ETag"] = etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(response.body, { headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching thumbnail:", error);
|
console.error("Error fetching thumbnail:", error);
|
||||||
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
return new NextResponse("Failed to fetch thumbnail", { status: 500 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
type Period = "day" | "week" | "month";
|
||||||
|
|
||||||
|
export function PeriodToggle({
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
labels: { day: string; week: string; month: string };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const raw = searchParams.get("period");
|
||||||
|
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
|
||||||
|
|
||||||
|
function setPeriod(period: Period) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (period === "month") {
|
||||||
|
params.delete("period");
|
||||||
|
} else {
|
||||||
|
params.set("period", period);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Period[] = ["day", "week", "month"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||||
|
{options.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
current === p
|
||||||
|
? "bg-card text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{labels[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
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 Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getServerTranslations } from "../lib/i18n/server";
|
import { getServerTranslations } from "../lib/i18n/server";
|
||||||
@@ -21,6 +22,24 @@ function formatNumber(n: number, locale: string): string {
|
|||||||
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
|
||||||
|
const loc = locale === "fr" ? "fr-FR" : "en-US";
|
||||||
|
if (period === "month") {
|
||||||
|
// raw = "YYYY-MM"
|
||||||
|
const [y, m] = raw.split("-");
|
||||||
|
const d = new Date(Number(y), Number(m) - 1, 1);
|
||||||
|
return d.toLocaleDateString(loc, { month: "short" });
|
||||||
|
}
|
||||||
|
if (period === "week") {
|
||||||
|
// raw = "YYYY-MM-DD" (Monday of the week)
|
||||||
|
const d = new Date(raw + "T00:00:00");
|
||||||
|
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
|
||||||
|
}
|
||||||
|
// day: raw = "YYYY-MM-DD"
|
||||||
|
const d = new Date(raw + "T00:00:00");
|
||||||
|
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
|
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
|
||||||
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
|
||||||
const pct = max > 0 ? (value / max) * 100 : 0;
|
const pct = max > 0 ? (value / max) * 100 : 0;
|
||||||
@@ -40,12 +59,19 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
|
const searchParamsAwaited = await searchParams;
|
||||||
|
const rawPeriod = searchParamsAwaited.period;
|
||||||
|
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
|
||||||
const { t, locale } = await getServerTranslations();
|
const { t, locale } = await getServerTranslations();
|
||||||
|
|
||||||
let stats: StatsResponse | null = null;
|
let stats: StatsResponse | null = null;
|
||||||
try {
|
try {
|
||||||
stats = await fetchStats();
|
stats = await fetchStats(period);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch stats:", e);
|
console.error("Failed to fetch stats:", e);
|
||||||
}
|
}
|
||||||
@@ -62,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 = [
|
||||||
@@ -110,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 (
|
||||||
@@ -150,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
|
||||||
@@ -175,20 +201,19 @@ export default async function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reading activity line chart */}
|
{/* Reading activity line chart */}
|
||||||
{reading_over_time.length > 0 && (
|
<Card hover={false}>
|
||||||
<Card hover={false}>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardHeader>
|
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
|
||||||
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RcAreaChart
|
<RcAreaChart
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={reading_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_read }))}
|
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
|
||||||
color="hsl(142 60% 45%)"
|
color="hsl(142 60% 45%)"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Charts row */}
|
{/* Charts row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -351,20 +376,47 @@ export default async function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monthly additions line chart – full width */}
|
{/* Additions line chart – full width */}
|
||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
|
||||||
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RcAreaChart
|
<RcAreaChart
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={additions_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_added }))}
|
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
|
||||||
color="hsl(198 78% 37%)"
|
color="hsl(198 78% 37%)"
|
||||||
/>
|
/>
|
||||||
</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,11 +589,13 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchStats() {
|
export async function fetchStats(period?: "day" | "week" | "month") {
|
||||||
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } });
|
const params = period && period !== "month" ? `?period=${period}` : "";
|
||||||
|
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -70,7 +70,15 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.readingStatus": "Reading status",
|
"dashboard.readingStatus": "Reading status",
|
||||||
"dashboard.byFormat": "By format",
|
"dashboard.byFormat": "By format",
|
||||||
"dashboard.byLibrary": "By library",
|
"dashboard.byLibrary": "By library",
|
||||||
"dashboard.booksAdded": "Books added (last 12 months)",
|
"dashboard.booksAdded": "Books added",
|
||||||
|
"dashboard.jobsOverTime": "Job runs",
|
||||||
|
"dashboard.jobScan": "Scan",
|
||||||
|
"dashboard.jobRebuild": "Rebuild",
|
||||||
|
"dashboard.jobThumbnail": "Thumbnails",
|
||||||
|
"dashboard.jobOther": "Other",
|
||||||
|
"dashboard.periodDay": "Day",
|
||||||
|
"dashboard.periodWeek": "Week",
|
||||||
|
"dashboard.periodMonth": "Month",
|
||||||
"dashboard.popularSeries": "Popular series",
|
"dashboard.popularSeries": "Popular series",
|
||||||
"dashboard.noSeries": "No series yet",
|
"dashboard.noSeries": "No series yet",
|
||||||
"dashboard.unknown": "Unknown",
|
"dashboard.unknown": "Unknown",
|
||||||
@@ -84,7 +92,7 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.withIsbn": "With ISBN",
|
"dashboard.withIsbn": "With ISBN",
|
||||||
"dashboard.currentlyReading": "Currently reading",
|
"dashboard.currentlyReading": "Currently reading",
|
||||||
"dashboard.recentlyRead": "Recently read",
|
"dashboard.recentlyRead": "Recently read",
|
||||||
"dashboard.readingActivity": "Reading activity (last 12 months)",
|
"dashboard.readingActivity": "Reading activity",
|
||||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||||
"dashboard.noCurrentlyReading": "No books in progress",
|
"dashboard.noCurrentlyReading": "No books in progress",
|
||||||
"dashboard.noRecentlyRead": "No books read recently",
|
"dashboard.noRecentlyRead": "No books read recently",
|
||||||
|
|||||||
@@ -68,7 +68,15 @@ const fr = {
|
|||||||
"dashboard.readingStatus": "Statut de lecture",
|
"dashboard.readingStatus": "Statut de lecture",
|
||||||
"dashboard.byFormat": "Par format",
|
"dashboard.byFormat": "Par format",
|
||||||
"dashboard.byLibrary": "Par bibliothèque",
|
"dashboard.byLibrary": "Par bibliothèque",
|
||||||
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)",
|
"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.periodWeek": "Semaine",
|
||||||
|
"dashboard.periodMonth": "Mois",
|
||||||
"dashboard.popularSeries": "Séries populaires",
|
"dashboard.popularSeries": "Séries populaires",
|
||||||
"dashboard.noSeries": "Aucune série pour le moment",
|
"dashboard.noSeries": "Aucune série pour le moment",
|
||||||
"dashboard.unknown": "Inconnu",
|
"dashboard.unknown": "Inconnu",
|
||||||
@@ -82,7 +90,7 @@ const fr = {
|
|||||||
"dashboard.withIsbn": "Avec ISBN",
|
"dashboard.withIsbn": "Avec ISBN",
|
||||||
"dashboard.currentlyReading": "En cours de lecture",
|
"dashboard.currentlyReading": "En cours de lecture",
|
||||||
"dashboard.recentlyRead": "Derniers livres lus",
|
"dashboard.recentlyRead": "Derniers livres lus",
|
||||||
"dashboard.readingActivity": "Activité de lecture (12 derniers mois)",
|
"dashboard.readingActivity": "Activité de lecture",
|
||||||
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
"dashboard.pageProgress": "p. {{current}} / {{total}}",
|
||||||
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
"dashboard.noCurrentlyReading": "Aucun livre en cours",
|
||||||
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
"dashboard.noRecentlyRead": "Aucun livre lu récemment",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const nextConfig = {
|
|||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
images: {
|
images: {
|
||||||
minimumCacheTTL: 86400,
|
minimumCacheTTL: 86400,
|
||||||
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "stripstream-backoffice",
|
"name": "stripstream-backoffice",
|
||||||
"version": "1.24.0",
|
"version": "1.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 7082",
|
"dev": "next dev -p 7082",
|
||||||
|
|||||||
@@ -170,6 +170,34 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
- Real-time notifications via Telegram Bot API (`sendMessage` and `sendPhoto`)
|
||||||
|
- Configuration: bot token, chat ID, enable/disable toggle
|
||||||
|
- Test connection button in settings
|
||||||
|
|
||||||
|
### Granular Event Toggles
|
||||||
|
12 individually configurable notification events grouped by category:
|
||||||
|
|
||||||
|
| Category | Events |
|
||||||
|
|----------|--------|
|
||||||
|
| Scans | `scan_completed`, `scan_failed`, `scan_cancelled` |
|
||||||
|
| Thumbnails | `thumbnail_completed`, `thumbnail_failed`, `thumbnail_cancelled` |
|
||||||
|
| Conversion | `conversion_completed`, `conversion_failed`, `conversion_cancelled` |
|
||||||
|
| Metadata | `metadata_approved`, `metadata_batch_completed`, `metadata_refresh_completed` |
|
||||||
|
|
||||||
|
### Thumbnail Images in Notifications
|
||||||
|
- Book cover thumbnails attached to applicable notifications (conversion, metadata approval)
|
||||||
|
- Uses `sendPhoto` multipart upload with fallback to text-only `sendMessage`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
- Shared `crates/notifications` crate used by both API and indexer
|
||||||
|
- Fire-and-forget: notification failures are logged but never block the main operation
|
||||||
|
- Messages formatted in HTML with event-specific icons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Page Rendering & Caching
|
## Page Rendering & Caching
|
||||||
|
|
||||||
### Page Extraction
|
### Page Extraction
|
||||||
@@ -238,13 +266,16 @@
|
|||||||
## Backoffice (Web UI)
|
## Backoffice (Web UI)
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
- Statistics cards: books, series, authors, libraries
|
- Statistics cards: books, series, authors, libraries, pages, total size
|
||||||
- Donut charts: reading status breakdown, format distribution
|
- Interactive charts (recharts): donut, area, stacked bar, horizontal bar
|
||||||
- Bar charts: books per language
|
- Reading status breakdown, format distribution, library distribution
|
||||||
- Per-library reading progress bars
|
- Currently reading section with progress bars
|
||||||
- Top series by book/page count
|
- Recently read section with cover thumbnails
|
||||||
- Monthly addition timeline
|
- Reading activity over time (area chart)
|
||||||
- Metadata coverage stats
|
- Books added over time (area chart)
|
||||||
|
- Per-library stacked reading progress
|
||||||
|
- Top series by book count
|
||||||
|
- Metadata coverage and provider breakdown
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
- **Libraries**: list, create, delete, configure monitoring and metadata provider
|
- **Libraries**: list, create, delete, configure monitoring and metadata provider
|
||||||
@@ -253,7 +284,7 @@
|
|||||||
- **Authors**: list with book/series counts, detail with author's books
|
- **Authors**: list with book/series counts, detail with author's books
|
||||||
- **Jobs**: history, live progress via SSE, error details
|
- **Jobs**: history, live progress via SSE, error details
|
||||||
- **Tokens**: create, list, revoke API tokens
|
- **Tokens**: create, list, revoke API tokens
|
||||||
- **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent)
|
- **Settings**: image processing, cache, thumbnails, external services (Prowlarr, qBittorrent), notifications (Telegram)
|
||||||
|
|
||||||
### Interactive Features
|
### Interactive Features
|
||||||
- Real-time search with suggestions
|
- Real-time search with suggestions
|
||||||
|
|||||||
Reference in New Issue
Block a user