feat: 前端添加下载状态卡片 (#385)
This commit is contained in:
@@ -168,14 +168,6 @@ pub struct SysInfoResponse {
|
||||
pub available_disk: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TaskStatusResponse {
|
||||
pub running: bool,
|
||||
pub last_run: Option<String>,
|
||||
pub next_run: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromQueryResult)]
|
||||
pub struct VideoSourceDetail {
|
||||
pub id: i32,
|
||||
|
||||
@@ -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<Arc<DatabaseConnection>>,
|
||||
ValidatedJson(config): ValidatedJson<Config>,
|
||||
) -> Result<ApiResponse<Arc<Config>>, 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());
|
||||
};
|
||||
|
||||
@@ -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<impl futures::Stream<Item = Result<Event, Infallible>>> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<MpscWriter>) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
let history = log_writer.log_history.lock();
|
||||
let rx = log_writer.sender.subscribe();
|
||||
let history_logs: Vec<String> = 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)))
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
98
crates/bili_sync/src/api/routes/sse/mod.rs
Normal file
98
crates/bili_sync/src/api/routes/sse/mod.rs
Normal file
@@ -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<impl futures::Stream<Item = Result<Event, Infallible>>> {
|
||||
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<MpscWriter>) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
let history = log_writer.log_history.lock();
|
||||
let rx = log_writer.sender.subscribe();
|
||||
let history_logs: Vec<String> = 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<impl futures::Stream<Item = Result<Event, Infallible>>> {
|
||||
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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DatabaseConnection>, bili_client: Arc<BiliClient>) {
|
||||
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<DatabaseConnection>, bili_client:
|
||||
}
|
||||
info!("本轮任务执行完毕,等待下一轮执行");
|
||||
}
|
||||
drop(_lock);
|
||||
TASK_STATUS_NOTIFIER.finish_running(_lock);
|
||||
time::sleep(time::Duration::from_secs(config.interval)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
79
crates/bili_sync/src/utils/task_notifier.rs
Normal file
79
crates/bili_sync/src/utils/task_notifier.rs
Normal file
@@ -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<TaskStatusNotifier> = LazyLock::new(TaskStatusNotifier::new);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TaskStatus {
|
||||
is_running: bool,
|
||||
last_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
last_finish: Option<chrono::DateTime<chrono::Local>>,
|
||||
next_run: Option<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
pub struct TaskStatusNotifier {
|
||||
mutex: tokio::sync::Mutex<()>,
|
||||
tx: tokio::sync::watch::Sender<Arc<TaskStatus>>,
|
||||
rx: tokio::sync::watch::Receiver<Arc<TaskStatus>>,
|
||||
}
|
||||
|
||||
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<MutexGuard<'_, ()>> {
|
||||
self.mutex.try_lock().ok()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> tokio::sync::watch::Receiver<Arc<TaskStatus>> {
|
||||
self.rx.clone()
|
||||
}
|
||||
}
|
||||
11
web/bun.lock
11
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=="],
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
14
web/src/lib/stores/tasks.ts
Normal file
14
web/src/lib/stores/tasks.ts
Normal file
@@ -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<TaskStatus>(undefined);
|
||||
|
||||
export function setTaskStatus(status: TaskStatus) {
|
||||
taskStatusStore.set(status);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toaster />
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>仪表盘 - Bili Sync</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
/* 避免最右侧 tooltip 溢出导致的无限抖动 */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -228,8 +226,8 @@
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card class="max-w-full overflow-hidden md:col-span-2">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">最近入库</CardTitle>
|
||||
<VideoIcon class="text-muted-foreground h-4 w-4" />
|
||||
@@ -273,18 +271,90 @@
|
||||
</BarChart>
|
||||
</Chart.Container>
|
||||
{:else}
|
||||
<div class="text-muted-foreground flex h-[300px] items-center justify-center text-sm">
|
||||
<div class="text-muted-foreground flex h-[200px] items-center justify-center text-sm">
|
||||
暂无视频统计数据
|
||||
</div>
|
||||
{/if}</CardContent
|
||||
>
|
||||
</Card>
|
||||
<Card class="max-w-full md:col-span-1">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">下载任务状态</CardTitle>
|
||||
<CloudDownloadIcon class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if $taskStatusStore}
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>当前任务状态</span>
|
||||
<Badge variant={$taskStatusStore.is_running ? 'default' : 'outline'}>
|
||||
{$taskStatusStore.is_running ? '运行中' : '未运行'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<PlayIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">开始运行</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{$taskStatusStore.last_run
|
||||
? new Date($taskStatusStore.last_run).toLocaleString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircleIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">运行结束</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{$taskStatusStore.last_finish
|
||||
? new Date($taskStatusStore.last_finish).toLocaleString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<CalendarIcon class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-sm">下次运行</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-sm">
|
||||
{$taskStatusStore.next_run
|
||||
? new Date($taskStatusStore.next_run).toLocaleString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground text-sm">加载中...</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:系统监控 -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- 内存使用情况 -->
|
||||
<Card>
|
||||
<Card class="overflow-hidden">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">内存使用情况</CardTitle>
|
||||
<MemoryStickIcon class="text-muted-foreground h-4 w-4" />
|
||||
@@ -332,12 +402,12 @@
|
||||
{#snippet tooltip()}
|
||||
<MyChartTooltip
|
||||
labelFormatter={(v: Date) => {
|
||||
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 @@
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card class="overflow-hidden">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">CPU 使用情况</CardTitle>
|
||||
<CpuIcon class="text-muted-foreground h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="overflow-hidden">
|
||||
{#if sysInfo}
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
@@ -399,12 +469,12 @@
|
||||
{#snippet tooltip()}
|
||||
<MyChartTooltip
|
||||
labelFormatter={(v: Date) => {
|
||||
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"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
(error: Event) => {
|
||||
console.error('日志流错误:', error);
|
||||
toast.error('日志流出现错误,请稍后重试');
|
||||
toast.error('日志流出现错误,请检查网络连接或稍后重试');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user