diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bda0a3..5c688e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and + the version, for a dashboard such as Homepage. + ### Changed - The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the diff --git a/src/main.rs b/src/main.rs index 5f30962..085b606 100644 --- a/src/main.rs +++ b/src/main.rs @@ -369,9 +369,9 @@ async fn run(ctx: &Arc, cmd: Cmd) -> Result<()> { } } -/// The counts `ipx status` prints. A running daemon's socket answers with this directly rather -/// than through the job queue. -async fn status(ctx: &Ctx) -> Event { +/// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with +/// this directly rather than through the job queue. +pub(crate) async fn status(ctx: &Ctx) -> Event { match ctx.db.counts().await { Ok((pending, downloaded)) => { let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0); diff --git a/src/web.rs b/src/web.rs index 0f10fc6..e1bc51a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -52,6 +52,7 @@ pub fn router(state: WebState) -> Router { .route("/api/fetch", post(fetch_now)) .route("/api/opml", get(export_opml).post(import_opml)) .route("/api/settings", get(get_settings).patch(patch_settings)) + .route("/api/status", get(status)) .route("/api/popular", get(get_popular)) .route("/api/directory", get(get_directory)) .route("/api/popular/{id}", post(subscribe_popular)) @@ -1808,6 +1809,22 @@ async fn import_opml( Ok(Json(serde_json::json!({ "added": added, "already": already }))) } +/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's +/// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token. +async fn status(State(state): State) -> Response { + match crate::status(&state.ctx).await { + Event::Status { feeds, pending, downloaded } => Json(serde_json::json!({ + "feeds": feeds, + "pending": pending, + "downloaded": downloaded, + "version": env!("CARGO_PKG_VERSION"), + })) + .into_response(), + Event::Error { msg } => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(), + _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + #[derive(Serialize)] struct Settings { schedule: String, diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 62b09b7..92ee4f2 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1197,6 +1197,17 @@ test('the logo is the dark one in dark mode and the light one in light mode', as await page.evaluate(() => setTheme('modern', 'dark')); }); +test('/api/status gives a dashboard the counts, with the shared token as a cookie', async ({ page }) => { + // As Homepage's customapi widget asks: no session, only the token in a Cookie header. + const r = await page.request.get('/api/status', { headers: { cookie: `ipx_token=${TOKEN}` } }); + expect(r.status()).toBe(200); + const s = await r.json(); + for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number'); + expect(s.version).toMatch(/^\d+\.\d+\.\d+/); + const none = await page.request.get('/api/status', { headers: { cookie: '' } }); + expect(none.status()).toBe(401); +}); + test('the favicon is the logo, square, from both pages', async ({ page }) => { await page.evaluate(() => setTheme('modern', 'light')); await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);