On essaie de faire démarer un serveur web

This commit is contained in:
2025-09-23 20:54:50 +02:00
parent c17eb08b11
commit d53a458a52
37 changed files with 5139 additions and 56 deletions

View File

@@ -1,9 +1,9 @@
devices:
mediarenderer:
fakerenderer:
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
mediaserver:
qobuz:
udn: 28963b75-4c5f-4da7-b10e-ffafd
mediarenderer:
fakerenderer:
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
mediaserver:
qobuz:
udn: 28963b75-4c5f-4da7-b10e-ffafd
host:
http_port: "8080"
http_port: '8080'

1390
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
[workspace]
resolver = "3"
members = ["PMOMusic", "pmoupnp"]
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils"]

190
Makefile Normal file
View 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)"

View File

@@ -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"

View File

@@ -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
View File

@@ -0,0 +1 @@
/target

17
pmoconfig/Cargo.toml Normal file
View 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
View 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()
}

View 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"

View File

@@ -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"

View File

@@ -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;

View 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, &params) {
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, &params) {
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
}

View 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
View 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)
}
}

View File

@@ -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?

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
pmoupnp/webapp/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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

View 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>

View 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

View 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>

View 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>

View 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");

View 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
View File

@@ -0,0 +1,5 @@
declare module "*.vue" {
import { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View 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;
}
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
mod ip_utils;
pub use ip_utils::guess_local_ip;