hierottu GPU-tukea
This commit is contained in:
@@ -13,6 +13,8 @@ use tokio::sync::broadcast;
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod db;
|
||||
|
||||
const MAX_MESSAGE_SIZE: usize = 16 * 1024;
|
||||
|
||||
// Sallitut originit — estää cross-site WebSocket hijackingin
|
||||
@@ -30,12 +32,173 @@ struct AppState {
|
||||
nodes_vram: Mutex<HashMap<u64, u32>>,
|
||||
total_tasks: Mutex<u64>,
|
||||
stats_tx: broadcast::Sender<String>,
|
||||
// IP-rajoitus: max 2 yhteyttä per IP (dashboard-UI + selainsolmu)
|
||||
ip_connections: Mutex<HashMap<IpAddr, u32>>,
|
||||
// Node ID → IP -mappaus (siivousta varten)
|
||||
node_ips: Mutex<HashMap<u64, IpAddr>>,
|
||||
db: db::NodeDb,
|
||||
}
|
||||
|
||||
const ADMIN_HTML: &str = r##"<!DOCTYPE html>
|
||||
<html lang="fi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kipina Admin</title>
|
||||
<style>
|
||||
:root { --bg:#0d1117; --panel:#161b22; --text:#c9d1d9; --accent:#58a6ff; --green:#3fb950; --yellow:#d29922; --red:#f85149; --border:#30363d; }
|
||||
* { box-sizing:border-box; margin:0; padding:0; }
|
||||
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--text); padding:20px; }
|
||||
h1 { color:var(--accent); margin-bottom:5px; }
|
||||
.sub { color:#8b949e; margin-bottom:20px; }
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:24px; }
|
||||
.stat-card { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:16px; text-align:center; }
|
||||
.stat-card .val { font-size:28px; font-weight:700; color:var(--accent); }
|
||||
.stat-card .label { font-size:12px; color:#8b949e; margin-top:4px; }
|
||||
table { width:100%; border-collapse:collapse; margin-bottom:24px; font-size:13px; }
|
||||
th { background:var(--panel); color:var(--accent); text-align:left; padding:10px 8px; border-bottom:2px solid var(--border); position:sticky; top:0; }
|
||||
td { padding:8px; border-bottom:1px solid var(--border); }
|
||||
tr:hover td { background:#1c2333; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||
.badge-green { background:#23392050; color:var(--green); border:1px solid #23392080; }
|
||||
.badge-yellow { background:#d2992220; color:var(--yellow); border:1px solid #d2992240; }
|
||||
.badge-red { background:#f8514920; color:var(--red); border:1px solid #f8514940; }
|
||||
.badge-blue { background:#58a6ff20; color:var(--accent); border:1px solid #58a6ff40; }
|
||||
.tabs { display:flex; gap:8px; margin-bottom:16px; }
|
||||
.tab { padding:8px 16px; border-radius:6px; border:1px solid var(--border); background:var(--panel); color:var(--text); cursor:pointer; font-size:14px; }
|
||||
.tab.active { background:var(--accent); color:#0d1117; border-color:var(--accent); }
|
||||
.panel { display:none; }
|
||||
.panel.active { display:block; }
|
||||
.table-wrap { overflow-x:auto; max-height:70vh; overflow-y:auto; }
|
||||
.online { color:var(--green); }
|
||||
.offline { color:#8b949e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Kipina Admin</h1>
|
||||
<p class="sub">Node-sessiot ja tokenisointivertailut</p>
|
||||
|
||||
<div id="stats" class="stats-grid"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="showTab('sessions')">Sessiot</div>
|
||||
<div class="tab" onclick="showTab('pairs')">Tokenisointiparit</div>
|
||||
</div>
|
||||
|
||||
<div id="sessions" class="panel active">
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr>
|
||||
<th>ID</th><th>Tila</th><th>Tyyppi</th><th>IP</th><th>Alusta</th>
|
||||
<th>OS</th><th>CPU</th><th>RAM</th><th>GPU</th><th>VRAM</th>
|
||||
<th>WebGPU</th><th>Teht.</th><th>Yhdistetty</th><th>Kesto</th>
|
||||
</tr></thead><tbody id="sessions-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pairs" class="panel">
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr>
|
||||
<th>Solmu</th><th>EN</th><th>EN tok</th><th>EN m/t</th>
|
||||
<th>FI</th><th>FI tok</th><th>FI m/t</th><th>Ylikust.</th><th>Aika</th>
|
||||
</tr></thead><tbody id="pairs-body"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showTab(name) {
|
||||
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById(name).classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
function badge(text, cls) { return `<span class="badge badge-${cls}">${text}</span>`; }
|
||||
|
||||
function timeSince(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const s = Math.floor((now - d) / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.floor(s/60) + 'min';
|
||||
if (s < 86400) return Math.floor(s/3600) + 'h';
|
||||
return Math.floor(s/86400) + 'pv';
|
||||
}
|
||||
|
||||
function duration(start, end) {
|
||||
if (!start) return '';
|
||||
const s = end ? new Date(end) : new Date();
|
||||
const d = Math.floor((s - new Date(start)) / 1000);
|
||||
if (d < 60) return d + 's';
|
||||
if (d < 3600) return Math.floor(d/60) + 'min';
|
||||
return Math.floor(d/3600) + 'h ' + (Math.floor(d/60)%60) + 'min';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [statsRes, sessionsRes, pairsRes] = await Promise.all([
|
||||
fetch('/api/stats'), fetch('/api/sessions'), fetch('/api/pairs')
|
||||
]);
|
||||
const stats = await statsRes.json();
|
||||
const sessions = await sessionsRes.json();
|
||||
const pairs = await pairsRes.json();
|
||||
|
||||
// Stats
|
||||
document.getElementById('stats').innerHTML = [
|
||||
{v: stats.total_sessions, l: 'Sessioita'},
|
||||
{v: stats.active_sessions, l: 'Aktiivisia'},
|
||||
{v: stats.unique_ips, l: 'Uniikkeja IP'},
|
||||
{v: stats.webgpu_sessions, l: 'WebGPU'},
|
||||
{v: stats.cpu_fallback_sessions, l: 'CPU fallback'},
|
||||
{v: stats.total_pairs, l: 'Pareja'},
|
||||
{v: stats.avg_en_chars_per_token, l: 'EN m/t (ka.)'},
|
||||
{v: stats.avg_fi_chars_per_token, l: 'FI m/t (ka.)'},
|
||||
{v: stats.avg_overhead_pct + '%', l: 'FI ylikust. (ka.)'},
|
||||
].map(s => `<div class="stat-card"><div class="val">${s.v}</div><div class="label">${s.l}</div></div>`).join('');
|
||||
|
||||
// Sessions
|
||||
document.getElementById('sessions-body').innerHTML = sessions.map(s => {
|
||||
const online = !s.disconnected_at;
|
||||
const status = online ? '<span class="online">ONLINE</span>' : '<span class="offline">offline</span>';
|
||||
const typeBadge = s.node_type === 'native' ? badge('native','blue') : badge('browser','yellow');
|
||||
const gpuBadge = s.has_webgpu ? badge('WebGPU','green') : badge('CPU','red');
|
||||
const gpu = s.gpu_name ? `${s.gpu_name}` : '-';
|
||||
const vram = s.vram_total_mb ? `${s.vram_total_mb} MB` : '-';
|
||||
const ram = s.ram_mb ? `${s.ram_mb} MB` : '-';
|
||||
const cores = s.cpu_cores || '-';
|
||||
const plat = s.platform || s.hostname || '-';
|
||||
const os = s.os || '-';
|
||||
const time = s.connected_at ? new Date(s.connected_at).toLocaleString('fi-FI') : '';
|
||||
const dur = duration(s.connected_at, s.disconnected_at);
|
||||
return `<tr>
|
||||
<td>${s.node_id}</td><td>${status}</td><td>${typeBadge}</td><td>${s.ip}</td>
|
||||
<td>${plat}</td><td>${os}</td><td>${cores}</td><td>${ram}</td>
|
||||
<td>${gpu}</td><td>${vram}</td><td>${gpuBadge}</td>
|
||||
<td>${s.tasks_completed}</td><td>${time}</td><td>${dur}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Pairs
|
||||
document.getElementById('pairs-body').innerHTML = pairs.map(p => {
|
||||
const ovColor = p.overhead_pct > 50 ? 'red' : p.overhead_pct > 20 ? 'yellow' : 'green';
|
||||
const enCpt = p.en_chars_per_token?.toFixed(2) || '-';
|
||||
const fiCpt = p.fi_chars_per_token?.toFixed(2) || '-';
|
||||
const ov = p.overhead_pct?.toFixed(1) || '-';
|
||||
return `<tr>
|
||||
<td>#${p.node_id}</td>
|
||||
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.en_text||''}</td>
|
||||
<td>${p.en_tokens||'-'}</td><td>${enCpt}</td>
|
||||
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.fi_text||''}</td>
|
||||
<td>${p.fi_tokens||'-'}</td><td>${fiCpt}</td>
|
||||
<td>${badge(ov+'%', ovColor)}</td>
|
||||
<td>${p.duration_ms||0}ms</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"##;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
@@ -55,8 +218,11 @@ async fn main() {
|
||||
stats_tx: stats_tx.clone(),
|
||||
ip_connections: Mutex::new(HashMap::new()),
|
||||
node_ips: Mutex::new(HashMap::new()),
|
||||
db: db::NodeDb::new(&std::env::var("DATABASE_PATH").unwrap_or_else(|_| "nodes.db".to_string())),
|
||||
});
|
||||
|
||||
tracing::info!("Tietokanta alustettu");
|
||||
|
||||
let state_for_task = state.clone();
|
||||
|
||||
// Ajastin, joka jakaa satunnaisia tekoälytehtäviä eri pituuksilla
|
||||
@@ -100,18 +266,41 @@ async fn main() {
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.nest_service("/", ServeDir::new(std::env::var("STATIC_DIR").unwrap_or_else(|_| "../static".to_string())))
|
||||
.route("/ws", get(ws_handler))
|
||||
.route("/api/sessions", get(api_sessions))
|
||||
.route("/api/pairs", get(api_pairs))
|
||||
.route("/api/stats", get(api_stats))
|
||||
.route("/admin", get(admin_page))
|
||||
.nest_service("/", ServeDir::new(std::env::var("STATIC_DIR").unwrap_or_else(|_| "../static".to_string())))
|
||||
.with_state(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
||||
tracing::debug!("Kipinä Agent Hub käynnistyy osoitteessa http://localhost:3000");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
).await.unwrap();
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
|
||||
}
|
||||
|
||||
async fn api_sessions(
|
||||
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
axum::Json(state.db.get_sessions(200))
|
||||
}
|
||||
|
||||
async fn api_pairs(
|
||||
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
axum::Json(state.db.get_pair_results(500))
|
||||
}
|
||||
|
||||
async fn api_stats(
|
||||
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
axum::Json(state.db.get_stats())
|
||||
}
|
||||
|
||||
async fn admin_page() -> impl IntoResponse {
|
||||
axum::response::Html(ADMIN_HTML)
|
||||
}
|
||||
|
||||
async fn ws_handler(
|
||||
@@ -298,6 +487,9 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
map.insert(node_id, allocated);
|
||||
}
|
||||
|
||||
// Tallennetaan sessiotieto tietokantaan
|
||||
state.db.insert_session(node_id, &ip.to_string(), node_type, &json);
|
||||
|
||||
if node_type == "native" {
|
||||
let sys = json.get("system");
|
||||
let hostname = sys.and_then(|s| s.get("hostname")).and_then(|v| v.as_str()).unwrap_or("?");
|
||||
@@ -383,6 +575,12 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
println!(" {} merkkiä → \x1b[35m{} tokenia\x1b[0m | \x1b[32m{:.2} merkkiä/token\x1b[0m", fi_chars, fi_tokens, fi_cpt);
|
||||
println!(" {}Suomen ylikustannus: {:+.1}%\x1b[0m", overhead_color, overhead);
|
||||
|
||||
// Tallennetaan parin tulos tietokantaan
|
||||
let en_ref = obj.get("en").cloned().unwrap_or_default();
|
||||
let fi_ref = obj.get("fi").cloned().unwrap_or_default();
|
||||
state.db.insert_pair_result(node_id, &en_ref, &fi_ref, overhead, duration);
|
||||
state.db.increment_tasks(node_id);
|
||||
|
||||
obj.insert("node_id".to_string(), serde_json::json!(node_id));
|
||||
}
|
||||
let _ = state.stats_tx.send(json.to_string());
|
||||
@@ -404,7 +602,8 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, ip: IpAddr) {
|
||||
}
|
||||
}
|
||||
|
||||
// Yhteys katkesi — siivotaan IP-laskuri ja node-tiedot
|
||||
// Yhteys katkesi — merkitään session päättyneeksi ja siivotaan
|
||||
state.db.close_session(node_id);
|
||||
{
|
||||
let mut conns = state.ip_connections.lock().unwrap();
|
||||
if let Some(count) = conns.get_mut(&ip) {
|
||||
|
||||
Reference in New Issue
Block a user