Headless Core & JS Blur & MultiThreading Addet
This commit is contained in:
parent
a41f765211
commit
5b99621044
4 changed files with 235 additions and 68 deletions
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue