hierottu GPU-tukea

This commit is contained in:
2026-04-01 19:12:04 +03:00
parent 3d91cd392a
commit d70fd81f05
4 changed files with 486 additions and 8 deletions

View File

@@ -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) {