From cc25749445bc10af3a839400be48a0ca54822b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Thu, 10 Jul 2025 15:13:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E7=8A=B6=E6=80=81=E5=8D=A1=E7=89=87=20(#385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/api/response.rs | 8 -- crates/bili_sync/src/api/routes/config/mod.rs | 4 +- .../bili_sync/src/api/routes/dashboard/mod.rs | 58 +--------- crates/bili_sync/src/api/routes/logs/mod.rs | 32 ------ crates/bili_sync/src/api/routes/mod.rs | 6 +- crates/bili_sync/src/api/routes/sse/mod.rs | 98 ++++++++++++++++ .../src/api/routes/{logs => sse}/mpsc.rs | 0 crates/bili_sync/src/task/mod.rs | 2 +- crates/bili_sync/src/task/video_downloader.rs | 7 +- crates/bili_sync/src/utils/mod.rs | 1 + crates/bili_sync/src/utils/task_notifier.rs | 79 +++++++++++++ web/bun.lock | 11 +- web/package.json | 7 +- web/src/lib/api.ts | 22 +++- web/src/lib/stores/tasks.ts | 14 +++ web/src/routes/+layout.svelte | 25 +++++ web/src/routes/+page.svelte | 106 +++++++++++++++--- web/src/routes/logs/+page.svelte | 2 +- 18 files changed, 341 insertions(+), 141 deletions(-) delete mode 100644 crates/bili_sync/src/api/routes/logs/mod.rs create mode 100644 crates/bili_sync/src/api/routes/sse/mod.rs rename crates/bili_sync/src/api/routes/{logs => sse}/mpsc.rs (100%) create mode 100644 crates/bili_sync/src/utils/task_notifier.rs create mode 100644 web/src/lib/stores/tasks.ts diff --git a/crates/bili_sync/src/api/response.rs b/crates/bili_sync/src/api/response.rs index 65e4816..bbec3a1 100644 --- a/crates/bili_sync/src/api/response.rs +++ b/crates/bili_sync/src/api/response.rs @@ -168,14 +168,6 @@ pub struct SysInfoResponse { pub available_disk: u64, } -#[derive(Serialize)] -pub struct TaskStatusResponse { - pub running: bool, - pub last_run: Option, - pub next_run: Option, - pub last_error: Option, -} - #[derive(Serialize, FromQueryResult)] pub struct VideoSourceDetail { pub id: i32, diff --git a/crates/bili_sync/src/api/routes/config/mod.rs b/crates/bili_sync/src/api/routes/config/mod.rs index cebbd4a..b859fb0 100644 --- a/crates/bili_sync/src/api/routes/config/mod.rs +++ b/crates/bili_sync/src/api/routes/config/mod.rs @@ -9,7 +9,7 @@ use sea_orm::DatabaseConnection; use crate::api::error::InnerApiError; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::config::{Config, VersionedConfig}; -use crate::task::DOWNLOADER_TASK_RUNNING; +use crate::utils::task_notifier::TASK_STATUS_NOTIFIER; pub(super) fn router() -> Router { Router::new().route("/config", get(get_config).put(update_config)) @@ -25,7 +25,7 @@ pub async fn update_config( Extension(db): Extension>, ValidatedJson(config): ValidatedJson, ) -> Result>, ApiError> { - let Ok(_lock) = DOWNLOADER_TASK_RUNNING.try_lock() else { + let Some(_lock) = TASK_STATUS_NOTIFIER.detect_running() else { // 简单避免一下可能的不一致现象 return Err(InnerApiError::BadRequest("下载任务正在运行,无法修改配置".to_string()).into()); }; diff --git a/crates/bili_sync/src/api/routes/dashboard/mod.rs b/crates/bili_sync/src/api/routes/dashboard/mod.rs index fe6fedd..b405211 100644 --- a/crates/bili_sync/src/api/routes/dashboard/mod.rs +++ b/crates/bili_sync/src/api/routes/dashboard/mod.rs @@ -1,27 +1,16 @@ -use std::convert::Infallible; use std::sync::Arc; -use std::time::Duration; -use axum::response::Sse; -use axum::response::sse::{Event, KeepAlive}; use axum::routing::get; use axum::{Extension, Router}; use bili_sync_entity::*; -use futures::StreamExt; use sea_orm::entity::prelude::*; use sea_orm::{FromQueryResult, Statement}; -use sysinfo::{ - CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, ProcessRefreshKind, RefreshKind, System, get_current_pid, -}; -use tokio_stream::wrappers::IntervalStream; -use crate::api::response::{DashBoardResponse, DayCountPair, SysInfoResponse}; +use crate::api::response::{DashBoardResponse, DayCountPair}; use crate::api::wrapper::{ApiError, ApiResponse}; pub(super) fn router() -> Router { - Router::new() - .route("/dashboard", get(get_dashboard)) - .route("/dashboard/sysinfo", get(get_sysinfo)) + Router::new().route("/dashboard", get(get_dashboard)) } async fn get_dashboard( @@ -86,46 +75,3 @@ ORDER BY videos_by_day, })); } - -async fn get_sysinfo() -> Sse>> { - let sys_refresh_kind = sys_refresh_kind(); - let disk_refresh_kind = disk_refresh_kind(); - let mut system = System::new(); - let mut disks = Disks::new(); - // safety: this functions always returns Ok on Linux/MacOS/Windows - let self_pid = get_current_pid().unwrap(); - let stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(2))) - .map(move |_| { - system.refresh_specifics(sys_refresh_kind); - disks.refresh_specifics(true, disk_refresh_kind); - let process = match system.process(self_pid) { - Some(p) => p, - None => return None, - }; - let info = SysInfoResponse { - total_memory: system.total_memory(), - used_memory: system.used_memory(), - process_memory: process.memory(), - used_cpu: system.global_cpu_usage(), - process_cpu: process.cpu_usage() / system.cpus().len() as f32, - total_disk: disks.iter().map(|d| d.total_space()).sum(), - available_disk: disks.iter().map(|d| d.available_space()).sum(), - }; - serde_json::to_string(&info).ok() - }) - .take_while(|info| futures::future::ready(info.is_some())) - // safety: after `take_while`, `info` is always Some - .map(|info| Ok(Event::default().data(info.unwrap()))); - Sse::new(stream).keep_alive(KeepAlive::default()) -} - -fn sys_refresh_kind() -> RefreshKind { - RefreshKind::nothing() - .with_cpu(CpuRefreshKind::nothing().with_cpu_usage()) - .with_memory(MemoryRefreshKind::nothing().with_ram()) - .with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory()) -} - -fn disk_refresh_kind() -> DiskRefreshKind { - DiskRefreshKind::nothing().with_storage() -} diff --git a/crates/bili_sync/src/api/routes/logs/mod.rs b/crates/bili_sync/src/api/routes/logs/mod.rs deleted file mode 100644 index 1b7502d..0000000 --- a/crates/bili_sync/src/api/routes/logs/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -mod mpsc; -use std::convert::Infallible; -use std::time::Duration; - -use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::routing::get; -use axum::{Extension, Router}; -use futures::{Stream, StreamExt}; -pub use mpsc::{MAX_HISTORY_LOGS, MpscWriter}; -use tokio_stream::wrappers::BroadcastStream; - -pub(super) fn router() -> Router { - Router::new().route("/logs", get(logs)) -} - -async fn logs(Extension(log_writer): Extension) -> Sse>> { - let history = log_writer.log_history.lock(); - let rx = log_writer.sender.subscribe(); - let history_logs: Vec = history.iter().cloned().collect(); - drop(history); - - let history_stream = { futures::stream::iter(history_logs.into_iter().map(|msg| Ok(Event::default().data(msg)))) }; - - let stream = BroadcastStream::new(rx).filter_map(async |msg| match msg { - Ok(log_message) => Some(Ok(Event::default().data(log_message))), - Err(e) => { - error!("Broadcast stream error: {:?}", e); - None - } - }); - Sse::new(history_stream.chain(stream)).keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) -} diff --git a/crates/bili_sync/src/api/routes/mod.rs b/crates/bili_sync/src/api/routes/mod.rs index c711170..ccaac2c 100644 --- a/crates/bili_sync/src/api/routes/mod.rs +++ b/crates/bili_sync/src/api/routes/mod.rs @@ -18,12 +18,12 @@ use crate::config::VersionedConfig; mod config; mod dashboard; -mod logs; mod me; +mod sse; mod video_sources; mod videos; -pub use logs::{MAX_HISTORY_LOGS, MpscWriter}; +pub use sse::{MAX_HISTORY_LOGS, MpscWriter}; pub fn router() -> Router { Router::new().route("/image-proxy", get(image_proxy)).nest( @@ -33,7 +33,7 @@ pub fn router() -> Router { .merge(video_sources::router()) .merge(videos::router()) .merge(dashboard::router()) - .merge(logs::router()) + .merge(sse::router()) .layer(middleware::from_fn(auth)), ) } diff --git a/crates/bili_sync/src/api/routes/sse/mod.rs b/crates/bili_sync/src/api/routes/sse/mod.rs new file mode 100644 index 0000000..551a5b6 --- /dev/null +++ b/crates/bili_sync/src/api/routes/sse/mod.rs @@ -0,0 +1,98 @@ +mod mpsc; + +use std::convert::Infallible; +use std::time::Duration; + +use axum::response::Sse; +use axum::response::sse::{Event, KeepAlive}; +use axum::routing::get; +use axum::{Extension, Router}; +use futures::{Stream, StreamExt}; +pub use mpsc::{MAX_HISTORY_LOGS, MpscWriter}; +use sysinfo::{ + CpuRefreshKind, DiskRefreshKind, Disks, MemoryRefreshKind, ProcessRefreshKind, RefreshKind, System, get_current_pid, +}; +use tokio_stream::wrappers::{BroadcastStream, IntervalStream, WatchStream}; + +use crate::api::response::SysInfoResponse; +use crate::utils::task_notifier::TASK_STATUS_NOTIFIER; + +pub(super) fn router() -> Router { + Router::new() + .route("/sse/logs", get(logs)) + .route("/sse/tasks", get(get_tasks)) + .route("/sse/sysinfo", get(get_sysinfo)) +} + +async fn get_tasks() -> Sse>> { + let stream = WatchStream::new(TASK_STATUS_NOTIFIER.subscribe()).filter_map(|status| async move { + match serde_json::to_string(&status) { + Ok(status) => Some(Ok(Event::default().data(status))), + Err(_) => None, + } + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +async fn logs(Extension(log_writer): Extension) -> Sse>> { + let history = log_writer.log_history.lock(); + let rx = log_writer.sender.subscribe(); + let history_logs: Vec = history.iter().cloned().collect(); + drop(history); + + let history_stream = { futures::stream::iter(history_logs.into_iter().map(|msg| Ok(Event::default().data(msg)))) }; + + let stream = BroadcastStream::new(rx).filter_map(async |msg| match msg { + Ok(log_message) => Some(Ok(Event::default().data(log_message))), + Err(e) => { + error!("Log stream error: {:?}", e); + None + } + }); + Sse::new(history_stream.chain(stream)).keep_alive(KeepAlive::default()) +} + +async fn get_sysinfo() -> Sse>> { + let sys_refresh_kind = sys_refresh_kind(); + let disk_refresh_kind = disk_refresh_kind(); + let mut system = System::new(); + let mut disks = Disks::new(); + // safety: this functions always returns Ok on Linux/MacOS/Windows + let self_pid = get_current_pid().unwrap(); + let stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(2))).filter_map(move |_| { + system.refresh_specifics(sys_refresh_kind); + disks.refresh_specifics(true, disk_refresh_kind); + let process = match system.process(self_pid) { + Some(p) => p, + None => return futures::future::ready(None), + }; + let info = SysInfoResponse { + total_memory: system.total_memory(), + used_memory: system.used_memory(), + process_memory: process.memory(), + used_cpu: system.global_cpu_usage(), + process_cpu: process.cpu_usage() / system.cpus().len() as f32, + total_disk: disks.iter().map(|d| d.total_space()).sum(), + available_disk: disks.iter().map(|d| d.available_space()).sum(), + }; + match serde_json::to_string(&info) { + Ok(json) => futures::future::ready(Some(Ok(Event::default().data(json)))), + Err(_) => { + error!("Failed to serialize system info"); + futures::future::ready(None) + } + } + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +fn sys_refresh_kind() -> RefreshKind { + RefreshKind::nothing() + .with_cpu(CpuRefreshKind::nothing().with_cpu_usage()) + .with_memory(MemoryRefreshKind::nothing().with_ram()) + .with_processes(ProcessRefreshKind::nothing().with_cpu().with_memory()) +} + +fn disk_refresh_kind() -> DiskRefreshKind { + DiskRefreshKind::nothing().with_storage() +} diff --git a/crates/bili_sync/src/api/routes/logs/mpsc.rs b/crates/bili_sync/src/api/routes/sse/mpsc.rs similarity index 100% rename from crates/bili_sync/src/api/routes/logs/mpsc.rs rename to crates/bili_sync/src/api/routes/sse/mpsc.rs diff --git a/crates/bili_sync/src/task/mod.rs b/crates/bili_sync/src/task/mod.rs index fb3a3d6..db2d677 100644 --- a/crates/bili_sync/src/task/mod.rs +++ b/crates/bili_sync/src/task/mod.rs @@ -2,4 +2,4 @@ mod http_server; mod video_downloader; pub use http_server::http_server; -pub use video_downloader::{DOWNLOADER_TASK_RUNNING, video_downloader}; +pub use video_downloader::video_downloader; diff --git a/crates/bili_sync/src/task/video_downloader.rs b/crates/bili_sync/src/task/video_downloader.rs index 4383b6b..9184d60 100644 --- a/crates/bili_sync/src/task/video_downloader.rs +++ b/crates/bili_sync/src/task/video_downloader.rs @@ -6,16 +6,15 @@ use tokio::time; use crate::bilibili::{self, BiliClient}; use crate::config::VersionedConfig; use crate::utils::model::get_enabled_video_sources; +use crate::utils::task_notifier::TASK_STATUS_NOTIFIER; use crate::workflow::process_video_source; -pub static DOWNLOADER_TASK_RUNNING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - /// 启动周期下载视频的任务 pub async fn video_downloader(connection: Arc, bili_client: Arc) { let mut anchor = chrono::Local::now().date_naive(); loop { info!("开始执行本轮视频下载任务.."); - let _lock = DOWNLOADER_TASK_RUNNING.lock().await; + let _lock = TASK_STATUS_NOTIFIER.start_running().await; let config = VersionedConfig::get().load_full(); 'inner: { if let Err(e) = config.check() { @@ -55,7 +54,7 @@ pub async fn video_downloader(connection: Arc, bili_client: } info!("本轮任务执行完毕,等待下一轮执行"); } - drop(_lock); + TASK_STATUS_NOTIFIER.finish_running(_lock); time::sleep(time::Duration::from_secs(config.interval)).await; } } diff --git a/crates/bili_sync/src/utils/mod.rs b/crates/bili_sync/src/utils/mod.rs index 58b8dbc..a3638a8 100644 --- a/crates/bili_sync/src/utils/mod.rs +++ b/crates/bili_sync/src/utils/mod.rs @@ -5,6 +5,7 @@ pub mod model; pub mod nfo; pub mod signal; pub mod status; +pub mod task_notifier; pub mod validation; use tracing_subscriber::fmt; use tracing_subscriber::layer::SubscriberExt; diff --git a/crates/bili_sync/src/utils/task_notifier.rs b/crates/bili_sync/src/utils/task_notifier.rs new file mode 100644 index 0000000..c1009d8 --- /dev/null +++ b/crates/bili_sync/src/utils/task_notifier.rs @@ -0,0 +1,79 @@ +use std::sync::{Arc, LazyLock}; + +use serde::Serialize; +use tokio::sync::MutexGuard; + +use crate::config::VersionedConfig; + +pub static TASK_STATUS_NOTIFIER: LazyLock = LazyLock::new(TaskStatusNotifier::new); + +#[derive(Serialize)] +pub struct TaskStatus { + is_running: bool, + last_run: Option>, + last_finish: Option>, + next_run: Option>, +} + +pub struct TaskStatusNotifier { + mutex: tokio::sync::Mutex<()>, + tx: tokio::sync::watch::Sender>, + rx: tokio::sync::watch::Receiver>, +} + +impl Default for TaskStatus { + fn default() -> Self { + Self { + is_running: false, + last_run: None, + last_finish: None, + next_run: None, + } + } +} + +impl TaskStatusNotifier { + pub fn new() -> Self { + let (tx, rx) = tokio::sync::watch::channel(Arc::new(TaskStatus::default())); + Self { + mutex: tokio::sync::Mutex::const_new(()), + tx, + rx, + } + } + + pub async fn start_running(&self) -> MutexGuard<()> { + let lock = self.mutex.lock().await; + let _ = self.tx.send(Arc::new(TaskStatus { + is_running: true, + last_run: Some(chrono::Local::now()), + last_finish: None, + next_run: None, + })); + lock + } + + pub fn finish_running(&self, _lock: MutexGuard<()>) { + let last_status = self.tx.borrow(); + let last_run = last_status.last_run.clone(); + drop(last_status); + let config = VersionedConfig::get().load(); + let now = chrono::Local::now(); + + let _ = self.tx.send(Arc::new(TaskStatus { + is_running: false, + last_run, + last_finish: Some(now), + next_run: now.checked_add_signed(chrono::Duration::seconds(config.interval as i64)), + })); + } + + /// 精确探测任务执行状态,保证如果读取到“未运行”,那么在锁释放之前任务不会被执行 + pub fn detect_running(&self) -> Option> { + self.mutex.try_lock().ok() + } + + pub fn subscribe(&self) -> tokio::sync::watch::Receiver> { + self.rx.clone() + } +} diff --git a/web/bun.lock b/web/bun.lock index 364fbbf..556b42f 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -3,9 +3,6 @@ "workspaces": { "": { "name": "my-app", - "dependencies": { - "@tanstack/svelte-query": "^5.81.5", - }, "devDependencies": { "@eslint/compat": "^1.2.5", "@eslint/js": "^9.18.0", @@ -39,7 +36,7 @@ "tw-animate-css": "^1.3.2", "typescript": "^5.0.0", "typescript-eslint": "^8.20.0", - "vite": "7.0.2", + "vite": "7.0.3", }, }, }, @@ -254,10 +251,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.8", "", { "dependencies": { "@tailwindcss/node": "4.1.8", "@tailwindcss/oxide": "4.1.8", "tailwindcss": "4.1.8" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A=="], - "@tanstack/query-core": ["@tanstack/query-core@5.81.5", "", {}, "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q=="], - - "@tanstack/svelte-query": ["@tanstack/svelte-query@5.81.5", "", { "dependencies": { "@tanstack/query-core": "5.81.5" }, "peerDependencies": { "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" } }, "sha512-P2TaL+dGHWwQ83CyX8I9icb/1lYUSFwqQvGHI8jzFbacOEtVtQQXB0N12fotvn/BDn7Eh+tyCNiiMN56tFuFJw=="], - "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], @@ -692,7 +685,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "vite": ["vite@7.0.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw=="], + "vite": ["vite@7.0.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-y2L5oJZF7bj4c0jgGYgBNSdIu+5HF+m68rn2cQXFbGoShdhV1phX9rbnxy9YXj82aS8MMsCLAAFkRxZeWdldrQ=="], "vitefu": ["vitefu@1.0.6", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" }, "optionalPeers": ["vite"] }, "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA=="], diff --git a/web/package.json b/web/package.json index d1436b1..2ff5621 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,7 @@ "tw-animate-css": "^1.3.2", "typescript": "^5.0.0", "typescript-eslint": "^8.20.0", - "vite": "7.0.2" + "vite": "7.0.3" }, "private": true, "scripts": { @@ -47,8 +47,5 @@ "format": "prettier --write .", "lint": "prettier --check . && eslint ." }, - "type": "module", - "dependencies": { - "@tanstack/svelte-query": "^5.81.5" - } + "type": "module" } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b000f32..1c9dcf2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -228,7 +228,7 @@ class ApiClient { onError?: (error: Event) => void ): EventSource { const token = localStorage.getItem('authToken'); - const url = `/api/logs${token ? `?token=${encodeURIComponent(token)}` : ''}`; + const url = `/api/sse/logs${token ? `?token=${encodeURIComponent(token)}` : ''}`; const eventSource = new EventSource(url); eventSource.onmessage = (event) => { onMessage(event.data); @@ -244,7 +244,7 @@ class ApiClient { onError?: (error: Event) => void ): EventSource { const token = localStorage.getItem('authToken'); - const url = `/api/dashboard/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`; + const url = `/api/sse/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`; const eventSource = new EventSource(url); eventSource.onmessage = (event) => { try { @@ -259,6 +259,22 @@ class ApiClient { } return eventSource; } + + createTasksStream( + onMessage: (data: string) => void, + onError?: (error: Event) => void + ): EventSource { + const token = localStorage.getItem('authToken'); + const url = `/api/sse/tasks${token ? `?token=${encodeURIComponent(token)}` : ''}`; + const eventSource = new EventSource(url); + eventSource.onmessage = (event) => { + onMessage(event.data); + }; + if (onError) { + eventSource.onerror = onError; + } + return eventSource; + } } // 创建默认的 API 客户端实例 @@ -293,6 +309,8 @@ const api = { ) => apiClient.createSysInfoStream(onMessage, onError), createLogStream: (onMessage: (data: string) => void, onError?: (error: Event) => void) => apiClient.createLogStream(onMessage, onError), + createTasksStream: (onMessage: (data: string) => void, onError?: (error: Event) => void) => + apiClient.createTasksStream(onMessage, onError), setAuthToken: (token: string) => apiClient.setAuthToken(token), clearAuthToken: () => apiClient.clearAuthToken() }; diff --git a/web/src/lib/stores/tasks.ts b/web/src/lib/stores/tasks.ts new file mode 100644 index 0000000..9652cf6 --- /dev/null +++ b/web/src/lib/stores/tasks.ts @@ -0,0 +1,14 @@ +import { writable } from 'svelte/store'; + +export interface TaskStatus { + is_running: boolean; + last_run: Date | null; + last_finish: Date | null; + next_run: Date | null; +} + +export const taskStatusStore = writable(undefined); + +export function setTaskStatus(status: TaskStatus) { + taskStatusStore.set(status); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index ea50e53..9b3b1b1 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,6 +6,31 @@ import { breadcrumbStore } from '$lib/stores/breadcrumb'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import { Toaster } from '$lib/components/ui/sonner/index.js'; + import { onMount } from 'svelte'; + import { setTaskStatus, type TaskStatus } from '$lib/stores/tasks'; + import api from '$lib/api'; + import { toast } from 'svelte-sonner'; + + let tasksStream: EventSource | undefined; + + onMount(() => { + tasksStream = api.createTasksStream( + (data: string) => { + const status: TaskStatus = JSON.parse(data); + setTaskStatus(status); + }, + (error: Event) => { + console.error('任务状态流错误:', error); + toast.error('任务状态流错误,请检查网络连接或稍后重试'); + } + ); + return () => { + if (tasksStream) { + tasksStream.close(); + tasksStream = undefined; + } + }; + }); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 2bc2985..e375d42 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -9,6 +9,7 @@ import { BarChart, AreaChart } from 'layerchart'; import { setBreadcrumb } from '$lib/stores/breadcrumb'; import { toast } from 'svelte-sonner'; + import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download'; import api from '$lib/api'; import type { DashBoardResponse, SysInfoResponse, ApiError } from '$lib/types'; import DatabaseIcon from '@lucide/svelte/icons/database'; @@ -20,6 +21,10 @@ import HardDriveIcon from '@lucide/svelte/icons/hard-drive'; import CpuIcon from '@lucide/svelte/icons/cpu'; import MemoryStickIcon from '@lucide/svelte/icons/memory-stick'; + import PlayIcon from '@lucide/svelte/icons/play'; + import CheckCircleIcon from '@lucide/svelte/icons/check-circle'; + import CalendarIcon from '@lucide/svelte/icons/calendar'; + import { taskStatusStore } from '$lib/stores/tasks'; let dashboardData: DashBoardResponse | null = null; let sysInfo: SysInfoResponse | null = null; @@ -61,7 +66,7 @@ }, (error) => { console.error('系统信息流错误:', error); - toast.error('系统信息流出现错误,请稍后重试'); + toast.error('系统信息流出现错误,请检查网络连接或稍后重试'); } ); } @@ -142,13 +147,6 @@ 仪表盘 - Bili Sync - -
@@ -228,8 +226,8 @@
-
- +
+ 最近入库 @@ -273,18 +271,90 @@ {:else} -
+
暂无视频统计数据
{/if} + + + 下载任务状态 + + + + {#if $taskStatusStore} +
+
+
+
+ 当前任务状态 + + {$taskStatusStore.is_running ? '运行中' : '未运行'} + +
+
+
+
+ + 开始运行 +
+ + {$taskStatusStore.last_run + ? new Date($taskStatusStore.last_run).toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }) + : '-'} + +
+
+
+ + 运行结束 +
+ + {$taskStatusStore.last_finish + ? new Date($taskStatusStore.last_finish).toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }) + : '-'} + +
+
+
+ + 下次运行 +
+ + {$taskStatusStore.next_run + ? new Date($taskStatusStore.next_run).toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true + }) + : '-'} + +
+
+
+ {:else} +
加载中...
+ {/if} +
+
- + 内存使用情况 @@ -332,12 +402,12 @@ {#snippet tooltip()} { - return new Intl.DateTimeFormat('en-US', { + return v.toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true - }).format(v); + }); }} valueFormatter={(v: number) => formatBytes(v)} indicator="line" @@ -353,12 +423,12 @@ - + CPU 使用情况 - + {#if sysInfo}
@@ -399,12 +469,12 @@ {#snippet tooltip()} { - return new Intl.DateTimeFormat('en-US', { + return v.toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true - }).format(v); + }); }} valueFormatter={(v: number) => formatCpu(v)} indicator="line" diff --git a/web/src/routes/logs/+page.svelte b/web/src/routes/logs/+page.svelte index 3542f01..beaeeb0 100644 --- a/web/src/routes/logs/+page.svelte +++ b/web/src/routes/logs/+page.svelte @@ -34,7 +34,7 @@ }, (error: Event) => { console.error('日志流错误:', error); - toast.error('日志流出现错误,请稍后重试'); + toast.error('日志流出现错误,请检查网络连接或稍后重试'); } ); }