From 0be4512293f9fc203d7f2190f34c7ec11efa7058 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Mon, 29 Jun 2026 20:49:41 +0300 Subject: [PATCH] Added config reload --- Cargo.toml | 2 +- src/i18n/phrases.rs | 7 ++++ src/vpn.rs | 59 ++++++++++++++++++++++++++++++++-- templates/configs.html | 73 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1d73919..c160bc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amnezia-fellow" -version = "0.1.1" +version = "0.1.2" edition = "2024" description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync" diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index badbfde..8f0a7a5 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -93,16 +93,23 @@ translations! { configs_loading_status: "Loading status..." , "Загрузка статуса..."; configs_no_pods: "No AmneziaWG pods." , "Нет подов AmneziaWG."; configs_server: "Server" , "Сервер"; + configs_endpoint: "Endpoint" , "Endpoint"; + configs_message: "Message" , "Сообщение"; configs_pod: "Pod" , "Pod"; + configs_node: "Node" , "Нода"; configs_rollout: "Rollout" , "Применение"; configs_ready: "Ready" , "Готов"; configs_not_ready: "Not ready" , "Не готов"; configs_phase: "Phase" , "Фаза"; configs_uptime: "Uptime" , "Аптайм"; configs_restarts: "Restarts" , "Рестарты"; + configs_config_applied: "Config applied" , "Конфиг применён"; + configs_reload_status: "Reload status" , "Статус reload"; + configs_config_hash: "Config hash" , "Хэш конфига"; configs_status_applied: "applied" , "применён"; configs_status_starting: "starting" , "стартует"; configs_status_pending_restart: "pending restart" , "ждёт рестарт"; + configs_status_error: "reload error" , "ошибка reload"; configs_status_unknown: "unknown" , "неизвестно"; configs_never: "n/a" , "н/д"; configs_servers: "Servers" , "Серверы"; diff --git a/src/vpn.rs b/src/vpn.rs index af31a81..cf7062b 100644 --- a/src/vpn.rs +++ b/src/vpn.rs @@ -399,6 +399,12 @@ fn qcompress(data: &[u8]) -> Result, String> { const CLIENT_SECRET_UPDATED_AT_ANNOTATION: &str = "amnezia-fellow.hexor.cy/client-secret-updated-at-ms"; +const CLIENT_SECRET_APPLIED_AT_ANNOTATION: &str = + "amnezia-fellow.hexor.cy/client-secret-applied-at-ms"; +const CLIENT_SECRET_APPLIED_HASH_ANNOTATION: &str = + "amnezia-fellow.hexor.cy/client-secret-applied-hash"; +const CLIENT_SECRET_RELOAD_STATUS_ANNOTATION: &str = + "amnezia-fellow.hexor.cy/client-secret-reload-status"; const AMNEZIAWG_POD_LABEL_SELECTOR: &str = "app=amneziawg"; #[derive(Debug, Clone, Serialize, JsonSchema)] @@ -613,6 +619,9 @@ pub struct VpnPodRolloutStatus { pub ready: bool, pub restart_count: i32, pub started_at_ms: Option, + pub config_applied_at_ms: Option, + pub config_applied_hash: Option, + pub config_reload_status: Option, pub uptime_seconds: Option, pub rollout_status: String, pub message: Option, @@ -705,6 +714,7 @@ fn pod_rollout_status( config_updated_at_ms: Option, now_ms: i64, ) -> VpnPodRolloutStatus { + let annotations = pod.metadata.annotations.clone().unwrap_or_default(); let name = pod.metadata.name.unwrap_or_default(); let node_name = pod .spec @@ -728,11 +738,26 @@ fn pod_rollout_status( let started_at_ms = status .and_then(|status| status.start_time.as_ref()) .map(time_to_millis); + let config_applied_at_ms = annotation_i64(&annotations, CLIENT_SECRET_APPLIED_AT_ANNOTATION); + let config_applied_hash = annotations + .get(CLIENT_SECRET_APPLIED_HASH_ANNOTATION) + .filter(|value| !value.is_empty()) + .cloned(); + let config_reload_status = annotations + .get(CLIENT_SECRET_RELOAD_STATUS_ANNOTATION) + .filter(|value| !value.is_empty()) + .cloned(); let uptime_seconds = started_at_ms.map(|started| { let elapsed_ms = now_ms.saturating_sub(started); (elapsed_ms / 1000) as u64 }); - let rollout_status = rollout_status(config_updated_at_ms, started_at_ms, ready); + let rollout_status = rollout_status( + config_updated_at_ms, + started_at_ms, + config_applied_at_ms, + config_reload_status.as_deref(), + ready, + ); let message = status.and_then(|status| status.message.clone().or(status.reason.clone())); VpnPodRolloutStatus { @@ -744,6 +769,9 @@ fn pod_rollout_status( ready, restart_count, started_at_ms, + config_applied_at_ms, + config_applied_hash, + config_reload_status, uptime_seconds, rollout_status, message, @@ -781,9 +809,16 @@ fn amneziawg_restart_count(status: &k8s_openapi::api::core::v1::PodStatus) -> i3 fn rollout_status( config_updated_at_ms: Option, started_at_ms: Option, + config_applied_at_ms: Option, + config_reload_status: Option<&str>, ready: bool, ) -> String { - match (config_updated_at_ms, started_at_ms) { + if config_reload_status == Some("error") { + return "error".to_owned(); + } + + let effective_applied_at_ms = started_at_ms.into_iter().chain(config_applied_at_ms).max(); + match (config_updated_at_ms, effective_applied_at_ms) { (None, _) => "unknown".to_owned(), (Some(_), None) => "pending_restart".to_owned(), (Some(updated), Some(started)) if started >= updated && ready => "applied".to_owned(), @@ -802,6 +837,10 @@ fn client_secret_updated_at_ms(secret: &Secret) -> Option { .ok() } +fn annotation_i64(annotations: &BTreeMap, key: &str) -> Option { + annotations.get(key)?.parse().ok() +} + fn time_to_millis(time: &Time) -> i64 { time.0.as_millisecond() } @@ -1037,6 +1076,22 @@ mod tests { assert!(!overrides.contains_key("empty")); } + #[test] + fn rollout_status_uses_hot_reload_annotations() { + assert_eq!( + rollout_status(Some(2_000), Some(1_000), Some(3_000), Some("applied"), true), + "applied" + ); + assert_eq!( + rollout_status(Some(2_000), Some(1_000), None, Some("error"), true), + "error" + ); + assert_eq!( + rollout_status(Some(2_000), Some(1_000), None, None, true), + "pending_restart" + ); + } + #[test] fn qcompress_uses_qt_wire_format() { let payload = br#"{"description":"de-fsn1"}"#; diff --git a/templates/configs.html b/templates/configs.html index 53b5381..9e77d5c 100644 --- a/templates/configs.html +++ b/templates/configs.html @@ -45,24 +45,28 @@ .rollout { margin: 1rem 0 1.25rem; } .rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; } .rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; } - .rollout-tile { position: relative; min-height: 86px; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .65rem .7rem; display: grid; gap: .45rem; align-content: space-between; } + .rollout-tile { position: relative; min-height: 86px; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .65rem .7rem; display: grid; gap: .45rem; align-content: space-between; cursor: pointer; } + .rollout-tile:focus { outline: 2px solid #7aa7c7; outline-offset: 2px; } .rollout-tile::after { content: attr(data-tooltip); position: absolute; left: .25rem; top: calc(100% + .45rem); z-index: 5; width: max-content; max-width: min(380px, 80vw); padding: .55rem .65rem; border-radius: 4px; background: #17202a; color: #fff; font-size: .78rem; line-height: 1.35; white-space: pre-line; box-shadow: 0 8px 24px rgba(23, 32, 42, .22); opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .12s ease, transform .12s ease; } .rollout-tile:hover::after, .rollout-tile:focus-within::after { opacity: 1; transform: translateY(0); } .rollout-tile-applied { border-left-color: #2f8f4e; } .rollout-tile-starting { border-left-color: #d39a00; } .rollout-tile-pending_restart { border-left-color: #c83f31; } + .rollout-tile-error { border-left-color: #c83f31; } .rollout-tile-unknown { border-left-color: #7b8793; } .rollout-tile-head { display: flex; align-items: center; min-width: 0; gap: .45rem; } .rollout-dot { width: .55rem; height: .55rem; border-radius: 999px; flex: 0 0 auto; background: #7b8793; } .rollout-dot-applied { background: #2f8f4e; } .rollout-dot-starting { background: #d39a00; } .rollout-dot-pending_restart { background: #c83f31; } + .rollout-dot-error { background: #c83f31; } .rollout-dot-unknown { background: #7b8793; } .rollout-server { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: #1d252d; } .rollout-badge { display: inline-flex; align-items: center; min-height: 24px; padding: .15rem .45rem; border-radius: 4px; font-size: .82rem; font-weight: 700; white-space: nowrap; } .rollout-applied { background: #dcefe4; color: #175c31; } .rollout-starting { background: #fff0c2; color: #6f4e00; } .rollout-pending_restart { background: #ffe3df; color: #8b2116; } + .rollout-error { background: #ffe3df; color: #8b2116; } .rollout-unknown { background: #e8ecef; color: #53606d; } .rollout-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #53606d; font-size: .82rem; } .server-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; } @@ -80,6 +84,11 @@ .server-card { border: 1px solid #dde2e6; border-radius: 6px; padding: .7rem; display: grid; grid-template-columns: minmax(160px, 1fr) auto; gap: .65rem; align-items: center; } .server-card-main { display: grid; gap: .25rem; min-width: 0; } .server-card-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; } + .detail-list { display: grid; gap: .55rem; overflow: auto; } + .detail-row { display: grid; grid-template-columns: minmax(120px, .45fr) minmax(0, 1fr); gap: .75rem; align-items: start; padding: .55rem 0; border-bottom: 1px solid #edf0f2; } + .detail-row:last-child { border-bottom: 0; } + .detail-label { color: #53606d; font-size: .84rem; } + .detail-value { color: #1d252d; overflow-wrap: anywhere; } .qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; } .qr-box svg { width: min(280px, 100%); height: auto; display: block; } .modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; } @@ -98,6 +107,7 @@ .server-card { grid-template-columns: 1fr; } .server-card-actions { justify-content: stretch; } .server-card-actions button { flex: 1 1 100%; } + .detail-row { grid-template-columns: 1fr; gap: .2rem; } .rollout-grid { grid-template-columns: 1fr; } } @@ -161,7 +171,7 @@