On essaie de faire démarer un serveur web
This commit is contained in:
1390
Cargo.lock
generated
1390
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["PMOMusic", "pmoupnp"]
|
||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils"]
|
||||
|
||||
190
Makefile
Normal file
190
Makefile
Normal file
@@ -0,0 +1,190 @@
|
||||
# Makefile pour projet Rust + Vue.js
|
||||
# Variables de configuration
|
||||
CARGO = cargo
|
||||
NPM = npm
|
||||
WEBAPP_DIR = pmoupnp/webapp
|
||||
DIST_DIR = $(WEBAPP_DIR)/dist
|
||||
RUST_TARGET = target/release
|
||||
DOC_DIR = target/doc
|
||||
BINARY_NAME = PMOMusic
|
||||
|
||||
# Couleurs pour l'affichage
|
||||
GREEN = \033[0;32m
|
||||
YELLOW = \033[1;33m
|
||||
RED = \033[0;31m
|
||||
NC = \033[0m # No Color
|
||||
|
||||
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch
|
||||
|
||||
# Cible par défaut
|
||||
all: build
|
||||
|
||||
## help: Affiche cette aide
|
||||
help:
|
||||
@echo "$(GREEN)Commandes disponibles :$(NC)"
|
||||
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':' | sed -e 's/^/ /'
|
||||
|
||||
## build: Compile tout (Rust + Vue.js)
|
||||
build: webapp release
|
||||
@echo "$(GREEN)✓ Build complet terminé$(NC)"
|
||||
|
||||
## release: Compile le binaire Rust en mode release
|
||||
release:
|
||||
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
||||
$(CARGO) build --release
|
||||
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## debug: Compile le binaire Rust en mode debug
|
||||
debug:
|
||||
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
||||
$(CARGO) build
|
||||
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
||||
|
||||
## test: Exécute tous les tests Rust
|
||||
test:
|
||||
@echo "$(YELLOW)→ Exécution des tests Rust...$(NC)"
|
||||
$(CARGO) test --all
|
||||
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
||||
|
||||
## test-doc: Teste les exemples dans la documentation
|
||||
test-doc:
|
||||
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
||||
$(CARGO) test --doc
|
||||
@echo "$(GREEN)✓ Tests de documentation terminés$(NC)"
|
||||
|
||||
## doc: Génère et ouvre la documentation Rust
|
||||
doc:
|
||||
@echo "$(YELLOW)→ Génération de la documentation...$(NC)"
|
||||
$(CARGO) doc --no-deps --document-private-items --open
|
||||
@echo "$(GREEN)✓ Documentation générée dans $(DOC_DIR)$(NC)"
|
||||
|
||||
## doc-build: Génère la documentation sans l'ouvrir
|
||||
doc-build:
|
||||
@echo "$(YELLOW)→ Génération de la documentation...$(NC)"
|
||||
$(CARGO) doc --no-deps --document-private-items
|
||||
@echo "$(GREEN)✓ Documentation générée dans $(DOC_DIR)$(NC)"
|
||||
|
||||
## webapp: Compile l'application Vue.js
|
||||
webapp: webapp-install
|
||||
@echo "$(YELLOW)→ Build Vue.js...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) run build
|
||||
@echo "$(GREEN)✓ Application Vue.js compilée dans $(DIST_DIR)$(NC)"
|
||||
|
||||
## webapp-install: Installe les dépendances npm
|
||||
webapp-install:
|
||||
@echo "$(YELLOW)→ Installation des dépendances npm...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) install
|
||||
@echo "$(GREEN)✓ Dépendances npm installées$(NC)"
|
||||
|
||||
## webapp-dev: Lance le serveur de développement Vue.js
|
||||
webapp-dev:
|
||||
@echo "$(YELLOW)→ Démarrage du serveur de dev Vue.js...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) run dev
|
||||
|
||||
## clean: Nettoie les fichiers de build
|
||||
clean:
|
||||
@echo "$(YELLOW)→ Nettoyage...$(NC)"
|
||||
$(CARGO) clean
|
||||
rm -rf $(DIST_DIR)
|
||||
rm -rf $(WEBAPP_DIR)/node_modules
|
||||
@echo "$(GREEN)✓ Nettoyage terminé$(NC)"
|
||||
|
||||
## clean-rust: Nettoie uniquement les builds Rust
|
||||
clean-rust:
|
||||
@echo "$(YELLOW)→ Nettoyage Rust...$(NC)"
|
||||
$(CARGO) clean
|
||||
@echo "$(GREEN)✓ Nettoyage Rust terminé$(NC)"
|
||||
|
||||
## clean-webapp: Nettoie uniquement le build Vue.js
|
||||
clean-webapp:
|
||||
@echo "$(YELLOW)→ Nettoyage Vue.js...$(NC)"
|
||||
rm -rf $(DIST_DIR)
|
||||
@echo "$(GREEN)✓ Nettoyage Vue.js terminé$(NC)"
|
||||
|
||||
## install: Installe le binaire dans ~/.cargo/bin
|
||||
install: release
|
||||
@echo "$(YELLOW)→ Installation du binaire...$(NC)"
|
||||
$(CARGO) install --path .
|
||||
@echo "$(GREEN)✓ $(BINARY_NAME) installé$(NC)"
|
||||
|
||||
## dev: Lance le serveur en mode debug (recompile à chaque changement)
|
||||
dev:
|
||||
@echo "$(YELLOW)→ Démarrage en mode développement...$(NC)"
|
||||
$(CARGO) watch -x run
|
||||
|
||||
## check: Vérifie que le code compile sans générer de binaire
|
||||
check:
|
||||
@echo "$(YELLOW)→ Vérification du code...$(NC)"
|
||||
$(CARGO) check --all
|
||||
@echo "$(GREEN)✓ Code valide$(NC)"
|
||||
|
||||
## fmt: Formate le code Rust
|
||||
fmt:
|
||||
@echo "$(YELLOW)→ Formatage du code...$(NC)"
|
||||
$(CARGO) fmt --all
|
||||
@echo "$(GREEN)✓ Code formaté$(NC)"
|
||||
|
||||
## fmt-check: Vérifie le formatage sans modifier
|
||||
fmt-check:
|
||||
@echo "$(YELLOW)→ Vérification du formatage...$(NC)"
|
||||
$(CARGO) fmt --all -- --check
|
||||
|
||||
## clippy: Exécute clippy (linter Rust)
|
||||
clippy:
|
||||
@echo "$(YELLOW)→ Analyse avec clippy...$(NC)"
|
||||
$(CARGO) clippy --all-targets --all-features -- -D warnings
|
||||
@echo "$(GREEN)✓ Analyse clippy terminée$(NC)"
|
||||
|
||||
## watch: Recompile automatiquement à chaque changement
|
||||
watch:
|
||||
@echo "$(YELLOW)→ Mode watch activé...$(NC)"
|
||||
$(CARGO) watch -x check
|
||||
|
||||
## ci: Exécute toutes les vérifications CI
|
||||
ci: fmt-check clippy test doc-build webapp
|
||||
@echo "$(GREEN)✓ Toutes les vérifications CI passées$(NC)"
|
||||
|
||||
## run: Lance le binaire en mode debug
|
||||
run: debug
|
||||
@echo "$(YELLOW)→ Lancement de l'application...$(NC)"
|
||||
./target/debug/$(BINARY_NAME)
|
||||
|
||||
## run-release: Lance le binaire en mode release
|
||||
run-release: release
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release)...$(NC)"
|
||||
./$(RUST_TARGET)/$(BINARY_NAME)
|
||||
|
||||
## size: Affiche la taille du binaire
|
||||
size:
|
||||
@echo "$(YELLOW)Taille des binaires :$(NC)"
|
||||
@if [ -f "target/debug/$(BINARY_NAME)" ]; then \
|
||||
echo " Debug: $$(du -h target/debug/$(BINARY_NAME) | cut -f1)"; \
|
||||
fi
|
||||
@if [ -f "$(RUST_TARGET)/$(BINARY_NAME)" ]; then \
|
||||
echo " Release: $$(du -h $(RUST_TARGET)/$(BINARY_NAME) | cut -f1)"; \
|
||||
fi
|
||||
|
||||
## deps: Liste les dépendances obsolètes
|
||||
deps:
|
||||
@echo "$(YELLOW)→ Vérification des dépendances...$(NC)"
|
||||
$(CARGO) outdated
|
||||
|
||||
## update: Met à jour les dépendances
|
||||
update:
|
||||
@echo "$(YELLOW)→ Mise à jour des dépendances Rust...$(NC)"
|
||||
$(CARGO) update
|
||||
@echo "$(YELLOW)→ Mise à jour des dépendances npm...$(NC)"
|
||||
cd $(WEBAPP_DIR) && $(NPM) update
|
||||
@echo "$(GREEN)✓ Dépendances mises à jour$(NC)"
|
||||
|
||||
## bench: Exécute les benchmarks
|
||||
bench:
|
||||
@echo "$(YELLOW)→ Exécution des benchmarks...$(NC)"
|
||||
$(CARGO) bench
|
||||
|
||||
## coverage: Génère un rapport de couverture de code
|
||||
coverage:
|
||||
@echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)"
|
||||
$(CARGO) tarpaulin --out Html --output-dir target/coverage
|
||||
@echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)"
|
||||
|
||||
@@ -4,3 +4,12 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
|
||||
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
|
||||
@@ -1,3 +1,52 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
use pmoupnp::server::{
|
||||
ServerBuilder, Webapp,
|
||||
logs::{LogState, SseLayer, log_dump, log_sse},
|
||||
}; // ton module pmoupnp::server
|
||||
use tracing_subscriber::Registry;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Charger la config
|
||||
|
||||
let mut server = ServerBuilder::new_configured().build();
|
||||
|
||||
// Ajouter des routes
|
||||
server
|
||||
.add_route("/hello", || async {
|
||||
serde_json::json!({"message": "Hello World"})
|
||||
})
|
||||
.await;
|
||||
|
||||
server
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
.await;
|
||||
|
||||
server.add_spa::<Webapp>("/app").await;
|
||||
|
||||
// Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console
|
||||
let log_state = LogState::new(1000);
|
||||
let subscriber = Registry::default()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_ansi(true), // Couleurs dans le terminal
|
||||
)
|
||||
.with(SseLayer::new(log_state.clone()));
|
||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
||||
|
||||
server
|
||||
.add_handler_with_state("/log-sse", log_sse, log_state.clone())
|
||||
.await;
|
||||
server
|
||||
.add_handler_with_state("/log-dump", log_dump, log_state.clone())
|
||||
.await;
|
||||
|
||||
server.add_redirect("/", "/app").await;
|
||||
|
||||
server.start().await;
|
||||
server.wait().await;
|
||||
}
|
||||
|
||||
1
pmoconfig/.gitignore
vendored
Normal file
1
pmoconfig/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
17
pmoconfig/Cargo.toml
Normal file
17
pmoconfig/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
[package]
|
||||
name = "pmoconfig"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
319
pmoconfig/src/lib.rs
Normal file
319
pmoconfig/src/lib.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use dirs::home_dir;
|
||||
use lazy_static::lazy_static;
|
||||
use pmoutils::guess_local_ip;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Configuration par défaut intégrée
|
||||
const DEFAULT_CONFIG: &str = include_str!("pmomusic.yaml");
|
||||
|
||||
lazy_static! {
|
||||
static ref CONFIG: Arc<Config> =
|
||||
Arc::new(Config::load_config("").expect("Failed to load PMOMusic configuration")) ;
|
||||
}
|
||||
|
||||
const ENV_CONFIG_FILE: &str = "PMOMUSIC_CONFIG";
|
||||
const ENV_PREFIX: &str = "PMOMUSIC_CONFIG__";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
path: String,
|
||||
data: Mutex<Value>,
|
||||
}
|
||||
|
||||
// Implémentation manuelle de Clone
|
||||
impl Clone for Config {
|
||||
fn clone(&self) -> Self {
|
||||
let data = self.data.lock().unwrap().clone();
|
||||
Self {
|
||||
path: self.path.clone(),
|
||||
data: Mutex::new(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load_config(filename: &str) -> Result<Self> {
|
||||
let mut path = filename.to_string();
|
||||
let mut data: Option<Vec<u8>> = None;
|
||||
|
||||
// Essayer de charger depuis différents emplacements
|
||||
if !filename.is_empty() {
|
||||
info!(config_file=%path, "Trying to load config");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
if let Ok(env_path) = env::var(ENV_CONFIG_FILE) {
|
||||
info!(env_var=ENV_CONFIG_FILE, path=%env_path, "Trying to load config from env");
|
||||
path = env_path.clone();
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file from env var");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
path = current_dir
|
||||
.join(".pmomusic.yml")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
info!(config_file=%path, "Trying to load config file from current directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in current dir");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
path = Self::get_home_yml_path();
|
||||
info!(config_file=%path, "Trying to load config file from home directory");
|
||||
data = fs::read(&path).ok();
|
||||
if data.is_none() {
|
||||
warn!(config_file=%path, "Cannot read config file in home directory");
|
||||
path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let yaml_data = if let Some(d) = data {
|
||||
d
|
||||
} else {
|
||||
info!("Using default embedded config");
|
||||
DEFAULT_CONFIG.as_bytes().to_vec()
|
||||
};
|
||||
|
||||
let mut config_value: Value = serde_yaml::from_slice(&yaml_data)?;
|
||||
config_value = Self::lower_keys_value(config_value);
|
||||
Self::apply_env_overrides(&mut config_value);
|
||||
|
||||
if path.is_empty() || !Self::is_writable(&path) {
|
||||
let candidates = [
|
||||
filename.to_string(),
|
||||
env::var(ENV_CONFIG_FILE).unwrap_or_default(),
|
||||
".pmomusic.yml".to_string(),
|
||||
Self::get_home_yml_path(),
|
||||
];
|
||||
for candidate in candidates.iter().filter(|c| !c.is_empty()) {
|
||||
if Self::is_writable(candidate) {
|
||||
path = candidate.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.is_empty() {
|
||||
return Err(anyhow!("Cannot find a place to store config file"));
|
||||
}
|
||||
|
||||
info!(config_file=%path, "Config file will be stored here");
|
||||
|
||||
let config = Config {
|
||||
path,
|
||||
data: Mutex::new(config_value),
|
||||
};
|
||||
config.save()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let data = self.data.lock().unwrap();
|
||||
let yaml = serde_yaml::to_string(&*data)?;
|
||||
fs::write(&self.path, yaml)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_value(&self, path: &[&str], value: Value) -> Result<()> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
Self::set_value_internal(&mut data, path, value.clone())?;
|
||||
drop(data);
|
||||
self.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_value_internal(data: &mut Value, path: &[&str], value: Value) -> Result<()> {
|
||||
if path.is_empty() {
|
||||
*data = value;
|
||||
return Ok(());
|
||||
}
|
||||
if let Value::Mapping(map) = data {
|
||||
let key = path[0].to_lowercase();
|
||||
let key_value = Value::String(key.clone());
|
||||
if path.len() == 1 {
|
||||
map.insert(key_value, value);
|
||||
} else {
|
||||
let entry = map
|
||||
.entry(key_value)
|
||||
.or_insert(Value::Mapping(Mapping::new()));
|
||||
Self::set_value_internal(entry, &path[1..], value)?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Current node is not a map"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_value(&self, path: &[&str]) -> Result<Value> {
|
||||
let data = self.data.lock().unwrap();
|
||||
Self::get_value_internal(&data, path)
|
||||
}
|
||||
|
||||
fn get_value_internal(data: &Value, path: &[&str]) -> Result<Value> {
|
||||
let mut current = data;
|
||||
for (i, key) in path.iter().enumerate() {
|
||||
if let Value::Mapping(map) = current {
|
||||
let key = key.to_lowercase();
|
||||
if let Some(next) = map.get(&Value::String(key)) {
|
||||
current = next;
|
||||
} else {
|
||||
return Err(anyhow!("Path {} does not exist", path[..=i].join(".")));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("Path {} is not a Config", path[..i].join(".")));
|
||||
}
|
||||
}
|
||||
Ok(current.clone())
|
||||
}
|
||||
|
||||
fn get_home_yml_path() -> String {
|
||||
home_dir()
|
||||
.map(|p| p.join(".pmomusic.yml"))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn apply_env_overrides(config: &mut Value) {
|
||||
for (key, value) in env::vars() {
|
||||
if key.starts_with(ENV_PREFIX) {
|
||||
let key_path = key
|
||||
.trim_start_matches(ENV_PREFIX)
|
||||
.split("__")
|
||||
.collect::<Vec<_>>();
|
||||
let yaml_value = Self::convert_env_value(&value);
|
||||
let _ = Self::set_value_internal(config, &key_path, yaml_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_env_value(value: &str) -> Value {
|
||||
if let Ok(parsed) = serde_yaml::from_str::<Value>(value) {
|
||||
return parsed;
|
||||
}
|
||||
Value::String(value.to_string())
|
||||
}
|
||||
|
||||
fn lower_keys_value(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
let mut new_map = Mapping::new();
|
||||
for (k, v) in map {
|
||||
if let Value::String(s) = k {
|
||||
let new_key = Value::String(s.to_lowercase());
|
||||
let new_val = Self::lower_keys_value(v);
|
||||
new_map.insert(new_key, new_val);
|
||||
} else {
|
||||
new_map.insert(k, Self::lower_keys_value(v));
|
||||
}
|
||||
}
|
||||
Value::Mapping(new_map)
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
Value::Sequence(seq.into_iter().map(Self::lower_keys_value).collect())
|
||||
}
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_writable(path: &str) -> bool {
|
||||
let path = Path::new(path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::metadata(parent)
|
||||
.map(|m| !m.permissions().readonly())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_base_url(&self) -> String {
|
||||
match self.get_value(&["host", "base_url"]) {
|
||||
Ok(Value::String(s)) if !s.is_empty() => s,
|
||||
Ok(_) => {
|
||||
tracing::warn!("Base URL is not a string or empty, using default localhost");
|
||||
guess_local_ip()
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to get base URL: {}, using default localhost", err);
|
||||
guess_local_ip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_http_port(&self) -> u16 {
|
||||
match self.get_value(&["host", "http_port"]) {
|
||||
Ok(Value::Number(n)) if n.is_i64() => n.as_i64().unwrap() as u16,
|
||||
Ok(Value::String(s)) => match s.parse::<u16>() {
|
||||
Ok(port) => port,
|
||||
Err(_) => {
|
||||
tracing::warn!("Invalid HTTP port '{}', using default 8080", s);
|
||||
8080
|
||||
}
|
||||
},
|
||||
Ok(_) => {
|
||||
tracing::warn!("HTTP port not a number or string, using default 8080");
|
||||
8080
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to get HTTP port: {}, using default 8080", err);
|
||||
8080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_device_udn(&self, devtype: &str, name: &str) -> Result<String> {
|
||||
let path = &["devices", devtype, name, "udn"];
|
||||
match self.get_value(path) {
|
||||
Ok(Value::String(udn)) => Ok(udn),
|
||||
_ => {
|
||||
let new_udn = Uuid::new_v4().to_string();
|
||||
self.set_value(path, Value::String(new_udn.clone()))?;
|
||||
Ok(new_udn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_dir(&self) -> Result<String> {
|
||||
match self.get_value(&["host", "cover_cache", "directory"])? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Ok("./.pmomusic_covers".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cover_cache_size(&self) -> Result<usize> {
|
||||
match self.get_value(&["host", "cover_cache", "size"])? {
|
||||
Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
Value::Number(n) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
|
||||
_ => Ok(2000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne l'instance globale
|
||||
pub fn get_config() -> Arc<Config> {
|
||||
CONFIG.clone()
|
||||
}
|
||||
11
pmoconfig/src/pmomusic.yaml
Normal file
11
pmoconfig/src/pmomusic.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
host:
|
||||
http_port: "8080"
|
||||
cover_cache:
|
||||
directory: "./.pmomusic_covers"
|
||||
size: 2000
|
||||
devices:
|
||||
mediarenderer:
|
||||
mpd_renderer:
|
||||
mediaserver:
|
||||
qobuz:
|
||||
udn: "uuid:28963b75-4c5f-4da7-b10e-ffafd"
|
||||
@@ -4,10 +4,30 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
|
||||
url = "2.5.7"
|
||||
uuid = "1.18.1"
|
||||
hex = "0.4.3"
|
||||
base64 = "0.22.1"
|
||||
thiserror = "2.0.16"
|
||||
xmltree = "0.11.0"
|
||||
xmltree = "0.11.0"
|
||||
get_if_addrs = "0.5.3"
|
||||
axum = "0.8.4"
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
tokio-stream = "0.1"
|
||||
futures-util = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
log = "0.4.28"
|
||||
once_cell = "1.20"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3.6"
|
||||
axum-server = "0.7.2"
|
||||
axum-embed = "0.1.0"
|
||||
rust-embed = "8.7.2"
|
||||
anyhow = "1.0"
|
||||
|
||||
@@ -3,6 +3,7 @@ mod object_trait;
|
||||
pub mod variable_types;
|
||||
pub mod state_variables;
|
||||
pub mod value_ranges;
|
||||
pub mod server;
|
||||
|
||||
pub use crate::object_trait::UpnpObject;
|
||||
|
||||
|
||||
158
pmoupnp/src/server/logs/mod.rs
Normal file
158
pmoupnp/src/server/logs/mod.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
// logs.rs
|
||||
mod sselayer;
|
||||
|
||||
pub use sselayer::SseLayer;
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{Arc, RwLock},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::{sse::{Event, KeepAlive, Sse}, IntoResponse},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Représente une entrée de log
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: SystemTime,
|
||||
pub level: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Buffer circulaire partagé
|
||||
#[derive(Clone)]
|
||||
pub struct LogState {
|
||||
buffer: Arc<RwLock<VecDeque<LogEntry>>>,
|
||||
tx: broadcast::Sender<LogEntry>,
|
||||
}
|
||||
|
||||
impl LogState {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(capacity))),
|
||||
tx: broadcast::channel(1000).0,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&self, entry: LogEntry) {
|
||||
let mut buf = self.buffer.write().unwrap();
|
||||
if buf.len() == buf.capacity() {
|
||||
buf.pop_front();
|
||||
}
|
||||
buf.push_back(entry.clone());
|
||||
let _ = self.tx.send(entry);
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn dump(&self) -> Vec<LogEntry> {
|
||||
self.buffer.read().unwrap().iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Query params pour /log-sse
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogQuery {
|
||||
#[serde(default)]
|
||||
pub error: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub warn: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub info: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub debug: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub trace: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// Handler SSE
|
||||
// Dans logs.rs
|
||||
pub async fn log_sse(
|
||||
State(state): State<LogState>,
|
||||
Query(params): Query<LogQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let mut rx = state.subscribe();
|
||||
|
||||
// Récupérer l'historique du buffer
|
||||
let history = state.dump();
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
// 1. Envoyer d'abord tous les logs historiques
|
||||
for entry in history {
|
||||
if !filter_entry(&entry, ¶ms) {
|
||||
continue;
|
||||
}
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
yield Ok::<_, axum::Error>(Event::default().data(json));
|
||||
}
|
||||
|
||||
// 2. Puis streamer les nouveaux logs en temps réel
|
||||
while let Ok(entry) = rx.recv().await {
|
||||
if !filter_entry(&entry, ¶ms) {
|
||||
continue;
|
||||
}
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
yield Ok::<_, axum::Error>(Event::default().data(json));
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// Handler REST (dump JSON du buffer)
|
||||
pub async fn log_dump(State(state): State<LogState>) -> impl IntoResponse {
|
||||
Json(state.dump())
|
||||
}
|
||||
|
||||
/// Fonction de filtrage
|
||||
fn filter_entry(entry: &LogEntry, q: &LogQuery) -> bool {
|
||||
// Filtrage par niveau
|
||||
let lvl = entry.level.to_lowercase();
|
||||
let mut allowed = false;
|
||||
|
||||
if let Some(true) = q.error {
|
||||
allowed |= lvl == "error";
|
||||
}
|
||||
if let Some(true) = q.warn {
|
||||
allowed |= lvl == "warn";
|
||||
}
|
||||
if let Some(true) = q.info {
|
||||
allowed |= lvl == "info";
|
||||
}
|
||||
if let Some(true) = q.debug {
|
||||
allowed |= lvl == "debug";
|
||||
}
|
||||
if let Some(true) = q.trace {
|
||||
allowed |= lvl == "trace";
|
||||
}
|
||||
|
||||
// si aucun flag → tout est autorisé
|
||||
if !(q.error.unwrap_or(false)
|
||||
|| q.warn.unwrap_or(false)
|
||||
|| q.info.unwrap_or(false)
|
||||
|| q.debug.unwrap_or(false)
|
||||
|| q.trace.unwrap_or(false))
|
||||
{
|
||||
allowed = true;
|
||||
}
|
||||
|
||||
// Filtrage par mot-clé
|
||||
if let Some(search) = &q.search {
|
||||
allowed &= entry.message.contains(search) || entry.target.contains(search);
|
||||
}
|
||||
|
||||
allowed
|
||||
}
|
||||
62
pmoupnp/src/server/logs/sselayer.rs
Normal file
62
pmoupnp/src/server/logs/sselayer.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use tracing::{Event, Subscriber};
|
||||
use tracing_subscriber::{layer::Context, Layer};
|
||||
use tracing::field::{Visit, Field};
|
||||
|
||||
use super::{LogEntry, LogState};
|
||||
use std::time::SystemTime;
|
||||
|
||||
struct LogVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl LogVisitor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
message: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for LogVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
// capture le champ "message" ou concatène les autres
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{:?}", value);
|
||||
} else {
|
||||
if !self.message.is_empty() {
|
||||
self.message.push(' ');
|
||||
}
|
||||
self.message.push_str(&format!("{}={:?}", field.name(), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer de tracing qui pousse les events dans le buffer
|
||||
pub struct SseLayer {
|
||||
state: LogState,
|
||||
}
|
||||
|
||||
impl SseLayer {
|
||||
pub fn new(state: LogState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for SseLayer
|
||||
where
|
||||
S: Subscriber,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut visitor = LogVisitor::new();
|
||||
event.record(&mut visitor);
|
||||
|
||||
let entry = LogEntry {
|
||||
timestamp: SystemTime::now(),
|
||||
level: event.metadata().level().to_string(),
|
||||
target: event.metadata().target().to_string(),
|
||||
message: visitor.message,
|
||||
};
|
||||
|
||||
self.state.push(entry);
|
||||
}
|
||||
}
|
||||
520
pmoupnp/src/server/mod.rs
Normal file
520
pmoupnp/src/server/mod.rs
Normal file
@@ -0,0 +1,520 @@
|
||||
//! # Module Server - API de haut niveau pour Axum
|
||||
//!
|
||||
//! Ce module fournit une abstraction simple et ergonomique pour créer des serveurs HTTP
|
||||
//! avec Axum, en cachant la complexité de la configuration et du routage.
|
||||
//!
|
||||
//! ## Fonctionnalités
|
||||
//!
|
||||
//! - 🚀 **Routes JSON simples** : Ajoutez des endpoints API avec `add_route()`
|
||||
//! - 📁 **Fichiers statiques** : Servez des assets avec `add_dir()`
|
||||
//! - ⚛️ **Applications SPA** : Support pour Vue.js/React avec `add_spa()`
|
||||
//! - 🔀 **Redirections** : Redirigez des routes avec `add_redirect()`
|
||||
//! - 🎯 **Handlers personnalisés** : Support SSE, WebSocket, etc. avec `add_handler_with_state()`
|
||||
//! - ⚡ **Gestion gracieuse** : Arrêt propre sur Ctrl+C
|
||||
//!
|
||||
//! ## Exemple d'utilisation
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoupnp::server::{ServerBuilder, Server};
|
||||
//! use rust_embed::RustEmbed;
|
||||
//!
|
||||
//! #[derive(RustEmbed, Clone)]
|
||||
//! #[folder = "static/"]
|
||||
//! struct Assets;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let mut server = ServerBuilder::new("MyAPI", "http://localhost:3000", 3000)
|
||||
//! .build();
|
||||
//!
|
||||
//! // Route JSON simple
|
||||
//! server.add_route("/api/hello", || async {
|
||||
//! serde_json::json!({"message": "Hello World"})
|
||||
//! }).await;
|
||||
//!
|
||||
//! // Redirection
|
||||
//! server.add_redirect("/", "/app").await;
|
||||
//!
|
||||
//! // Application Vue.js
|
||||
//! server.add_spa::<Assets>("/app").await;
|
||||
//!
|
||||
//! server.start().await;
|
||||
//! server.wait().await;
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod logs;
|
||||
|
||||
use axum::handler::Handler;
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use axum_embed::ServeEmbed;
|
||||
use pmoconfig::get_config;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Serialize;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tokio::{signal, sync::RwLock, task::JoinHandle};
|
||||
use tracing::{info,warn,debug,error};
|
||||
|
||||
/// Info serveur sérialisable
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub name: String,
|
||||
pub base_url: String,
|
||||
pub http_port: u16,
|
||||
}
|
||||
|
||||
/// Serveur principal
|
||||
pub struct Server {
|
||||
name: String,
|
||||
base_url: String,
|
||||
http_port: u16,
|
||||
router: Arc<RwLock<Router>>,
|
||||
join_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(RustEmbed, Clone)]
|
||||
#[folder = "webapp/dist"]
|
||||
pub struct Webapp;
|
||||
|
||||
impl Server {
|
||||
/// Crée une nouvelle instance de serveur
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom du serveur (pour les logs)
|
||||
/// * `base_url` - URL de base (ex: "http://localhost:3000")
|
||||
/// * `http_port` - Port HTTP à écouter
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// let server = Server::new("MyAPI", "http://localhost:3000", 3000);
|
||||
/// ```
|
||||
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url: base_url.into(),
|
||||
http_port,
|
||||
router: Arc::new(RwLock::new(Router::new())),
|
||||
join_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_configured() -> Self {
|
||||
let config = get_config();
|
||||
let url = config.get_base_url();
|
||||
let port = config.get_http_port();
|
||||
|
||||
return Self::new("PMO-Music-Server", url, port);
|
||||
}
|
||||
|
||||
/// Ajoute une route dynamique
|
||||
/// Ajoute une route JSON dynamique
|
||||
///
|
||||
/// Crée un endpoint qui retourne du JSON. La closure fournie sera appelée
|
||||
/// à chaque requête GET sur le chemin spécifié.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route (ex: "/api/hello")
|
||||
/// * `f` - Closure async retournant une valeur sérialisable
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_route("/api/status", || async {
|
||||
/// serde_json::json!({
|
||||
/// "status": "online",
|
||||
/// "version": "1.0.0"
|
||||
/// })
|
||||
/// }).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_route<F, Fut, T>(&mut self, path: &str, f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = T> + Send + 'static,
|
||||
T: Serialize + Send + 'static,
|
||||
{
|
||||
let f = Arc::new(f);
|
||||
|
||||
let handler = {
|
||||
let f = f.clone();
|
||||
move || {
|
||||
let f = f.clone();
|
||||
async move { Json(f().await) }
|
||||
}
|
||||
};
|
||||
|
||||
let route = Router::new().route("/", get(handler));
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute un répertoire de fichiers statiques
|
||||
///
|
||||
/// Sert des fichiers embarqués via `RustEmbed`. Les fichiers sont compilés
|
||||
/// dans le binaire à la compilation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin où monter les fichiers statiques
|
||||
///
|
||||
/// # Type Parameter
|
||||
///
|
||||
/// * `E` - Type RustEmbed définissant le répertoire à servir
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # use rust_embed::RustEmbed;
|
||||
/// #[derive(RustEmbed, Clone)]
|
||||
/// #[folder = "static/"]
|
||||
/// struct Assets;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_dir::<Assets>("/assets").await;
|
||||
/// // Les fichiers de static/ sont accessibles via /assets/*
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_dir<E>(&mut self, path: &str)
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::new();
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
if path == "/" {
|
||||
*r = std::mem::take(&mut *r).fallback_service(serve);
|
||||
} else {
|
||||
let route = Router::new().fallback_service(serve);
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute une Single Page Application (SPA)
|
||||
///
|
||||
/// Sert une application JavaScript moderne (Vue.js, React, etc.) avec support
|
||||
/// du routage côté client. Tous les chemins non trouvés renvoient `index.html`
|
||||
/// pour permettre au routeur JavaScript de gérer la navigation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin où monter l'application (souvent "/" ou "/app")
|
||||
///
|
||||
/// # Type Parameter
|
||||
///
|
||||
/// * `E` - Type RustEmbed contenant les fichiers de la SPA
|
||||
///
|
||||
/// # Exemple avec Vue.js
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # use rust_embed::RustEmbed;
|
||||
/// #[derive(RustEmbed, Clone)]
|
||||
/// #[folder = "webapp/dist"] // Build output de Vue.js
|
||||
/// struct WebApp;
|
||||
///
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.add_spa::<WebApp>("/").await;
|
||||
/// // L'app Vue.js gère toutes les routes comme /about, /users, etc.
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Pour Vue.js/Vite, configure le `base` dans `vite.config.js` si tu montes
|
||||
/// sur un sous-chemin :
|
||||
/// ```javascript
|
||||
/// export default {
|
||||
/// base: '/app/'
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn add_spa<E>(&mut self, path: &str)
|
||||
where
|
||||
E: RustEmbed + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let serve = ServeEmbed::<E>::with_parameters(
|
||||
Some("index.html".to_string()),
|
||||
axum_embed::FallbackBehavior::Ok,
|
||||
Some("index.html".to_string()),
|
||||
);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
if path == "/" {
|
||||
*r = std::mem::take(&mut *r).fallback_service(serve);
|
||||
} else {
|
||||
let route = Router::new().fallback_service(serve);
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajoute un handler Axum personnalisé
|
||||
///
|
||||
/// Pour des cas d'usage avancés nécessitant un contrôle complet sur le handler.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route
|
||||
/// * `handler` - Handler Axum
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # use axum::response::Html;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// async fn custom_handler() -> Html<&'static str> {
|
||||
/// Html("<h1>Custom Response</h1>")
|
||||
/// }
|
||||
///
|
||||
/// server.add_handler("/custom", custom_handler).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_handler<H, T>(&mut self, path: &str, handler: H)
|
||||
where
|
||||
H: Handler<T, ()>,
|
||||
T: 'static,
|
||||
{
|
||||
let route = Router::new().route("/", get(handler));
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute un handler avec state (pour SSE, extracteurs, etc.)
|
||||
///
|
||||
/// Permet d'utiliser des extracteurs Axum comme `State`, `Query`, etc.
|
||||
/// Idéal pour Server-Sent Events (SSE), WebSockets ou tout handler nécessitant un état partagé.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route
|
||||
/// * `handler` - Handler Axum avec extracteurs
|
||||
/// * `state` - État partagé (doit être Clone + Send + Sync)
|
||||
///
|
||||
/// # Exemple avec SSE
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # use axum::extract::State;
|
||||
/// # use axum::response::sse::{Event, Sse, KeepAlive};
|
||||
/// # use tokio::sync::broadcast;
|
||||
/// # #[derive(Clone)]
|
||||
/// # struct LogState { tx: broadcast::Sender<String> }
|
||||
/// # impl LogState { fn subscribe(&self) -> broadcast::Receiver<String> { self.tx.subscribe() } }
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// async fn log_sse(State(state): State<LogState>) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
/// let mut rx = state.subscribe();
|
||||
/// let stream = async_stream::stream! {
|
||||
/// while let Ok(msg) = rx.recv().await {
|
||||
/// yield Ok(Event::default().data(msg));
|
||||
/// }
|
||||
/// };
|
||||
/// Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
/// }
|
||||
///
|
||||
/// let log_state = LogState { tx: broadcast::channel(100).0 };
|
||||
/// server.add_handler_with_state("/logs", log_sse, log_state).await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S>,
|
||||
T: 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let route = Router::new()
|
||||
.route("/", get(handler))
|
||||
.with_state(state);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute un handler POST avec state
|
||||
///
|
||||
/// Similaire à `add_handler_with_state` mais pour les requêtes POST.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Chemin de la route
|
||||
/// * `handler` - Handler Axum pour POST
|
||||
/// * `state` - État partagé
|
||||
pub async fn add_post_handler_with_state<H, T, S>(&mut self, path: &str, handler: H, state: S)
|
||||
where
|
||||
H: Handler<T, S>,
|
||||
T: 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let route = Router::new()
|
||||
.route("/", axum::routing::post(handler))
|
||||
.with_state(state);
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
*r = std::mem::take(&mut *r).nest(path, route);
|
||||
}
|
||||
|
||||
/// Ajoute une redirection HTTP
|
||||
///
|
||||
/// Redirige automatiquement les requêtes d'un chemin vers un autre avec un code 308 (permanent).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `from` - Chemin source (peut être "/" pour la racine)
|
||||
/// * `to` - Chemin de destination
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// // Rediriger la racine vers /app
|
||||
/// server.add_redirect("/", "/app").await;
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn add_redirect(&mut self, from: &str, to: &str) {
|
||||
let to = to.to_string();
|
||||
let handler = move || {
|
||||
let to = to.clone();
|
||||
async move { Redirect::permanent(&to) }
|
||||
};
|
||||
|
||||
let mut r = self.router.write().await;
|
||||
|
||||
if from == "/" {
|
||||
// Pour la racine, utiliser merge au lieu de nest
|
||||
let route = Router::new().route("/", get(handler));
|
||||
*r = std::mem::take(&mut *r).merge(route);
|
||||
} else {
|
||||
let route = Router::new().route("/", get(handler));
|
||||
*r = std::mem::take(&mut *r).nest(from, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// Démarre le serveur HTTP
|
||||
///
|
||||
/// Lance le serveur sur le port configuré et met en place la gestion
|
||||
/// de Ctrl+C pour un arrêt gracieux.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoupnp::server::Server;
|
||||
/// # #[tokio::main]
|
||||
/// # async fn main() {
|
||||
/// # let mut server = Server::new("Test", "http://localhost:3000", 3000);
|
||||
/// server.start().await;
|
||||
/// server.wait().await; // Attend Ctrl+C
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn start(&mut self) {
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], self.http_port));
|
||||
info!("Server {} running at [http://{}:{}](http://{}:{})", self.name, self.base_url, self.http_port, self.base_url, self.http_port);
|
||||
|
||||
let router = self.router.clone();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let r = router.read().await.clone();
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, r.into_make_service()).await.unwrap();
|
||||
});
|
||||
|
||||
let shutdown_task = tokio::spawn(async move {
|
||||
signal::ctrl_c().await.expect("failed to listen for ctrl_c");
|
||||
info!("Ctrl+C reçu, arrêt gracieux");
|
||||
});
|
||||
|
||||
self.join_handle = Some(tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = server_task => {},
|
||||
_ = shutdown_task => {},
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Attend la fin du serveur
|
||||
pub async fn wait(&mut self) {
|
||||
if let Some(h) = self.join_handle.take() {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les infos du serveur
|
||||
pub fn info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
name: self.name.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
http_port: self.http_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder pattern
|
||||
pub struct ServerBuilder {
|
||||
name: String,
|
||||
base_url: String,
|
||||
http_port: u16,
|
||||
}
|
||||
|
||||
impl ServerBuilder {
|
||||
/// Crée un nouveau builder
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - Nom du serveur
|
||||
/// * `base_url` - URL de base (ex: "http://localhost:3000")
|
||||
/// * `http_port` - Port HTTP
|
||||
pub fn new(name: impl Into<String>, base_url: impl Into<String>, http_port: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url: base_url.into(),
|
||||
http_port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_configured() -> Self {
|
||||
let config = get_config();
|
||||
Self {
|
||||
name: "PMO-Music-Server".to_string(),
|
||||
base_url: config.get_base_url(),
|
||||
http_port: config.get_http_port()
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le serveur
|
||||
///
|
||||
/// Consomme le builder et retourne une instance de `Server` prête à l'emploi.
|
||||
///
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust
|
||||
/// # use pmoupnp::server::ServerBuilder;
|
||||
/// let mut server = ServerBuilder::new("MyAPI", "http://localhost:3000", 3000)
|
||||
/// .build();
|
||||
/// ```
|
||||
pub fn build(self) -> Server {
|
||||
Server::new(self.name, self.base_url, self.http_port)
|
||||
}
|
||||
}
|
||||
24
pmoupnp/webapp/.gitignore
vendored
24
pmoupnp/webapp/.gitignore
vendored
@@ -1,2 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
3
pmoupnp/webapp/.vscode/extensions.json
vendored
Normal file
3
pmoupnp/webapp/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
pmoupnp/webapp/README.md
Normal file
5
pmoupnp/webapp/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
pmoupnp/webapp/index.html
Normal file
13
pmoupnp/webapp/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webapp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1486
pmoupnp/webapp/package-lock.json
generated
Normal file
1486
pmoupnp/webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
pmoupnp/webapp/package.json
Normal file
25
pmoupnp/webapp/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "webapp",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.7",
|
||||
"marked": "^16.3.0",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.7",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
1
pmoupnp/webapp/public/vite.svg
Normal file
1
pmoupnp/webapp/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
29
pmoupnp/webapp/src/App.vue
Normal file
29
pmoupnp/webapp/src/App.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav>
|
||||
<router-link to="/">Accueil</router-link> |
|
||||
<router-link to="/logs">Logs</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// rien à importer
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
nav {
|
||||
background: #333;
|
||||
width: 100vw;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
a {
|
||||
color: #eee;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
a.router-link-active {
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
1
pmoupnp/webapp/src/assets/vue.svg
Normal file
1
pmoupnp/webapp/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
41
pmoupnp/webapp/src/components/HelloWorld.vue
Normal file
41
pmoupnp/webapp/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
580
pmoupnp/webapp/src/components/LogView.vue
Normal file
580
pmoupnp/webapp/src/components/LogView.vue
Normal file
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<div class="log-viewer">
|
||||
<div class="header">
|
||||
<h2>📋 System Logs</h2>
|
||||
<div class="controls">
|
||||
<button @click="toggleAutoScroll" :class="{ active: autoScroll }">
|
||||
{{ autoScroll ? '📌 Auto-scroll ON' : '📌 Auto-scroll OFF' }}
|
||||
</button>
|
||||
<button @click="clearLogs">🗑️ Clear</button>
|
||||
<select v-model="levelFilter" class="filter">
|
||||
<option value="ALL">All Levels</option>
|
||||
<option value="TRACE">TRACE</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARN">WARN</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-container" ref="logContainer">
|
||||
<div
|
||||
v-for="(log, index) in filteredLogs"
|
||||
:key="index"
|
||||
:class="['log-entry', `level-${log.level.toLowerCase()}`, { 'is-history': log.isHistory }]"
|
||||
>
|
||||
<span class="timestamp">{{ formatTimestamp(log.timestamp) }}</span>
|
||||
<span class="level">{{ log.level }}</span>
|
||||
<span class="target">{{ log.target }}</span>
|
||||
<span class="message markdown-content" v-html="renderMarkdown(log.message)"></span>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingHistory" class="loading-state">
|
||||
⏳ Loading history...
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredLogs.length === 0" class="empty-state">
|
||||
{{ isConnected ? 'Waiting for logs...' : 'Connecting to log stream...' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span :class="['status', { connected: isConnected }]">
|
||||
{{ isConnected ? '🟢 Connected' : '🔴 Disconnected' }}
|
||||
</span>
|
||||
<span class="count">{{ filteredLogs.length }} logs</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
// Configurer marked pour un rendu inline simple
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
const logs = ref([])
|
||||
const autoScroll = ref(true)
|
||||
const isConnected = ref(false)
|
||||
const isLoadingHistory = ref(true)
|
||||
const levelFilter = ref('ALL')
|
||||
const logContainer = ref(null)
|
||||
let eventSource = null
|
||||
let historyLoaded = false
|
||||
const seenLogIds = new Set() // Pour détecter les duplicatas
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (levelFilter.value === 'ALL') {
|
||||
return logs.value
|
||||
}
|
||||
return logs.value.filter(log => log.level === levelFilter.value)
|
||||
})
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp.secs_since_epoch * 1000)
|
||||
return date.toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
})
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
// Convertir markdown en HTML et nettoyer pour la sécurité
|
||||
const rawHtml = marked.parse(text, { async: false })
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'p', 'br'],
|
||||
ALLOWED_ATTR: ['href', 'target']
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll.value = !autoScroll.value
|
||||
if (autoScroll.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = []
|
||||
seenLogIds.clear()
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!logContainer.value || !autoScroll.value) return
|
||||
nextTick(() => {
|
||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
// Ajuste l'URL selon ton setup
|
||||
const baseUrl = window.location.origin
|
||||
eventSource = new EventSource(`${baseUrl}/log-sse`)
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnected.value = true
|
||||
console.log('SSE connection opened')
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const logEntry = JSON.parse(event.data)
|
||||
|
||||
// Créer un ID unique basé sur timestamp + message + target
|
||||
const logId = `${logEntry.timestamp.secs_since_epoch}-${logEntry.timestamp.nanos_since_epoch}-${logEntry.message}-${logEntry.target}`
|
||||
|
||||
// Ignorer les duplicatas
|
||||
if (seenLogIds.has(logId)) {
|
||||
return
|
||||
}
|
||||
seenLogIds.add(logId)
|
||||
|
||||
// Marquer les logs historiques
|
||||
if (!historyLoaded) {
|
||||
logEntry.isHistory = true
|
||||
}
|
||||
|
||||
logs.value.push(logEntry)
|
||||
|
||||
// Limiter à 1000 logs en mémoire
|
||||
if (logs.value.length > 1000) {
|
||||
const removed = logs.value.shift()
|
||||
// Nettoyer aussi le Set pour éviter qu'il grandisse indéfiniment
|
||||
const removedId = `${removed.timestamp.secs_since_epoch}-${removed.timestamp.nanos_since_epoch}-${removed.message}-${removed.target}`
|
||||
seenLogIds.delete(removedId)
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error('Failed to parse log entry:', error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isConnected.value = false
|
||||
isLoadingHistory.value = false
|
||||
console.error('SSE connection error')
|
||||
|
||||
// Reconnexion automatique après 3 secondes
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
historyLoaded = false
|
||||
connectSSE()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// Détecter la fin du chargement de l'historique
|
||||
// (on considère qu'après 500ms sans log, l'historique est chargé)
|
||||
let historyTimeout
|
||||
const originalOnMessage = eventSource.onmessage
|
||||
eventSource.onmessage = (event) => {
|
||||
clearTimeout(historyTimeout)
|
||||
originalOnMessage(event)
|
||||
|
||||
if (!historyLoaded) {
|
||||
historyTimeout = setTimeout(() => {
|
||||
historyLoaded = true
|
||||
isLoadingHistory.value = false
|
||||
console.log('History loaded, now streaming live logs')
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connectSSE()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Désactiver auto-scroll si l'utilisateur scroll manuellement
|
||||
watch(logContainer, (container) => {
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener('scroll', () => {
|
||||
const isAtBottom =
|
||||
container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
||||
|
||||
if (!isAtBottom && autoScroll.value) {
|
||||
autoScroll.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 80vh;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3c3c3c;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
button {
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #505050;
|
||||
}
|
||||
|
||||
button.active {
|
||||
background: #0e639c;
|
||||
border-color: #1177bb;
|
||||
}
|
||||
|
||||
.filter {
|
||||
padding: 0.5rem;
|
||||
background: #3c3c3c;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter {
|
||||
padding: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.log-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 80px 200px 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.log-entry {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.3rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
border-left-width: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.log-entry.is-history {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #858585;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timestamp {
|
||||
font-size: 0.75rem;
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.level {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.level {
|
||||
order: 2;
|
||||
width: fit-content;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.target {
|
||||
color: #4ec9b0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.target {
|
||||
order: 3;
|
||||
font-size: 0.8rem;
|
||||
color: #6eb8a5;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #d4d4d4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
order: 4;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-content {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code) {
|
||||
background: #3c3c3c;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.85em;
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre) {
|
||||
background: #2d2d30;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre code) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.markdown-content :deep(strong) {
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown-content :deep(em) {
|
||||
color: #dcdcaa;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content :deep(a) {
|
||||
color: #569cd6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(p) {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol) {
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* Level colors */
|
||||
.level-trace {
|
||||
border-left-color: #808080;
|
||||
}
|
||||
|
||||
.level-trace .level {
|
||||
background: #3a3a3a;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
border-left-color: #569cd6;
|
||||
}
|
||||
|
||||
.level-debug .level {
|
||||
background: #1e3a5f;
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.level-info {
|
||||
border-left-color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-info .level {
|
||||
background: #1e4d42;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.level-warn {
|
||||
border-left-color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-warn .level {
|
||||
background: #4d4d2a;
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.level-error {
|
||||
border-left-color: #f48771;
|
||||
}
|
||||
|
||||
.level-error .level {
|
||||
background: #5a1e1e;
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #858585;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #569cd6;
|
||||
font-size: 1.1rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #252526;
|
||||
border-top: 1px solid #3e3e42;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.log-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.log-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
</style>
|
||||
7
pmoupnp/webapp/src/main.ts
Normal file
7
pmoupnp/webapp/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
16
pmoupnp/webapp/src/router/index.ts
Normal file
16
pmoupnp/webapp/src/router/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import HelloWorld from "../components/HelloWorld.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", name: "home", component: HelloWorld },
|
||||
{ path: "/logs", name: "logs", component: LogView },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
// history avec base /app
|
||||
history: createWebHistory("/app"),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
5
pmoupnp/webapp/src/shims-vue.d.ts
vendored
Normal file
5
pmoupnp/webapp/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module "*.vue" {
|
||||
import { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
80
pmoupnp/webapp/src/style.css
Normal file
80
pmoupnp/webapp/src/style.css
Normal file
@@ -0,0 +1,80 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
16
pmoupnp/webapp/tsconfig.app.json
Normal file
16
pmoupnp/webapp/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
pmoupnp/webapp/tsconfig.json
Normal file
7
pmoupnp/webapp/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
pmoupnp/webapp/tsconfig.node.json
Normal file
26
pmoupnp/webapp/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
8
pmoupnp/webapp/vite.config.ts
Normal file
8
pmoupnp/webapp/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
})
|
||||
7
pmoutils/Cargo.toml
Normal file
7
pmoutils/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "pmoutils"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
get_if_addrs = "0.5.3"
|
||||
41
pmoutils/src/ip_utils.rs
Normal file
41
pmoutils/src/ip_utils.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::net::UdpSocket;
|
||||
use get_if_addrs::get_if_addrs;
|
||||
|
||||
pub fn guess_local_ip() -> String {
|
||||
// On tente de deviner l'IP locale
|
||||
match UdpSocket::bind("0.0.0.0:0") {
|
||||
Ok(socket) => {
|
||||
if socket.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(local_addr) = socket.local_addr() {
|
||||
return local_addr.ip().to_string();
|
||||
}
|
||||
}
|
||||
// Si erreur sur connect ou récupération de l'adresse
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
Err(_) => "127.0.0.1".to_string(), // Si bind échoue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn list_all_ips() -> std::collections::HashMap<String, Vec<String>> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
|
||||
if let Ok(interfaces) = get_if_addrs() {
|
||||
for iface in interfaces {
|
||||
let ip = iface.ip();
|
||||
if ip.is_loopback() {
|
||||
continue;
|
||||
}
|
||||
if ip.is_ipv4() {
|
||||
result.entry(iface.name)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(ip.to_string());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.insert("error".to_string(), vec!["Failed to get interfaces".to_string()]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
3
pmoutils/src/lib.rs
Normal file
3
pmoutils/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod ip_utils;
|
||||
|
||||
pub use ip_utils::guess_local_ip;
|
||||
Reference in New Issue
Block a user