add SPA config UI, wire media/rss adapters, event-driven layout push

- React SPA: dashboard, data sources CRUD, widgets CRUD, layout builder,
  presets. TanStack Router + Query, shadcn/ui, Vite proxy to :3000
- wire media + rss adapters into polling loop, remove xtb source type
- media adapter: read username/password from headers, proper subsonic auth
- event handler: subscribe to LayoutChanged, push screen update to clients
- fix clippy warnings across workspace (Default impls, collapsible ifs,
  redundant closures, is_none_or, unused imports)
This commit is contained in:
2026-06-19 00:12:42 +02:00
parent 21c08911df
commit 26ebfad3a2
175 changed files with 12338 additions and 801 deletions

View File

@@ -1,10 +1,10 @@
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use config_memory::MemoryConfigStore;
use tcp_server::TcpEventBus;
use http_api::{AppState, router};
use std::sync::Arc;
use tcp_server::TcpEventBus;
use tower::ServiceExt;
fn test_app() -> axum::Router {
let config = Arc::new(MemoryConfigStore::new());
@@ -38,13 +38,22 @@ async fn create_and_get_widget() {
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
}"#;
let resp = app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap();
let resp = app
.clone()
.oneshot(json_request("POST", "/api/widgets", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap();
let resp = app
.oneshot(json_request("GET", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "weather");
assert_eq!(json["display_hint"], "icon_value");
@@ -58,11 +67,22 @@ async fn list_widgets() {
let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w1))).await.unwrap();
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w2))).await.unwrap();
app.clone()
.oneshot(json_request("POST", "/api/widgets", Some(w1)))
.await
.unwrap();
app.clone()
.oneshot(json_request("POST", "/api/widgets", Some(w2)))
.await
.unwrap();
let resp = app.oneshot(json_request("GET", "/api/widgets", None)).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let resp = app
.oneshot(json_request("GET", "/api/widgets", None))
.await
.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(json.len(), 2);
}
@@ -71,13 +91,24 @@ async fn list_widgets() {
async fn delete_widget() {
let app = test_app();
let body = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap();
let body =
r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
app.clone()
.oneshot(json_request("POST", "/api/widgets", Some(body)))
.await
.unwrap();
let resp = app.clone().oneshot(json_request("DELETE", "/api/widgets/1", None)).await.unwrap();
let resp = app
.clone()
.oneshot(json_request("DELETE", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap();
let resp = app
.oneshot(json_request("GET", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
@@ -95,13 +126,22 @@ async fn create_and_get_data_source() {
"headers": []
}"#;
let resp = app.clone().oneshot(json_request("POST", "/api/data-sources", Some(body))).await.unwrap();
let resp = app
.clone()
.oneshot(json_request("POST", "/api/data-sources", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(json_request("GET", "/api/data-sources/10", None)).await.unwrap();
let resp = app
.oneshot(json_request("GET", "/api/data-sources/10", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "weather_api");
assert_eq!(json["poll_interval_secs"], 300);
@@ -124,13 +164,22 @@ async fn update_and_get_layout() {
}
}"#;
let resp = app.clone().oneshot(json_request("PUT", "/api/layout", Some(body))).await.unwrap();
let resp = app
.clone()
.oneshot(json_request("PUT", "/api/layout", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = app.oneshot(json_request("GET", "/api/layout", None)).await.unwrap();
let resp = app
.oneshot(json_request("GET", "/api/layout", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["root"]["type"], "container");
assert_eq!(json["root"]["direction"], "row");
@@ -141,9 +190,16 @@ async fn update_and_get_layout() {
async fn get_nonexistent_returns_404() {
let app = test_app();
let resp = app.clone().oneshot(json_request("GET", "/api/widgets/99", None)).await.unwrap();
let resp = app
.clone()
.oneshot(json_request("GET", "/api/widgets/99", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = app.oneshot(json_request("GET", "/api/data-sources/99", None)).await.unwrap();
let resp = app
.oneshot(json_request("GET", "/api/data-sources/99", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}