diff --git a/src/admin/mod.rs b/src/admin/mod.rs index a63fe8c..caf901d 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -184,6 +184,7 @@ pub fn api_routes(admin_token: String) -> Router { .route("/admin/api/logs", get(get_logs).delete(clear_logs)) .route("/admin/api/system", get(get_system)) .route("/admin/api/restart", post(restart_gateway)) + .route("/admin/api/shutdown", post(shutdown_gateway)) .route("/admin/api/bench", get(bench_list).post(bench_start)) .route("/admin/api/config/export", get(config_export)) .route("/admin/api/config/import", post(config_import)) @@ -2272,6 +2273,20 @@ async fn get_system(State(state): State) -> ApiResult { ok(serde_json::to_value(snap).unwrap_or_else(|_| json!({}))) } +/// Stop every managed local model server and power the gateway off. The 200 +/// goes out first so the SPA can show its "gateway is off" screen; nothing is +/// respawned — the operator starts the gateway again by hand (or the Docker +/// restart policy does, in which case this behaves like a restart). +async fn shutdown_gateway(State(state): State) -> ApiResult { + state.cmf.stop().await; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + tracing::info!("shutdown requested from the admin console"); + std::process::exit(0); + }); + ok(json!({ "ok": true })) +} + /// Restart the gateway in place: stop managed model servers, spawn a fresh /// copy of ourselves (it retries the bind while our port frees), then exit. /// In Docker we only exit — we are PID 1, a spawned child would die with the diff --git a/web/css/styles.css b/web/css/styles.css index 3eae362..6e68e6b 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -706,3 +706,8 @@ tbody tr:last-child { animation: bm-in .25s ease; } @media (prefers-reduced-motion: reduce) { .sidebar, .mobile-nav-backdrop { transition: none; } } + +/* full-screen farewell after "stop everything & power off" */ +.shutdown-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; gap: 12px; text-align: center; padding: 24px; } +.shutdown-screen .shutdown-icon { font-size: 64px; color: var(--bad); } +.shutdown-screen .hint { max-width: 480px; } diff --git a/web/js/api.js b/web/js/api.js index 32230ac..4047de0 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -134,6 +134,7 @@ export const api = { logs: (limit = 300) => j("GET", "/logs?limit=" + limit), clearLogs: () => j("DELETE", "/logs"), restart: () => j("POST", "/restart"), + shutdown: () => j("POST", "/shutdown"), benchStart: (model) => j("POST", "/bench", { model }), benchList: () => j("GET", "/bench"), diff --git a/web/js/i18n/de.js b/web/js/i18n/de.js index 314fe2c..1a42f1d 100644 --- a/web/js/i18n/de.js +++ b/web/js/i18n/de.js @@ -536,9 +536,13 @@ export default { "keys.snip.withKey": "Die Beispiele unten enthalten bereits den gerade erstellten Schlüssel.", "keys.snip.openMode": "Keine API-Schlüssel konfiguriert — das Gateway akzeptiert jeden Wert.", "keys.snip.keyPh": "dein API-Schlüssel", - "settings.restart": "Gateway neu starten", - "settings.restartConfirm": "Gateway neu starten? Laufende Anfragen brechen ab; lokale Modellserver starten ebenfalls neu.", + "settings.restart": "Alles neu starten", + "settings.restartConfirm": "Gateway und alle lokalen Modellserver neu starten? Laufende Anfragen werden abgebrochen.", "settings.restarting": "Neustart — die Seite lädt neu, sobald das Gateway wieder da ist…", + "settings.shutdown": "Alles stoppen & ausschalten", + "settings.shutdownConfirm": "Alle lokalen Modellserver stoppen und das Gateway ausschalten? API und dieses Panel antworten erst wieder, wenn Sie das Gateway neu starten.", + "settings.shutdownDone": "Das Gateway ist ausgeschaltet.", + "settings.shutdownHint": "Alle lokalen Modellserver sind gestoppt, der Prozess wurde beendet. Starten Sie das Gateway auf dem Server neu, um das Panel zurückzubekommen.", "bench.title": "Geschwindigkeit lokaler Modelle", "bench.hint": "Führt `cortiq bench` auf dieser Maschine aus — echte tok/s, ein Modell nach dem anderen.", "bench.run": "Messen", diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 673b5e2..6029ba7 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -529,9 +529,13 @@ export default { "keys.snip.withKey": "Snippets below include the key you just created.", "keys.snip.openMode": "No API keys are configured — the gateway accepts any key value.", "keys.snip.keyPh": "your API key", - "settings.restart": "Restart gateway", - "settings.restartConfirm": "Restart the gateway? Requests in flight will be dropped; local model servers restart too.", + "settings.restart": "Restart everything", + "settings.restartConfirm": "Restart the gateway and all local model servers? Requests in flight will be dropped.", "settings.restarting": "Restarting — the page reloads when the gateway is back…", + "settings.shutdown": "Stop everything & power off", + "settings.shutdownConfirm": "Stop all local model servers and power the gateway off? The API and this panel stop answering until you start the gateway again.", + "settings.shutdownDone": "The gateway is off.", + "settings.shutdownHint": "All local model servers are stopped and the process has exited. Start the gateway again on the server to bring the panel back.", "bench.title": "Local model speed", "bench.hint": "Runs `cortiq bench` on this machine — real tok/s, one model at a time.", "bench.run": "Measure", diff --git a/web/js/i18n/es.js b/web/js/i18n/es.js index fcdd7eb..7ed6e54 100644 --- a/web/js/i18n/es.js +++ b/web/js/i18n/es.js @@ -536,9 +536,13 @@ export default { "keys.snip.withKey": "Los ejemplos de abajo ya incluyen la clave que acabas de crear.", "keys.snip.openMode": "No hay claves API configuradas — la pasarela acepta cualquier valor.", "keys.snip.keyPh": "tu clave API", - "settings.restart": "Reiniciar la pasarela", - "settings.restartConfirm": "¿Reiniciar la pasarela? Las peticiones en curso se cortarán; los servidores de modelos locales también se reinician.", + "settings.restart": "Reiniciar todo", + "settings.restartConfirm": "¿Reiniciar la pasarela y todos los servidores de modelos locales? Las solicitudes en curso se interrumpirán.", "settings.restarting": "Reiniciando — la página se recargará cuando la pasarela vuelva…", + "settings.shutdown": "Detener todo y apagar", + "settings.shutdownConfirm": "¿Detener todos los servidores de modelos locales y apagar la pasarela? La API y este panel dejarán de responder hasta que vuelva a iniciar la pasarela.", + "settings.shutdownDone": "La pasarela está apagada.", + "settings.shutdownHint": "Todos los servidores de modelos locales están detenidos y el proceso ha terminado. Inicie de nuevo la pasarela en el servidor para recuperar el panel.", "bench.title": "Velocidad de modelos locales", "bench.hint": "Ejecuta `cortiq bench` en esta máquina — tok/s reales, un modelo a la vez.", "bench.run": "Medir", diff --git a/web/js/i18n/fr.js b/web/js/i18n/fr.js index d64802f..0d43874 100644 --- a/web/js/i18n/fr.js +++ b/web/js/i18n/fr.js @@ -536,9 +536,13 @@ export default { "keys.snip.withKey": "Les exemples ci-dessous incluent la clé que vous venez de créer.", "keys.snip.openMode": "Aucune clé API configurée — la passerelle accepte n'importe quelle valeur.", "keys.snip.keyPh": "votre clé API", - "settings.restart": "Redémarrer la passerelle", - "settings.restartConfirm": "Redémarrer la passerelle ? Les requêtes en cours seront coupées ; les serveurs de modèles locaux redémarrent aussi.", + "settings.restart": "Tout redémarrer", + "settings.restartConfirm": "Redémarrer la passerelle et tous les serveurs de modèles locaux ? Les requêtes en cours seront interrompues.", "settings.restarting": "Redémarrage — la page se recharge dès que la passerelle revient…", + "settings.shutdown": "Tout arrêter et éteindre", + "settings.shutdownConfirm": "Arrêter tous les serveurs de modèles locaux et éteindre la passerelle ? L'API et ce panneau ne répondront plus tant que vous n'aurez pas relancé la passerelle.", + "settings.shutdownDone": "La passerelle est éteinte.", + "settings.shutdownHint": "Tous les serveurs de modèles locaux sont arrêtés et le processus est terminé. Relancez la passerelle sur le serveur pour retrouver le panneau.", "bench.title": "Vitesse des modèles locaux", "bench.hint": "Exécute `cortiq bench` sur cette machine — de vrais tok/s, un modèle à la fois.", "bench.run": "Mesurer", diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index ddfabde..b6106b2 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -528,9 +528,13 @@ export default { "keys.snip.withKey": "В примерах ниже уже подставлен только что созданный ключ.", "keys.snip.openMode": "API-ключи не настроены — шлюз принимает любое значение ключа.", "keys.snip.keyPh": "ваш API-ключ", - "settings.restart": "Перезапустить шлюз", - "settings.restartConfirm": "Перезапустить шлюз? Текущие запросы оборвутся; серверы локальных моделей тоже перезапустятся.", + "settings.restart": "Перезапустить всё", + "settings.restartConfirm": "Перезапустить шлюз и все серверы локальных моделей? Текущие запросы оборвутся.", "settings.restarting": "Перезапуск — страница обновится, когда шлюз поднимется…", + "settings.shutdown": "Остановить всё и выключить", + "settings.shutdownConfirm": "Остановить все серверы локальных моделей и выключить шлюз? API и эта панель перестанут отвечать, пока вы не запустите шлюз заново.", + "settings.shutdownDone": "Шлюз выключен.", + "settings.shutdownHint": "Все серверы локальных моделей остановлены, процесс завершён. Запустите шлюз на сервере заново, чтобы вернуть панель.", "bench.title": "Скорость локальных моделей", "bench.hint": "Запускает `cortiq bench` на этой машине — реальные tok/s, по одной модели за раз.", "bench.run": "Замерить", diff --git a/web/js/i18n/tr.js b/web/js/i18n/tr.js index 13bfd91..eefd4dd 100644 --- a/web/js/i18n/tr.js +++ b/web/js/i18n/tr.js @@ -536,9 +536,13 @@ export default { "keys.snip.withKey": "Aşağıdaki örnekler az önce oluşturduğun anahtarı içeriyor.", "keys.snip.openMode": "API anahtarı yapılandırılmadı — ağ geçidi her değeri kabul eder.", "keys.snip.keyPh": "API anahtarın", - "settings.restart": "Ağ geçidini yeniden başlat", - "settings.restartConfirm": "Ağ geçidi yeniden başlatılsın mı? Devam eden istekler kesilir; yerel model sunucuları da yeniden başlar.", + "settings.restart": "Tümünü yeniden başlat", + "settings.restartConfirm": "Ağ geçidi ve tüm yerel model sunucuları yeniden başlatılsın mı? Devam eden istekler kesilecek.", "settings.restarting": "Yeniden başlatılıyor — ağ geçidi dönünce sayfa yenilenecek…", + "settings.shutdown": "Tümünü durdur ve kapat", + "settings.shutdownConfirm": "Tüm yerel model sunucuları durdurulup ağ geçidi kapatılsın mı? Ağ geçidini yeniden başlatana kadar API ve bu panel yanıt vermez.", + "settings.shutdownDone": "Ağ geçidi kapalı.", + "settings.shutdownHint": "Tüm yerel model sunucuları durduruldu ve süreç sonlandı. Paneli geri getirmek için ağ geçidini sunucuda yeniden başlatın.", "bench.title": "Yerel model hızı", "bench.hint": "Bu makinede `cortiq bench` çalıştırır — gerçek tok/s, aynı anda tek model.", "bench.run": "Ölç", diff --git a/web/js/i18n/zh.js b/web/js/i18n/zh.js index c9c1f08..57ff253 100644 --- a/web/js/i18n/zh.js +++ b/web/js/i18n/zh.js @@ -536,9 +536,13 @@ export default { "keys.snip.withKey": "下面的示例已包含你刚创建的密钥。", "keys.snip.openMode": "未配置 API 密钥 — 网关接受任意密钥值。", "keys.snip.keyPh": "你的 API 密钥", - "settings.restart": "重启网关", - "settings.restartConfirm": "重启网关?进行中的请求会中断;本地模型服务器也会重启。", + "settings.restart": "全部重启", + "settings.restartConfirm": "重启网关和所有本地模型服务器?正在处理的请求将被中断。", "settings.restarting": "正在重启 — 网关恢复后页面会自动刷新……", + "settings.shutdown": "全部停止并关机", + "settings.shutdownConfirm": "停止所有本地模型服务器并关闭网关?在你重新启动网关之前,API 和此面板都将无法响应。", + "settings.shutdownDone": "网关已关闭。", + "settings.shutdownHint": "所有本地模型服务器已停止,进程已退出。请在服务器上重新启动网关以恢复面板。", "bench.title": "本地模型速度", "bench.hint": "在本机运行 `cortiq bench` — 真实 tok/s,一次一个模型。", "bench.run": "测速", diff --git a/web/js/views/settings.js b/web/js/views/settings.js index 53aac06..c6a44e5 100644 --- a/web/js/views/settings.js +++ b/web/js/views/settings.js @@ -559,6 +559,31 @@ export async function renderSettings() { } }); + const stopBtn = h("button", { class: "btn danger" }, "\u23fb " + t("settings.shutdown")); + stopBtn.addEventListener("click", async () => { + if (!(await confirmDialog(t("settings.shutdownConfirm")))) return; + stopBtn.disabled = true; + restartBtn.disabled = true; + try { + await api.shutdown(); + // the server is gone in ~300ms — replace the app with a static farewell + document.body.innerHTML = ""; + document.body.appendChild( + h( + "div", + { class: "shutdown-screen" }, + h("div", { class: "shutdown-icon" }, "\u23fb"), + h("h2", {}, t("settings.shutdownDone")), + h("p", { class: "hint" }, t("settings.shutdownHint")) + ) + ); + } catch (e) { + toast(String(e.message || e), "bad"); + stopBtn.disabled = false; + restartBtn.disabled = false; + } + }); + mount( root, h( @@ -682,7 +707,8 @@ export async function renderSettings() { h("button", { class: "btn", onClick: () => fileIn.click() }, t("settings.import")), fileIn, h("div", { class: "grow" }), - restartBtn + restartBtn, + stopBtn ) ) );