feat: Library 批量注册 + PeopleView 修复 + 国际化
Library: - 添加 checkbox 和鼠标框选功能 - Unregistered 卡片添加快速注册按钮 - 状态标签移至左下方,人脸图标移至左侧中间 - Deep Scan 改为 unregister -> register -> process - 完整国际化(日语、繁体中文、英语) PeopleView: - 修复卡片不显示问题(使用 get_file_traces API) - 移除对 identity_id 字段的依赖 其他: - 添加日语语言包 - 添加 useDragSelect composable - 新增文档:Face Bbox Null Bug Report
This commit is contained in:
@@ -4,6 +4,40 @@ mod proxy;
|
||||
#[path = "../db.rs"]
|
||||
mod db;
|
||||
|
||||
#[path = "../validate.rs"]
|
||||
mod validate;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct SystemStatsResponse {
|
||||
pub cpu_used_percent: f64,
|
||||
pub memory_used_percent: f64,
|
||||
pub memory_available_mb: u64,
|
||||
pub memory_total_mb: u64,
|
||||
pub swap_used_percent: f64,
|
||||
pub swap_available_mb: u64,
|
||||
pub swap_total_mb: u64,
|
||||
pub gpu_available: bool,
|
||||
pub gpu_utilization: Option<f64>,
|
||||
pub gpu_memory_used_pct: Option<f64>,
|
||||
pub ane_available: bool,
|
||||
pub ane_utilization: Option<f64>,
|
||||
pub source: String,
|
||||
pub chip_name: Option<String>,
|
||||
pub cpu_power: Option<f64>,
|
||||
pub gpu_power: Option<f64>,
|
||||
pub ane_power: Option<f64>,
|
||||
pub sys_power: Option<f64>,
|
||||
pub ram_power: Option<f64>,
|
||||
pub cpu_temp: Option<f64>,
|
||||
pub gpu_temp: Option<f64>,
|
||||
pub ecpu_freq_mhz: Option<u32>,
|
||||
pub pcpu_freq_mhz: Option<u32>,
|
||||
pub gpu_freq_mhz: Option<u32>,
|
||||
pub ecpu_cores: Option<u8>,
|
||||
pub pcpu_cores: Option<u8>,
|
||||
pub gpu_cores: Option<u8>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = db::init_db();
|
||||
|
||||
@@ -24,6 +24,16 @@ CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS video_marks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_uuid TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
start_frame INTEGER NOT NULL,
|
||||
end_frame INTEGER,
|
||||
note TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -36,6 +46,7 @@ CREATE TABLE IF NOT EXISTS app_users (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_search_history_sort ON search_history(pinned DESC, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_video_marks_file ON video_marks(file_uuid);
|
||||
";
|
||||
|
||||
static DB_CONN: Mutex<Option<Connection>> = Mutex::new(None);
|
||||
@@ -281,6 +292,75 @@ pub fn delete_bookmark(id: i64) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VideoMark {
|
||||
pub id: i64,
|
||||
pub file_uuid: String,
|
||||
pub tag: String,
|
||||
pub start_frame: i64,
|
||||
pub end_frame: Option<i64>,
|
||||
pub note: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub fn get_video_marks(file_uuid: String) -> Result<Vec<VideoMark>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, file_uuid, tag, start_frame, end_frame, note, created_at FROM video_marks WHERE file_uuid = ?1 ORDER BY start_frame ASC")
|
||||
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||
let items = stmt
|
||||
.query_map([file_uuid], |row| {
|
||||
Ok(VideoMark {
|
||||
id: row.get(0)?,
|
||||
file_uuid: row.get(1)?,
|
||||
tag: row.get(2)?,
|
||||
start_frame: row.get(3)?,
|
||||
end_frame: row.get(4)?,
|
||||
note: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Query error: {}", e))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub fn save_video_mark(
|
||||
file_uuid: String,
|
||||
tag: String,
|
||||
start_frame: i64,
|
||||
end_frame: Option<i64>,
|
||||
note: Option<String>,
|
||||
) -> Result<VideoMark, String> {
|
||||
let conn = get_conn()?;
|
||||
conn.execute(
|
||||
"INSERT INTO video_marks (file_uuid, tag, start_frame, end_frame, note) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![file_uuid, tag, start_frame, end_frame, note],
|
||||
)
|
||||
.map_err(|e| format!("Insert error: {}", e))?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(VideoMark {
|
||||
id,
|
||||
file_uuid,
|
||||
tag,
|
||||
start_frame,
|
||||
end_frame,
|
||||
note,
|
||||
created_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub fn delete_video_mark(id: i64) -> Result<(), String> {
|
||||
let conn = get_conn()?;
|
||||
conn.execute("DELETE FROM video_marks WHERE id = ?1", [id])
|
||||
.map_err(|e| format!("Delete error: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_user(username: &str, password: &str) -> Result<Option<User>, String> {
|
||||
let conn = get_conn()?;
|
||||
let mut stmt = conn
|
||||
|
||||
@@ -4,6 +4,7 @@ use dirs;
|
||||
use markbase_core::vfs::VfsBackend;
|
||||
mod proxy;
|
||||
mod db;
|
||||
mod validate;
|
||||
|
||||
use serde::Serialize;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
@@ -12,7 +13,6 @@ use std::sync::{Arc, Mutex};
|
||||
use std::num::NonZeroUsize;
|
||||
use lru::LruCache;
|
||||
use image::GenericImageView;
|
||||
use image::DynamicImage;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
static THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
@@ -242,7 +242,7 @@ async fn get_files(args: GetFilesArgs) -> Result<Vec<FileInfo>, String> {
|
||||
modified_time: f["modified_time"].as_str().unwrap_or("").to_string(),
|
||||
is_registered: is_reg,
|
||||
registration_time: f["registration_time"].as_str().map(|s| s.to_string()),
|
||||
ingested: status == "completed",
|
||||
ingested: matches!(status.as_str(), "completed" | "keyword_ready" | "semantic_ready" | "face_mgmt_ready" | "agent_ready"),
|
||||
status,
|
||||
})
|
||||
})
|
||||
@@ -999,14 +999,17 @@ struct CreateIdentityFromFaceResult {
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn create_identity_from_face(name: String, _face_id: Option<String>, _face_row_id: Option<i64>, file_uuid: String) -> Result<CreateIdentityFromFaceResult, String> {
|
||||
async fn create_identity_from_face(name: String, _face_id: Option<String>, _face_row_id: Option<i64>, file_uuid: String, trace_ids: Option<Vec<i64>>) -> Result<CreateIdentityFromFaceResult, String> {
|
||||
let client = get_client();
|
||||
|
||||
// Use POST /api/v1/file/:file_uuid/pending-person to create pending identity
|
||||
let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY);
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"name": name
|
||||
});
|
||||
if let Some(tids) = &trace_ids {
|
||||
body["trace_ids"] = serde_json::json!(tids);
|
||||
}
|
||||
|
||||
let resp = client.post(&url)
|
||||
.json(&body)
|
||||
@@ -1293,6 +1296,16 @@ async fn merge_history(source_uuid: Option<String>, target_uuid: Option<String>,
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn stop_service(name: String) -> Result<String, String> {
|
||||
if name == "Worker" || name == "worker" {
|
||||
// Worker has no port — stop via pidfile / pkill
|
||||
let output = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("sleep 0.5; WPID=$(cat /Users/accusys/momentry_core/logs/worker_3002.pid 2>/dev/null); if [ -n \"$WPID\" ]; then pkill -P $WPID 2>/dev/null; kill -9 $WPID 2>/dev/null; fi; pkill -9 -f 'target/release/momentry worker' 2>/dev/null; rm -f /Users/accusys/momentry_core/logs/worker_3002.pid; echo 'stopped'")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to stop worker: {}", e))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
return Ok(stdout.trim().to_string());
|
||||
}
|
||||
let port = match name.as_str() {
|
||||
"Core API" | "core" => 3002,
|
||||
"MarkBase" | "markbase" => 11438,
|
||||
@@ -1313,9 +1326,31 @@ async fn stop_service(name: String) -> Result<String, String> {
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn start_service(name: String) -> Result<String, String> {
|
||||
if name == "Worker" || name == "worker" {
|
||||
// Worker has no port — verify via pidfile instead of lsof
|
||||
let output = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("cd /Users/accusys/momentry_core && nohup bash run-worker-3002.sh > /tmp/momentry_worker.log 2>&1 &")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to start worker: {}", e))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Worker start failed: {}", stderr));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let check = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("WPID=$(cat /Users/accusys/momentry_core/logs/worker_3002.pid 2>/dev/null); [ -n \"$WPID\" ] && kill -0 $WPID 2>/dev/null && echo alive")
|
||||
.output()
|
||||
.map_err(|e| format!("Worker check failed: {}", e))?;
|
||||
if String::from_utf8_lossy(&check.stdout).contains("alive") {
|
||||
return Ok("started".to_string());
|
||||
}
|
||||
return Err("Worker failed to start".to_string());
|
||||
}
|
||||
let cmd = match name.as_str() {
|
||||
"MarkBase" | "markbase" => {
|
||||
"cd /Users/accusys/markbase && nohup cargo run --bin markbase-core -- web-start --port 11438 > /tmp/markbase_web.log 2>&1 &"
|
||||
"mkdir -p /Users/accusys/markbase/data && cd /Users/accusys/markbase && nohup ./target/release/markbase-core web-start --port 11438 > /tmp/markbase_web.log 2>&1 &"
|
||||
}
|
||||
"Core API" | "core" => {
|
||||
"cd /Users/accusys/momentry_core && nohup bash run-server-3002.sh > /tmp/momentry_start.log 2>&1 &"
|
||||
@@ -1405,48 +1440,6 @@ async fn get_current_user(username: String) -> Result<Option<db::User>, String>
|
||||
db::get_user_by_username(&username)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn list_admin_users() -> Result<serde_json::Value, String> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
|
||||
let db_path = home.join("momentry/data/auth.sqlite");
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open database: {}", e))?;
|
||||
|
||||
let mut stmt = conn.prepare("SELECT username, home_dir, status FROM sftpgo_users")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let users = stmt.query_map([], |row| {
|
||||
Ok(serde_json::json!({
|
||||
"username": row.get::<_, String>(0)?,
|
||||
"homeDir": row.get::<_, String>(1)?,
|
||||
"status": row.get::<_, i32>(2)?
|
||||
}))
|
||||
}).map_err(|e| format!("Failed to query: {}", e))?;
|
||||
|
||||
let result: Vec<serde_json::Value> = users.filter_map(|u| u.ok()).collect();
|
||||
Ok(serde_json::Value::Array(result))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_system_stats() -> Result<serde_json::Value, String> {
|
||||
let mut stats = serde_json::Map::new();
|
||||
stats.insert("cpu".to_string(), serde_json::Value::Number(25.into()));
|
||||
stats.insert("memory".to_string(), serde_json::Value::Number(60.into()));
|
||||
stats.insert("disk".to_string(), serde_json::Value::Number(45.into()));
|
||||
Ok(serde_json::Value::Object(stats))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn list_shares() -> Result<serde_json::Value, String> {
|
||||
let shares = vec![
|
||||
serde_json::json!({"name": "MarkBase Web", "port": 11438, "status": "running", "protocol": "HTTP/WebDAV", "url": "http://localhost:11438"}),
|
||||
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running", "protocol": "SSH/SFTP"}),
|
||||
serde_json::json!({"name": "SMB", "port": 4445, "status": "running", "protocol": "SMB"}),
|
||||
];
|
||||
Ok(serde_json::Value::Array(shares))
|
||||
}
|
||||
|
||||
// === Client Commands ===
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
@@ -1509,6 +1502,29 @@ fn main() {
|
||||
rt.block_on(proxy::start_proxy_server());
|
||||
});
|
||||
|
||||
fn ensure_markbase_running() {
|
||||
let check = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("lsof -ti :11438")
|
||||
.output();
|
||||
|
||||
if let Ok(output) = check {
|
||||
if output.stdout.is_empty() {
|
||||
let cmd = "mkdir -p /Users/accusys/markbase/data && \
|
||||
cd /Users/accusys/markbase && \
|
||||
nohup ./target/release/markbase-core web-start --port 11438 \
|
||||
> /tmp/markbase_web.log 2>&1 &";
|
||||
let _ = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.spawn();
|
||||
eprintln!("[startup] MarkBase started on port 11438");
|
||||
} else {
|
||||
eprintln!("[startup] MarkBase already running on port 11438");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
@@ -1517,6 +1533,7 @@ fn main() {
|
||||
if let Err(e) = db::init_db() {
|
||||
eprintln!("Failed to init database: {}", e);
|
||||
}
|
||||
ensure_markbase_running();
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -1568,11 +1585,11 @@ fn main() {
|
||||
db::get_bookmarks,
|
||||
db::save_bookmark,
|
||||
db::delete_bookmark,
|
||||
db::get_video_marks,
|
||||
db::save_video_mark,
|
||||
db::delete_video_mark,
|
||||
login_user,
|
||||
get_current_user,
|
||||
list_admin_users,
|
||||
get_system_stats,
|
||||
list_shares,
|
||||
list_client_files,
|
||||
mkdir_client,
|
||||
rm_client,
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
extract::{State, Query},
|
||||
http::{HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use crate::db;
|
||||
|
||||
#[path = "validate.rs"]
|
||||
mod validate;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use image::GenericImageView;
|
||||
use image::DynamicImage;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::num::NonZeroUsize;
|
||||
use lru::LruCache;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const CORE_API: &str = "http://localhost:3002";
|
||||
const CORE_API: &str = "http://127.0.0.1:3002";
|
||||
const MARKBASE_API: &str = "http://127.0.0.1:11438";
|
||||
const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
|
||||
const DIST_DIR: &str = "../dist";
|
||||
const PROFILE_DIRS: [&str; 4] = [
|
||||
@@ -28,7 +31,6 @@ const PROFILE_DIRS: [&str; 4] = [
|
||||
|
||||
static FACE_THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static PROFILE_PROXY_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||
static RAW_DIM_PROXY_CACHE: Mutex<Option<HashMap<String, (u32, u32)>>> = Mutex::new(None);
|
||||
static THUMB_SEM_MAP_PROXY: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
|
||||
|
||||
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
|
||||
@@ -84,11 +86,16 @@ pub async fn start_proxy_server() {
|
||||
.route("/api/v1/face-thumbnail", axum::routing::get(get_face_thumbnail_handler))
|
||||
.route("/api/v1/media/frame", axum::routing::get(get_frame_handler))
|
||||
.route("/api/v1/identity-matches", axum::routing::get(get_identity_matches_handler))
|
||||
.route("/api/v1/trace-profile-file", axum::routing::get(get_trace_profile_file_handler))
|
||||
.route("/api/v1/cluster-results", axum::routing::get(get_cluster_results_handler))
|
||||
.route("/api/v1/processor-json", axum::routing::get(get_processor_json_handler))
|
||||
.route("/api/v1/file/thumbnail", axum::routing::get(get_file_thumbnail_by_path_handler))
|
||||
.route("/api/v1/file/:file_uuid/pose", axum::routing::get(get_pose_handler))
|
||||
.route("/api/v1/file/:file_uuid/appearance", axum::routing::get(get_appearance_handler))
|
||||
.route("/api/v1/admin/config", axum::routing::get(get_admin_config_handler))
|
||||
.route("/api/v1/admin/config", axum::routing::post(save_admin_config_handler))
|
||||
.route("/api/v1/system-stats", axum::routing::get(get_system_stats_handler))
|
||||
.route("/api/v1/system-stats/validate", axum::routing::get(validate_system_stats_handler))
|
||||
.fallback(fallback_handler)
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
@@ -206,7 +213,7 @@ async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::R
|
||||
if let Some(rest) = path.strip_prefix("/api/v1/identity/") {
|
||||
if rest.ends_with("/profile-image") {
|
||||
let uuid = rest.trim_end_matches("/profile-image").to_string();
|
||||
let response = proxy_api(state, req).await;
|
||||
let response = proxy_to(state, req, CORE_API, true).await;
|
||||
// Invalidate profile cache on successful upload
|
||||
let status = response.status();
|
||||
if status == StatusCode::OK || status == StatusCode::CREATED {
|
||||
@@ -220,12 +227,14 @@ async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::R
|
||||
}
|
||||
|
||||
// Handle search-history CRUD locally
|
||||
if path.starts_with("/api/v1/search-history") || path.starts_with("/api/v1/bookmarks") {
|
||||
if path.starts_with("/api/v1/search-history") || path.starts_with("/api/v1/bookmarks") || path.starts_with("/api/v1/video-marks") || path.starts_with("/api/v1/video-mark/") {
|
||||
return handle_local_api(req).await;
|
||||
}
|
||||
|
||||
if path.starts_with("/api/") {
|
||||
return proxy_api(state, req).await;
|
||||
if path.starts_with("/api/v2/") {
|
||||
return proxy_to(state, req, MARKBASE_API, false).await;
|
||||
} else if path.starts_with("/api/") {
|
||||
return proxy_to(state, req, CORE_API, true).await;
|
||||
} else if state.serve_static {
|
||||
return serve_spa(&path).await;
|
||||
} else {
|
||||
@@ -235,6 +244,7 @@ async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::R
|
||||
|
||||
async fn handle_local_api(req: axum::extract::Request) -> Response {
|
||||
let path = req.uri().path().to_string();
|
||||
let query_str = req.uri().query().unwrap_or("").to_string();
|
||||
let method = req.method().clone();
|
||||
let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default();
|
||||
|
||||
@@ -271,26 +281,46 @@ async fn handle_local_api(req: axum::extract::Request) -> Response {
|
||||
return save_bookmark_handler_inner(body_bytes);
|
||||
}
|
||||
|
||||
// Video marks (local SQLite)
|
||||
if method == axum::http::Method::GET && path == "/api/v1/video-marks" {
|
||||
return get_video_marks_handler_inner(&query_str);
|
||||
}
|
||||
if method == axum::http::Method::POST && path == "/api/v1/video-marks" {
|
||||
return save_video_mark_handler_inner(body_bytes);
|
||||
}
|
||||
if method == axum::http::Method::DELETE {
|
||||
if let Some(id_str) = path.strip_prefix("/api/v1/video-mark/") {
|
||||
if let Ok(id) = id_str.parse::<i64>() {
|
||||
return delete_video_mark_handler_inner(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::NOT_FOUND, "Not found").into_response()
|
||||
}
|
||||
|
||||
async fn proxy_api(state: ProxyState, req: axum::extract::Request) -> Response {
|
||||
async fn proxy_to(state: ProxyState, req: axum::extract::Request, base: &str, inject_api_key: bool) -> Response {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
let headers = req.headers().clone();
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let query_str = req.uri().query().unwrap_or("").to_string();
|
||||
let mut url = format!("{}{}?api_key={}", CORE_API, path, API_KEY);
|
||||
let mut url = if inject_api_key {
|
||||
format!("{}{}?api_key={}", base, path, API_KEY)
|
||||
} else {
|
||||
format!("{}{}", base, path)
|
||||
};
|
||||
for pair in query_str.split('&') {
|
||||
if pair.is_empty() || pair.starts_with("api_key=") {
|
||||
if pair.is_empty() || (inject_api_key && pair.starts_with("api_key=")) {
|
||||
continue;
|
||||
}
|
||||
url.push_str(&format!("&{}", pair));
|
||||
}
|
||||
|
||||
let target = if base == CORE_API { "core api" } else { "markbase" };
|
||||
let range_hdr = headers.get("range").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
||||
eprintln!("[proxy] --> {} {} (core api) range={}", method, path, range_hdr);
|
||||
eprintln!("[proxy] --> {} {} ({}) range={}", method, path, target, range_hdr);
|
||||
|
||||
let reqwest_method = match method.as_str() {
|
||||
"POST" => reqwest::Method::POST,
|
||||
@@ -537,6 +567,45 @@ fn delete_bookmark_handler_inner(id: i64) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_video_marks_handler_inner(query_str: &str) -> Response {
|
||||
let file_uuid = query_str
|
||||
.split('&')
|
||||
.find_map(|pair| {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
let k = it.next().unwrap_or("");
|
||||
let v = it.next().unwrap_or("");
|
||||
if k == "file_uuid" { Some(v.to_string()) } else { None }
|
||||
})
|
||||
.unwrap_or_default();
|
||||
match db::get_video_marks(file_uuid) {
|
||||
Ok(items) => axum::Json(items).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_video_mark_handler_inner(body: axum::body::Bytes) -> Response {
|
||||
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
||||
};
|
||||
let file_uuid = req.get("file_uuid").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let tag = req.get("tag").and_then(|v| v.as_str()).unwrap_or("default").to_string();
|
||||
let start_frame = req.get("start_frame").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let end_frame = req.get("end_frame").and_then(|v| v.as_i64());
|
||||
let note = req.get("note").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
match db::save_video_mark(file_uuid, tag, start_frame, end_frame, note) {
|
||||
Ok(item) => axum::Json(item).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_video_mark_handler_inner(id: i64) -> Response {
|
||||
match db::delete_video_mark(id) {
|
||||
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_identity_profile_handler_inner(uuid: String) -> Response {
|
||||
let start = std::time::Instant::now();
|
||||
let no_dash = uuid.replace('-', "");
|
||||
@@ -579,33 +648,6 @@ async fn get_identity_profile_handler_inner(uuid: String) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read EXIF orientation and physically rotate pixels, stripping orientation tag.
|
||||
/// Fetch raw video dimensions from Core API with in-memory cache.
|
||||
async fn fetch_raw_dimensions(uuid: &str) -> Option<(u32, u32)> {
|
||||
{
|
||||
let cache = RAW_DIM_PROXY_CACHE.lock().unwrap();
|
||||
if let Some(cache) = cache.as_ref() {
|
||||
if let Some(dims) = cache.get(uuid) {
|
||||
return Some(*dims);
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
|
||||
let result = reqwest::get(&url).await.ok()?
|
||||
.json::<serde_json::Value>().await.ok()?;
|
||||
let w = result["width"].as_u64().unwrap_or(0) as u32;
|
||||
let h = result["height"].as_u64().unwrap_or(0) as u32;
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
{
|
||||
let mut cache = RAW_DIM_PROXY_CACHE.lock().unwrap();
|
||||
let cache = cache.get_or_insert_with(HashMap::new);
|
||||
cache.insert(uuid.to_string(), (w, h));
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
|
||||
|
||||
async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
@@ -729,6 +771,46 @@ async fn get_identity_matches_handler(axum::extract::Query(params): axum::extrac
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_trace_profile_file_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_uuid = match params.get("file_uuid") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing file_uuid").into_response(),
|
||||
};
|
||||
let trace_id = match params.get("trace_id") {
|
||||
Some(v) => v.clone(),
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing trace_id").into_response(),
|
||||
};
|
||||
let output_bases = [
|
||||
"/Users/accusys/momentry/output",
|
||||
"/Users/accusys/momentry/output_dev",
|
||||
"/Volumes/external/momentry/output",
|
||||
"/Volumes/external/momentry/output_dev",
|
||||
];
|
||||
let mut profile_path = None;
|
||||
for base in &output_bases {
|
||||
let path = format!("{}/{}/trace_{}/trace_profile.json", base, file_uuid, trace_id);
|
||||
if std::path::Path::new(&path).exists() {
|
||||
profile_path = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match profile_path {
|
||||
Some(path) => match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response();
|
||||
}
|
||||
};
|
||||
axum::Json(parsed).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
},
|
||||
None => (StatusCode::NOT_FOUND, "Trace profile file not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_cluster_results_handler(axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||
let file_hash = match params.get("file_hash") {
|
||||
Some(v) => v.clone(),
|
||||
@@ -736,29 +818,46 @@ async fn get_cluster_results_handler(axum::extract::Query(params): axum::extract
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/v1/file/{}/face-groups", CORE_API, file_hash);
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("X-API-Key", API_KEY)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
// Try new cluster-results endpoint first, fallback to face-groups
|
||||
let url = format!("{}/api/v1/cluster-results?file_hash={}", CORE_API, file_hash);
|
||||
let resp = client.get(&url).header("X-API-Key", API_KEY).send().await;
|
||||
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
let b = r.text().await.unwrap_or_default();
|
||||
// Check if response has data
|
||||
if status.is_success() {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&b) {
|
||||
let fg = v.get("face_groups").and_then(|x| x.as_array()).map(|x| x.len()).unwrap_or(0);
|
||||
if fg > 0 {
|
||||
return ([(axum::http::header::CONTENT_TYPE, "application/json")], b).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
// Fallback to face-groups
|
||||
let fg_url = format!("{}/api/v1/file/{}/face-groups", CORE_API, file_hash);
|
||||
let fg_resp = match client.get(&fg_url).header("X-API-Key", API_KEY).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Face groups request failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body = match resp.text().await {
|
||||
let fg_status = fg_resp.status();
|
||||
let fg_body = match fg_resp.text().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body read failed: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let axum_status = axum::http::StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let axum_status = axum::http::StatusCode::from_u16(fg_status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !axum_status.is_success() {
|
||||
return (axum_status, body).into_response();
|
||||
return (axum_status, fg_body).into_response();
|
||||
}
|
||||
|
||||
let result: serde_json::Value = match serde_json::from_str(&body) {
|
||||
let result: serde_json::Value = match serde_json::from_str(&fg_body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("JSON parse failed: {}", e)).into_response(),
|
||||
};
|
||||
@@ -907,12 +1006,11 @@ async fn get_frame_handler(State(state): State<ProxyState>, axum::extract::Query
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)).into_response(),
|
||||
}
|
||||
} else {
|
||||
let fps = file_info["fps"].as_f64().unwrap_or(24.0);
|
||||
let time_sec = frame as f64 / fps;
|
||||
// Use select filter for exact frame extraction instead of -ss seeking
|
||||
let output = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss", &time_sec.to_string(),
|
||||
"-i", &file_path,
|
||||
"-vf", &format!("select=eq(n\\,{frame})"),
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
"-f", "mjpeg",
|
||||
@@ -1015,4 +1113,288 @@ async fn get_appearance_handler(
|
||||
};
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
const ADMIN_CONFIG_PATH: &str = "/Users/accusys/momentry/config/admin_config.json";
|
||||
|
||||
async fn get_admin_config_handler() -> Response {
|
||||
match std::fs::read_to_string(ADMIN_CONFIG_PATH) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(v) => axum::Json(v).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::NOT_FOUND, format!("Config file not found: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_admin_config_handler(axum::extract::Json(payload): axum::extract::Json<serde_json::Value>) -> Response {
|
||||
match serde_json::to_string_pretty(&payload) {
|
||||
Ok(formatted) => {
|
||||
match std::fs::write(ADMIN_CONFIG_PATH, formatted) {
|
||||
Ok(()) => axum::Json(serde_json::json!({"success": true, "message": "Config saved"})).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Write failed: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Format failed: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "aarch64")]
|
||||
mod macmon_sampler {
|
||||
use macmon::Sampler;
|
||||
|
||||
pub struct MacmonData {
|
||||
pub cpu_power: f32,
|
||||
pub gpu_power: f32,
|
||||
pub ane_power: f32,
|
||||
pub sys_power: f32,
|
||||
pub ram_power: f32,
|
||||
pub cpu_temp: f32,
|
||||
pub gpu_temp: f32,
|
||||
pub ecpu_freq_mhz: u32,
|
||||
pub pcpu_freq_mhz: u32,
|
||||
pub gpu_freq_mhz: u32,
|
||||
pub cpu_scaled_ratio: f32,
|
||||
pub gpu_scaled_ratio: f32,
|
||||
pub chip_name: String,
|
||||
pub ecpu_cores: u8,
|
||||
pub pcpu_cores: u8,
|
||||
pub gpu_cores: u8,
|
||||
pub ram_total: u64,
|
||||
pub ram_usage: u64,
|
||||
pub swap_total: u64,
|
||||
pub swap_used: u64,
|
||||
}
|
||||
|
||||
fn get_swap_info() -> (u64, u64) {
|
||||
use std::process::Command;
|
||||
let output = match Command::new("sysctl")
|
||||
.args(["-n", "vm.swapusage"])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return (0, 0),
|
||||
};
|
||||
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||
|
||||
let total_mb = parts.get(2)
|
||||
.and_then(|v| v.trim_end_matches('M').parse::<f64>().ok())
|
||||
.unwrap_or(0.0) as u64;
|
||||
let used_mb = parts.get(5)
|
||||
.and_then(|v| v.trim_end_matches('M').parse::<f64>().ok())
|
||||
.unwrap_or(0.0) as u64;
|
||||
(total_mb, used_mb)
|
||||
}
|
||||
|
||||
pub fn get_metrics() -> Option<MacmonData> {
|
||||
let mut sampler = Sampler::new().ok()?;
|
||||
let metrics = sampler.get_metrics(100).ok()?;
|
||||
let soc = sampler.get_soc_info();
|
||||
let (swap_total, swap_used) = get_swap_info();
|
||||
|
||||
Some(MacmonData {
|
||||
cpu_power: metrics.cpu_power,
|
||||
gpu_power: metrics.gpu_power,
|
||||
ane_power: metrics.ane_power,
|
||||
sys_power: metrics.sys_power,
|
||||
ram_power: metrics.ram_power,
|
||||
cpu_temp: metrics.temp.cpu_temp_avg,
|
||||
gpu_temp: metrics.temp.gpu_temp_avg,
|
||||
ecpu_freq_mhz: metrics.ecpu_freq_mhz,
|
||||
pcpu_freq_mhz: metrics.pcpu_freq_mhz,
|
||||
gpu_freq_mhz: metrics.gpu_freq_mhz,
|
||||
cpu_scaled_ratio: metrics.cpu_scaled_ratio,
|
||||
gpu_scaled_ratio: metrics.gpu_scaled_ratio,
|
||||
chip_name: soc.chip_name.clone(),
|
||||
ecpu_cores: soc.ecpu_cores,
|
||||
pcpu_cores: soc.pcpu_cores,
|
||||
gpu_cores: soc.gpu_cores,
|
||||
ram_total: metrics.memory.ram_total,
|
||||
ram_usage: metrics.memory.ram_usage,
|
||||
swap_total,
|
||||
swap_used,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
mod macmon_sampler {
|
||||
pub struct MacmonData;
|
||||
pub fn get_metrics() -> Option<MacmonData> { None }
|
||||
}
|
||||
|
||||
pub use super::SystemStatsResponse;
|
||||
|
||||
async fn get_system_stats_handler() -> Response {
|
||||
// 1. Try macmon first (Apple Silicon only)
|
||||
if let Some(m) = macmon_sampler::get_metrics() {
|
||||
let ram_total_mb = m.ram_total / 1024 / 1024;
|
||||
let ram_usage_mb = m.ram_usage / 1024 / 1024;
|
||||
let ram_available_mb = ram_total_mb.saturating_sub(ram_usage_mb);
|
||||
let memory_used_percent = if ram_total_mb > 0 {
|
||||
(ram_usage_mb as f64 / ram_total_mb as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let swap_total_mb = m.swap_total;
|
||||
let swap_used_mb = m.swap_used;
|
||||
let swap_available_mb = swap_total_mb.saturating_sub(swap_used_mb);
|
||||
let swap_used_percent = if swap_total_mb > 0 {
|
||||
(swap_used_mb as f64 / swap_total_mb as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let stats = SystemStatsResponse {
|
||||
cpu_used_percent: m.cpu_scaled_ratio as f64 * 100.0,
|
||||
memory_used_percent,
|
||||
memory_available_mb: ram_available_mb,
|
||||
memory_total_mb: ram_total_mb,
|
||||
swap_used_percent,
|
||||
swap_available_mb,
|
||||
swap_total_mb,
|
||||
gpu_available: true,
|
||||
gpu_utilization: Some(m.gpu_scaled_ratio as f64 * 100.0),
|
||||
gpu_memory_used_pct: None,
|
||||
ane_available: m.ane_power > 0.0,
|
||||
ane_utilization: None,
|
||||
source: "macmon".to_string(),
|
||||
chip_name: Some(m.chip_name),
|
||||
cpu_power: Some(m.cpu_power as f64),
|
||||
gpu_power: Some(m.gpu_power as f64),
|
||||
ane_power: Some(m.ane_power as f64),
|
||||
sys_power: Some(m.sys_power as f64),
|
||||
ram_power: Some(m.ram_power as f64),
|
||||
cpu_temp: Some(m.cpu_temp as f64),
|
||||
gpu_temp: Some(m.gpu_temp as f64),
|
||||
ecpu_freq_mhz: Some(m.ecpu_freq_mhz),
|
||||
pcpu_freq_mhz: Some(m.pcpu_freq_mhz),
|
||||
gpu_freq_mhz: Some(m.gpu_freq_mhz),
|
||||
ecpu_cores: Some(m.ecpu_cores),
|
||||
pcpu_cores: Some(m.pcpu_cores),
|
||||
gpu_cores: Some(m.gpu_cores),
|
||||
};
|
||||
return axum::Json(stats).into_response();
|
||||
}
|
||||
|
||||
// 2. Fallback: Use Core API
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/health/detailed", CORE_API);
|
||||
|
||||
match client.get(&url).header("api_key", API_KEY).send().await {
|
||||
Ok(resp) => {
|
||||
match resp.json::<serde_json::Value>().await {
|
||||
Ok(data) => {
|
||||
if let Some(resources) = data.get("resources") {
|
||||
let stats = SystemStatsResponse {
|
||||
cpu_used_percent: resources.get("cpu_used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
memory_used_percent: resources.get("memory_used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
memory_available_mb: resources.get("memory_available_mb").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
memory_total_mb: resources.get("memory_total_mb").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
swap_used_percent: 0.0,
|
||||
swap_available_mb: 0,
|
||||
swap_total_mb: 0,
|
||||
gpu_available: resources.get("gpu_available").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
gpu_utilization: resources.get("gpu_utilization").and_then(|v| v.as_f64()),
|
||||
gpu_memory_used_pct: resources.get("gpu_memory_used_pct").and_then(|v| v.as_f64()),
|
||||
ane_available: resources.get("ane_available").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
ane_utilization: resources.get("ane_utilization").and_then(|v| v.as_f64()),
|
||||
source: "fallback".to_string(),
|
||||
chip_name: None,
|
||||
cpu_power: None,
|
||||
gpu_power: None,
|
||||
ane_power: None,
|
||||
sys_power: None,
|
||||
ram_power: None,
|
||||
cpu_temp: None,
|
||||
gpu_temp: None,
|
||||
ecpu_freq_mhz: None,
|
||||
pcpu_freq_mhz: None,
|
||||
gpu_freq_mhz: None,
|
||||
ecpu_cores: None,
|
||||
pcpu_cores: None,
|
||||
gpu_cores: None,
|
||||
};
|
||||
axum::Json(stats).into_response()
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "No resources in response").into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Parse failed: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("Core API error: {}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_system_stats_handler(
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
// Parse layer parameter
|
||||
let layers: Vec<u32> = params
|
||||
.get("layers")
|
||||
.map(|s| s.split(',').filter_map(|n| n.trim().parse().ok()).collect())
|
||||
.unwrap_or_else(|| vec![1, 2, 4]);
|
||||
|
||||
// Get current macmon stats
|
||||
let stats = if let Some(m) = macmon_sampler::get_metrics() {
|
||||
let ram_total_mb = m.ram_total / 1024 / 1024;
|
||||
let ram_usage_mb = m.ram_usage / 1024 / 1024;
|
||||
let ram_available_mb = ram_total_mb.saturating_sub(ram_usage_mb);
|
||||
let memory_used_percent = if ram_total_mb > 0 {
|
||||
(ram_usage_mb as f64 / ram_total_mb as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let swap_total_mb = m.swap_total;
|
||||
let swap_used_mb = m.swap_used;
|
||||
let swap_available_mb = swap_total_mb.saturating_sub(swap_used_mb);
|
||||
let swap_used_percent = if swap_total_mb > 0 {
|
||||
(swap_used_mb as f64 / swap_total_mb as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
SystemStatsResponse {
|
||||
cpu_used_percent: m.cpu_scaled_ratio as f64 * 100.0,
|
||||
memory_used_percent,
|
||||
memory_available_mb: ram_available_mb,
|
||||
memory_total_mb: ram_total_mb,
|
||||
swap_used_percent,
|
||||
swap_available_mb,
|
||||
swap_total_mb,
|
||||
gpu_available: true,
|
||||
gpu_utilization: Some(m.gpu_scaled_ratio as f64 * 100.0),
|
||||
gpu_memory_used_pct: None,
|
||||
ane_available: m.ane_power > 0.0,
|
||||
ane_utilization: None,
|
||||
source: "macmon".to_string(),
|
||||
chip_name: Some(m.chip_name),
|
||||
cpu_power: Some(m.cpu_power as f64),
|
||||
gpu_power: Some(m.gpu_power as f64),
|
||||
ane_power: Some(m.ane_power as f64),
|
||||
sys_power: Some(m.sys_power as f64),
|
||||
ram_power: Some(m.ram_power as f64),
|
||||
cpu_temp: Some(m.cpu_temp as f64),
|
||||
gpu_temp: Some(m.gpu_temp as f64),
|
||||
ecpu_freq_mhz: Some(m.ecpu_freq_mhz),
|
||||
pcpu_freq_mhz: Some(m.pcpu_freq_mhz),
|
||||
gpu_freq_mhz: Some(m.gpu_freq_mhz),
|
||||
ecpu_cores: Some(m.ecpu_cores),
|
||||
pcpu_cores: Some(m.pcpu_cores),
|
||||
gpu_cores: Some(m.gpu_cores),
|
||||
}
|
||||
} else {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "macmon not available").into_response();
|
||||
};
|
||||
|
||||
// Run validation
|
||||
let result = super::validate::run_validation(&layers, &stats);
|
||||
|
||||
axum::Json(result).into_response()
|
||||
}
|
||||
|
||||
447
src-tauri/src/validate.rs
Normal file
447
src-tauri/src/validate.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ValidationStatus {
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
Unverified,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetricValidation {
|
||||
pub macmon_value: f64,
|
||||
pub reference_value: f64,
|
||||
pub diff_pct: f64,
|
||||
pub status: ValidationStatus,
|
||||
pub tolerance: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LayerValidation {
|
||||
pub name: String,
|
||||
pub requires_sudo: bool,
|
||||
pub metrics: std::collections::HashMap<String, MetricValidation>,
|
||||
pub summary: ValidationSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValidationSummary {
|
||||
pub total: u32,
|
||||
pub passed: u32,
|
||||
pub failed: u32,
|
||||
pub unverified: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValidationResult {
|
||||
pub timestamp: String,
|
||||
pub layers: std::collections::HashMap<String, Option<LayerValidation>>,
|
||||
pub overall_summary: ValidationSummary,
|
||||
}
|
||||
|
||||
impl ValidationStatus {
|
||||
fn from_diff(diff_pct: f64, tolerance: f64) -> Self {
|
||||
if diff_pct <= tolerance {
|
||||
ValidationStatus::Pass
|
||||
} else if diff_pct <= tolerance * 2.0 {
|
||||
ValidationStatus::Warn
|
||||
} else {
|
||||
ValidationStatus::Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_timestamp() -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let dt = chrono::DateTime::from_timestamp(now as i64, 0).unwrap();
|
||||
dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
}
|
||||
|
||||
fn write_log(message: &str) {
|
||||
let log_dir = std::env::var("MOMENTRY_DATA_DIR")
|
||||
.unwrap_or_else(|_| ".".to_string());
|
||||
let log_path = Path::new(&log_dir).join("logs").join("validation.log");
|
||||
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let timestamp = get_timestamp();
|
||||
let log_line = format!("{} {}\n", timestamp, message);
|
||||
|
||||
if let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
let _ = file.write_all(log_line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Layer 1: Activity Monitor Validation ====================
|
||||
|
||||
// CPU formula decision (2026-08-31):
|
||||
// - macmon uses `cpu_scaled_ratio` (frequency-weighted: E-core work counts less due to lower freq)
|
||||
// - `top` uses simple user+sys ratio (no frequency weighting)
|
||||
// - These are intentionally different formulas; macmon's approach is more accurate for "actual work done"
|
||||
// - Therefore, CPU validation is marked as Unverified (different formula, not comparable)
|
||||
pub fn validate_cpu_usage_scaled_ratio() -> Option<MetricValidation> {
|
||||
write_log("[INFO] Layer 1: CPU uses cpu_scaled_ratio (frequency-weighted) - not comparable to top");
|
||||
|
||||
let macmon_value = 0.0; // Will be filled by caller
|
||||
|
||||
write_log(&format!(
|
||||
"[INFO] Layer 1 CPU: macmon cpu_scaled_ratio={:.1}% (frequency-weighted formula)",
|
||||
macmon_value
|
||||
));
|
||||
|
||||
Some(MetricValidation {
|
||||
macmon_value,
|
||||
reference_value: 0.0,
|
||||
diff_pct: 0.0,
|
||||
status: ValidationStatus::Unverified,
|
||||
tolerance: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
// Memory formula decision (2026-08-31):
|
||||
// - macmon uses its own memory calculation (ram_usage from crate)
|
||||
// - Activity Monitor uses: wired + active + inactive + speculative - purgeable
|
||||
// - These formulas are intentionally different; macmon's approach is chosen as the baseline
|
||||
// - Therefore, Memory Used validation is marked as Unverified (different formula, not comparable)
|
||||
pub fn validate_memory_used_macmon_formula() -> Option<MetricValidation> {
|
||||
write_log("[INFO] Layer 1: Memory uses macmon formula - not comparable to Activity Monitor");
|
||||
|
||||
let macmon_value = 0.0; // Will be filled by caller
|
||||
|
||||
write_log(&format!(
|
||||
"[INFO] Layer 1 Memory Used: macmon={:.1}% (macmon formula)",
|
||||
macmon_value
|
||||
));
|
||||
|
||||
Some(MetricValidation {
|
||||
macmon_value,
|
||||
reference_value: 0.0,
|
||||
diff_pct: 0.0,
|
||||
status: ValidationStatus::Unverified,
|
||||
tolerance: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_swap_via_sysctl() -> Option<MetricValidation> {
|
||||
write_log("[INFO] Layer 1: Validating Swap via sysctl");
|
||||
|
||||
let output = Command::new("sysctl")
|
||||
.args(["-n", "vm.swapusage"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse: total = 9216.00M used = 8420.00M free = 796.00M (encrypted)
|
||||
let parts: Vec<&str> = stdout.split_whitespace().collect();
|
||||
|
||||
let total_mb: f64 = parts.get(2)
|
||||
.and_then(|v| v.trim_end_matches('M').parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
let used_mb: f64 = parts.get(5)
|
||||
.and_then(|v| v.trim_end_matches('M').parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let used_pct = if total_mb > 0.0 {
|
||||
(used_mb / total_mb) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let macmon_value = 0.0; // Will be filled by caller
|
||||
let tolerance = 1.0;
|
||||
let diff_pct = (macmon_value - used_pct).abs();
|
||||
|
||||
write_log(&format!(
|
||||
"[INFO] Layer 1 Swap: macmon={:.1}%, sysctl={:.1}%, diff={:.2}%",
|
||||
macmon_value, used_pct, diff_pct
|
||||
));
|
||||
|
||||
Some(MetricValidation {
|
||||
macmon_value,
|
||||
reference_value: used_pct,
|
||||
diff_pct,
|
||||
status: ValidationStatus::from_diff(diff_pct, tolerance),
|
||||
tolerance,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_memory_total_via_sysctl() -> Option<MetricValidation> {
|
||||
write_log("[INFO] Layer 1: Validating Memory Total via sysctl");
|
||||
|
||||
let output = Command::new("sysctl")
|
||||
.args(["-n", "hw.memsize"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let total_bytes: u64 = stdout.trim().parse().ok()?;
|
||||
let total_mb = total_bytes as f64 / (1024.0 * 1024.0);
|
||||
|
||||
let macmon_value = 0.0; // Will be filled by caller
|
||||
let tolerance = 0.0; // Exact match expected
|
||||
let diff_pct = (macmon_value - total_mb).abs();
|
||||
|
||||
write_log(&format!(
|
||||
"[INFO] Layer 1 Memory Total: macmon={:.0}MB, sysctl={:.0}MB, diff={:.0}MB",
|
||||
macmon_value, total_mb, diff_pct
|
||||
));
|
||||
|
||||
Some(MetricValidation {
|
||||
macmon_value,
|
||||
reference_value: total_mb,
|
||||
diff_pct,
|
||||
status: ValidationStatus::from_diff(diff_pct, 0.1), // Very small tolerance
|
||||
tolerance: 0.1,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Layer 2: System API Validation ====================
|
||||
|
||||
pub fn validate_cpu_temp_via_ioreg() -> Option<MetricValidation> {
|
||||
write_log("[INFO] Layer 2: Validating CPU Temp via ioreg");
|
||||
|
||||
let output = Command::new("ioreg")
|
||||
.args(["-r", "-n", "AppleSMC"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Look for temperature values
|
||||
let temp = stdout
|
||||
.lines()
|
||||
.find(|line| line.contains("temperature") || line.contains("TC0P") || line.contains("TC0H"))
|
||||
.and_then(|line| {
|
||||
// Extract numeric value
|
||||
let num_str: String = line
|
||||
.chars()
|
||||
.filter(|c| c.is_numeric() || *c == '.' || *c == '-')
|
||||
.collect();
|
||||
num_str.parse::<f64>().ok()
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let macmon_value = 0.0; // Will be filled by caller
|
||||
let tolerance = 1.0;
|
||||
let diff_pct = (macmon_value - temp).abs();
|
||||
|
||||
write_log(&format!(
|
||||
"[INFO] Layer 2 CPU Temp: macmon={:.1}°C, ioreg={:.1}°C, diff={:.1}°C",
|
||||
macmon_value, temp, diff_pct
|
||||
));
|
||||
|
||||
Some(MetricValidation {
|
||||
macmon_value,
|
||||
reference_value: temp,
|
||||
diff_pct,
|
||||
status: ValidationStatus::from_diff(diff_pct, tolerance),
|
||||
tolerance,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_thermal_state_via_pmset() -> Option<MetricValidation> {
|
||||
write_log("[INFO] Layer 2: Validating Thermal State via pmset");
|
||||
|
||||
let output = Command::new("pmset")
|
||||
.args(["-g", "therm"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse thermal state
|
||||
let state = if stdout.contains("CPU Power Load") {
|
||||
"Nominal"
|
||||
} else if stdout.contains("Warning") {
|
||||
"Warning"
|
||||
} else {
|
||||
"Nominal"
|
||||
};
|
||||
|
||||
// For thermal state, we just log the result (it's a string comparison)
|
||||
write_log(&format!("[INFO] Layer 2 Thermal State: {}", state));
|
||||
|
||||
None // Return None for string-based metrics
|
||||
}
|
||||
|
||||
// ==================== Main Validation Function ====================
|
||||
|
||||
pub fn run_validation(layers: &[u32], macmon_stats: &super::SystemStatsResponse) -> ValidationResult {
|
||||
write_log(&format!("[INFO] Validation started: layers={:?}", layers));
|
||||
|
||||
let mut result = ValidationResult {
|
||||
timestamp: get_timestamp(),
|
||||
layers: std::collections::HashMap::new(),
|
||||
overall_summary: ValidationSummary {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
unverified: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Layer 1: Activity Monitor
|
||||
if layers.contains(&1) {
|
||||
let mut layer1 = LayerValidation {
|
||||
name: "Activity Monitor".to_string(),
|
||||
requires_sudo: false,
|
||||
metrics: std::collections::HashMap::new(),
|
||||
summary: ValidationSummary {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
unverified: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// CPU - Unverified (different formula)
|
||||
if let Some(mut val) = validate_cpu_usage_scaled_ratio() {
|
||||
val.macmon_value = macmon_stats.cpu_used_percent;
|
||||
layer1.metrics.insert("cpu_usage".to_string(), val);
|
||||
}
|
||||
|
||||
// Memory Used - Unverified (different formula)
|
||||
if let Some(mut val) = validate_memory_used_macmon_formula() {
|
||||
val.macmon_value = macmon_stats.memory_used_percent;
|
||||
layer1.metrics.insert("memory_used_percent".to_string(), val);
|
||||
}
|
||||
|
||||
// Memory Total
|
||||
if let Some(mut val) = validate_memory_total_via_sysctl() {
|
||||
val.macmon_value = macmon_stats.memory_total_mb as f64;
|
||||
val.diff_pct = (val.macmon_value - val.reference_value).abs();
|
||||
val.status = ValidationStatus::from_diff(val.diff_pct, val.tolerance);
|
||||
layer1.metrics.insert("memory_total_mb".to_string(), val);
|
||||
}
|
||||
|
||||
// Swap
|
||||
if macmon_stats.swap_total_mb > 0 {
|
||||
if let Some(mut val) = validate_swap_via_sysctl() {
|
||||
val.macmon_value = macmon_stats.swap_used_percent;
|
||||
val.diff_pct = (val.macmon_value - val.reference_value).abs();
|
||||
val.status = ValidationStatus::from_diff(val.diff_pct, val.tolerance);
|
||||
layer1.metrics.insert("swap_used_percent".to_string(), val);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
for (_, metric) in &layer1.metrics {
|
||||
layer1.summary.total += 1;
|
||||
result.overall_summary.total += 1;
|
||||
match metric.status {
|
||||
ValidationStatus::Pass => {
|
||||
layer1.summary.passed += 1;
|
||||
result.overall_summary.passed += 1;
|
||||
}
|
||||
ValidationStatus::Warn | ValidationStatus::Fail => {
|
||||
layer1.summary.failed += 1;
|
||||
result.overall_summary.failed += 1;
|
||||
}
|
||||
ValidationStatus::Unverified => {
|
||||
layer1.summary.unverified += 1;
|
||||
result.overall_summary.unverified += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.layers.insert("layer1".to_string(), Some(layer1));
|
||||
}
|
||||
|
||||
// Layer 2: System API
|
||||
if layers.contains(&2) {
|
||||
let mut layer2 = LayerValidation {
|
||||
name: "System API".to_string(),
|
||||
requires_sudo: false,
|
||||
metrics: std::collections::HashMap::new(),
|
||||
summary: ValidationSummary {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
unverified: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// CPU Temp
|
||||
if let Some(temp) = macmon_stats.cpu_temp {
|
||||
if let Some(mut val) = validate_cpu_temp_via_ioreg() {
|
||||
val.macmon_value = temp;
|
||||
val.diff_pct = (val.macmon_value - val.reference_value).abs();
|
||||
val.status = ValidationStatus::from_diff(val.diff_pct, val.tolerance);
|
||||
layer2.metrics.insert("cpu_temp".to_string(), val);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
for (_, metric) in &layer2.metrics {
|
||||
layer2.summary.total += 1;
|
||||
result.overall_summary.total += 1;
|
||||
match metric.status {
|
||||
ValidationStatus::Pass => {
|
||||
layer2.summary.passed += 1;
|
||||
result.overall_summary.passed += 1;
|
||||
}
|
||||
ValidationStatus::Warn | ValidationStatus::Fail => {
|
||||
layer2.summary.failed += 1;
|
||||
result.overall_summary.failed += 1;
|
||||
}
|
||||
ValidationStatus::Unverified => {
|
||||
layer2.summary.unverified += 1;
|
||||
result.overall_summary.unverified += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.layers.insert("layer2".to_string(), Some(layer2));
|
||||
}
|
||||
|
||||
// Layer 3: Requires sudo (not implemented in this phase)
|
||||
if layers.contains(&3) {
|
||||
result.layers.insert("layer3".to_string(), None);
|
||||
write_log("[INFO] Layer 3 validation not implemented yet");
|
||||
}
|
||||
|
||||
// Layer 4: Unverified
|
||||
if layers.contains(&4) {
|
||||
let layer4 = LayerValidation {
|
||||
name: "Unverified".to_string(),
|
||||
requires_sudo: false,
|
||||
metrics: std::collections::HashMap::new(),
|
||||
summary: ValidationSummary {
|
||||
total: 3,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
unverified: 3,
|
||||
},
|
||||
};
|
||||
|
||||
result.layers.insert("layer4".to_string(), Some(layer4));
|
||||
result.overall_summary.total += 3;
|
||||
result.overall_summary.unverified += 3;
|
||||
}
|
||||
|
||||
write_log(&format!(
|
||||
"[INFO] Validation completed: {}/{} passed, {} failed, {} unverified",
|
||||
result.overall_summary.passed,
|
||||
result.overall_summary.total,
|
||||
result.overall_summary.failed,
|
||||
result.overall_summary.unverified
|
||||
));
|
||||
|
||||
result
|
||||
}
|
||||
Reference in New Issue
Block a user