Headless Core & JS Blur & MultiThreading Addet

This commit is contained in:
Joey Pillunat 2025-10-24 16:02:19 +02:00
parent a41f765211
commit 5b99621044
4 changed files with 235 additions and 68 deletions

View file

@ -24,4 +24,6 @@ zip = "0.6"
sha2 = "0.10"
pathdiff = "0.2"
chromiumoxide = {version = "0.7", features = ["tokio-runtime"] }
futures = "0.3"
futures = "0.3"
regex = "1.12.2"
uuid = { version = "1", features = ["v4"] }

View file

@ -1,12 +1,12 @@
use std::{path::Path, time::Duration};
use std::{path::Path, time::Duration, fs};
use futures::StreamExt;
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::page::NavigateParams;
use chromiumoxide::cdp::browser_protocol::input::InsertTextParams;
use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use tokio::sync::broadcast;
use crate::state::{SharedState, HeadlessPhase};
use crate::config::read_config;
pub async fn start(state: SharedState) -> Result<(), Box<dyn std::error::Error>> {
{
@ -109,6 +109,93 @@ impl PageHandle {
elem.click().await?;
Ok(())
}
pub async fn click_and_wait_navigation(&self, selector: &str, timeout_ms: u64) -> Result<(), Box<dyn std::error::Error>> {
// Future zum Warten auf die nächste Navigation vorbereiten
let wait_nav = self.page.wait_for_navigation();
// Klick ausführen
let elem = self.page.find_element(selector).await?;
elem.click().await?;
// Auf Navigation warten (mit Timeout absichern)
match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), wait_nav).await {
Ok(Ok(_)) => Ok(()), // Navigation erfolgreich abgeschlossen
Ok(Err(e)) => Err(Box::new(e)), // Navigation-Error
Err(_) => Err("navigation timeout".into()), // Timeout
}
}
pub async fn wait_for_dom_quiet(
&self,
timeout_ms: u64,
) -> Result<(), Box<dyn std::error::Error>> {
use tokio::time::{sleep, Duration, Instant};
// Heuristik: HTML-Größe stabilisiert sich für zwei aufeinanderfolgende Messungen
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
let mut last_len = 0usize;
let mut stable = 0u8;
while Instant::now() < deadline {
let html = self.page.content().await.unwrap_or_default();
let len = html.len();
if len == last_len {
stable += 1;
if stable >= 2 {
return Ok(());
}
} else {
stable = 0;
last_len = len;
}
sleep(Duration::from_millis(250)).await;
}
Err("wait_for_dom_quiet: timeout".into())
}
/// Setzt den Wert eines Inputs per JS (löscht zuerst), feuert input/change/blur.
pub async fn set_input_value_by_id(&self, id: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
let js = format!(r#"
(function() {{
var el = document.getElementById("{id}");
if (!el) return false;
el.scrollIntoView({{block:'center', inline:'center'}});
el.focus();
el.value = "";
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.value = "{value}";
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
// Blur auslösen, viele Form-UIs übernehmen dann erst
el.blur && el.blur();
return true;
}})()
"#);
// v0.7: evaluate akzeptiert &str direkt
let _ = self.page.evaluate(js.as_str()).await?;
Ok(())
}
//Debug
pub async fn dump_all_ids(&self, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
// Gesamtes DOM als HTML ziehen
let html = self.page.content().await?;
// IDs per Regex herausziehen (einfach & robust)
let re = regex::Regex::new(r#"id\s*=\s*"([^"]+)""#)?;
let mut out = String::new();
for cap in re.captures_iter(&html) {
out.push_str(&cap[1]);
out.push('\n');
}
std::fs::write(filename, out)?;
Ok(())
}
}
pub async fn run_test(ip: &str, log: broadcast::Sender<String>) -> Result<(), Box<dyn std::error::Error>> {
@ -122,23 +209,37 @@ pub async fn run_test(ip: &str, log: broadcast::Sender<String>) -> Result<(), Bo
let manager = HeadlessManager::new(chrome_exe, &cfg.chrome.args).await?;
let page = manager.new_page().await?;
let url_https = format!("https://{ip}/");
let url_http = format!("http://{ip}/");
let _ = log.send(format!("[Headless] goto {url_https}"));
if page.goto(&url_https).await.is_err() {
let _ = log.send(format!("[Headless] HTTPS fehlgeschlagen, versuche HTTP"));
let _ = log.send(format!("[Headless] goto {url_http}"));
page.goto(&url_http).await?;
}
page.goto("https://192.168.178.240/hp/device/SignIn/Index").await?;
page.wait_for_selector("#PasswordTextBox", 5000).await?;
let _ = log.send("PasswordTextBox gefunden".to_string());
let _ = log.send("[Headless] warte auf <h1> …".to_string());
let _ = page.wait_for_selector("h1", 5000).await?;
page.type_text("#PasswordTextBox", "Pa55w.rt").await?;
let _ = log.send("Passwort erfolgreich eingetragen".to_string());
match page.get_text("h1").await? {
Some(h1) => { let _ = log.send(format!("[Headless] H1: {h1}")); }
None => { let _ = log.send("[Headless] Keine H1 gefunden".to_string()); }
}
page.wait_for_selector("#signInOk", 5000).await?;
let _ = log.send("Login-Button gefunden, klicke …".to_string());
page.click_and_wait_navigation("#signInOk", 15_000).await?;
let _ = log.send("Login erfolgreich, warte auf Menü".to_string());
page.wait_for_dom_quiet(8_000).await?;
page.goto("https://192.168.178.240/hp/device/SleepSchedule/Index").await?;
page.wait_for_selector("#SleepDelayTimeLimit", 5000).await?;
let _ = log.send("Eingabefeld 'SleepDelayTimeLimit' gefunden".to_string());
page.set_input_value_by_id("SleepDelayTimeLimit", "15").await?;
let _ = log.send("SleepDelayTimeLimit = 15 eingetragen".to_string());
page.wait_for_selector("#FormButtonSubmit", 5000).await?;
let _ = log.send("Submit-Button gefunden, sende Formular …".to_string());
page.click_and_wait_navigation("#FormButtonSubmit", 15_000).await?;
let _ = log.send("Formular erfolgreich gesendet ✅".to_string());
page.wait_for_dom_quiet(8_000).await?;
Ok(())
}

View file

@ -38,29 +38,33 @@ document.addEventListener('DOMContentLoaded', () => {
const status = document.getElementById('status');
const consoleEl = document.getElementById('console');
// SSE: Logs anhören
const es = new EventSource('/api/logs');
es.onmessage = (ev) => {
consoleEl.textContent += ev.data + '\n';
consoleEl.scrollTop = consoleEl.scrollHeight;
};
es.onerror = () => {
// optional: Reconnect-Info
};
btn.addEventListener('click', async () => {
const val = (ip.value || '').trim();
if (!val) { status.textContent = 'Bitte IP eingeben.'; return; }
status.textContent = `Starte`;
status.textContent = 'Starte';
try {
// 1) Job starten → job_id erhalten
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip: val })
});
const text = await res.text();
status.textContent = text || 'OK';
const data = await res.json();
const jobId = data.job_id;
status.textContent = `Job: ${jobId}`;
consoleEl.textContent = '';
// 2) individuellen Log-Stream für diesen Job öffnen
const es = new EventSource(`/api/logs?job=${encodeURIComponent(jobId)}`);
es.onmessage = (ev) => {
consoleEl.textContent += ev.data + '\n';
consoleEl.scrollTop = consoleEl.scrollHeight;
};
es.onerror = () => {
// Stream beendet/Fehler optional kennzeichnen
};
} catch (err) {
status.textContent = 'Fehler: ' + (err?.message || err);
}

View file

@ -1,25 +1,29 @@
mod assets;
use std::{
collections::HashMap,
env, fs,
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use axum::{
extract::{Json, Query, State as AxumState},
response::{Html, sse::{Event, KeepAlive, Sse}},
routing::{get, post},
extract::Json,
Router,
response::{Html, sse::{Sse, Event, KeepAlive}},
};
use tower_http::services::ServeDir;
use serde::Deserialize;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::{sync::{broadcast, Mutex}};
use tokio::time::{sleep, Duration};
use tokio_stream::wrappers::BroadcastStream;
use tower_http::services::ServeDir;
use uuid::Uuid;
use assets::{DEFAULT_INDEX_HTML, DEFAULT_APP_JS, DEFAULT_STYLES_CSS};
use crate::{state::SharedState, headless};
use assets::{DEFAULT_APP_JS, DEFAULT_INDEX_HTML, DEFAULT_STYLES_CSS};
use crate::{headless, state::SharedState};
const WEB_DIR: &str = "web";
@ -45,7 +49,10 @@ fn rebuild_web_assets(dir: &Path) -> std::io::Result<()> {
#[derive(Clone)]
struct HttpState {
shared: SharedState,
log_tx: broadcast::Sender<String>,
// Optional: globaler Stream (falls du eine Gesamtkonsole willst)
_global_log_tx: broadcast::Sender<String>,
// Pro-Job Sender-Registry
jobs: Arc<Mutex<HashMap<String, broadcast::Sender<String>>>>,
}
#[derive(Deserialize)]
@ -53,67 +60,120 @@ struct StartRequest {
ip: String,
}
#[derive(Serialize)]
struct StartResponse {
job_id: String,
}
async fn start_job(
axum::extract::State(st): axum::extract::State<HttpState>,
Json(req): Json<StartRequest>,
) {
) -> axum::Json<StartResponse> {
use axum::Json as AxumJson;
let ip = req.ip.trim().to_string();
let tx_for_task = st.log_tx.clone();
// neuen Job anlegen
let job_id = uuid::Uuid::new_v4().to_string();
let (tx, _rx) = tokio::sync::broadcast::channel::<String>(200);
{
// in Registry eintragen
let mut map = st.jobs.lock().await;
map.insert(job_id.clone(), tx.clone());
}
// Klone für den Task
let job_id_for_task = job_id.clone();
let jobs_map = st.jobs.clone();
tokio::spawn(async move {
if let Err(e) = headless::run_test(&ip, tx_for_task.clone()).await {
let _ = tx_for_task.send(format!("[Headless Error] {e}"));
sleep(Duration::from_millis(500)).await;
tx.send(format!("[Job {job_id_for_task}] Starte Setup für {ip}")).ok();
// WICHTIG: Ergebnis NICHT in Variable halten, sondern sofort matchen,
// damit kein non-Send Error über das spätere .await (Mutex) "lebt".
if let Err(e) = crate::headless::run_test(&ip, tx.clone()).await {
tx.send(format!("[Job {job_id_for_task}] ❌ Fehler: {e}")).ok();
} else {
let _ = tx_for_task.send(format!("[Headless] Fertig für {ip}"));
tx.send(format!("[Job {job_id_for_task}] ✅ Fertig")).ok();
}
// Erst NACH dem match den Mutex awaiten -> der Error ist schon gedroppt
let mut m = jobs_map.lock().await;
m.remove(&job_id_for_task);
});
AxumJson(StartResponse { job_id })
}
#[derive(Deserialize)]
struct LogsQuery {
job: String,
}
async fn stream_logs(
axum::extract::State(st): axum::extract::State<HttpState>
) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
let rx = st.log_tx.subscribe();
AxumState(st): AxumState<HttpState>,
Query(q): Query<LogsQuery>,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>, axum::http::StatusCode> {
// Sender für diesen Job holen
let tx = {
let map = st.jobs.lock().await;
match map.get(&q.job) {
Some(tx) => tx.clone(),
None => return Err(axum::http::StatusCode::NOT_FOUND),
}
};
// Subscribe & streamen
let rx = tx.subscribe();
let stream = BroadcastStream::new(rx)
.filter_map(|res| async move { res.ok() })
.map(|msg| Ok(Event::default().data(msg)));
Sse::new(stream).keep_alive(
Ok(Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(std::time::Duration::from_secs(10))
.text("💓"),
)
))
}
// Port wird jetzt von außen übergeben
pub async fn start(state: SharedState, port: u16)
-> Result<tokio::task::JoinHandle<()>, Box<dyn std::error::Error>>
{
pub async fn start(
state: SharedState,
port: u16,
) -> Result<tokio::task::JoinHandle<()>, Box<dyn std::error::Error>> {
let dir = exe_dir();
rebuild_web_assets(&dir)?;
// Broadcast-Kanal für Web-Logs
let (log_tx, _rx) = broadcast::channel::<String>(200);
let http_state = HttpState { shared: state.clone(), log_tx: log_tx.clone() };
let (global_log_tx, _rx) = broadcast::channel::<String>(200);
let http_state = HttpState {
shared: state.clone(),
_global_log_tx: global_log_tx.clone(),
jobs: Arc::new(Mutex::new(HashMap::new())),
};
let web_root = dir.join(WEB_DIR);
let static_root = web_root.clone();
let index_path = web_root.join("index.html");
let app = Router::new()
.route("/", get({
let index_path = index_path.clone();
move || {
.route(
"/",
get({
let index_path = index_path.clone();
async move {
let html = fs::read_to_string(&index_path)
.unwrap_or_else(|_| "<h1>index.html fehlt</h1>".to_string());
Html(html)
move || {
let index_path = index_path.clone();
async move {
let html = fs::read_to_string(&index_path)
.unwrap_or_else(|_| "<h1>index.html fehlt</h1>".to_string());
Html(html)
}
}
}
}))
.route("/api/start", post(start_job)) // Start-Button
.route("/api/logs", get(stream_logs)) // SSE-Konsole
}),
)
.route("/api/start", post(start_job))
.route("/api/logs", get(stream_logs)) // /api/logs?job=<job_id>
.nest_service("/static", ServeDir::new(static_root))
.route("/healthz", get(|| async { "ok" }))
.with_state(http_state);