This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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" , "Серверы";
|
||||
|
||||
+57
-2
@@ -399,6 +399,12 @@ fn qcompress(data: &[u8]) -> Result<Vec<u8>, 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<i64>,
|
||||
pub config_applied_at_ms: Option<i64>,
|
||||
pub config_applied_hash: Option<String>,
|
||||
pub config_reload_status: Option<String>,
|
||||
pub uptime_seconds: Option<u64>,
|
||||
pub rollout_status: String,
|
||||
pub message: Option<String>,
|
||||
@@ -705,6 +714,7 @@ fn pod_rollout_status(
|
||||
config_updated_at_ms: Option<i64>,
|
||||
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<i64>,
|
||||
started_at_ms: Option<i64>,
|
||||
config_applied_at_ms: Option<i64>,
|
||||
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<i64> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn annotation_i64(annotations: &BTreeMap<String, String>, key: &str) -> Option<i64> {
|
||||
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"}"#;
|
||||
|
||||
+68
-5
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
@@ -161,7 +171,7 @@
|
||||
<template x-if="rollout && rollout.pods.length > 0">
|
||||
<div class="rollout-grid">
|
||||
<template x-for="pod in rollout.pods" :key="pod.name">
|
||||
<div class="rollout-tile" :class="`rollout-tile-${pod.rollout_status}`" :data-tooltip="podTooltip(pod)" :title="podTooltip(pod)" tabindex="0">
|
||||
<div class="rollout-tile" :class="`rollout-tile-${pod.rollout_status}`" :data-tooltip="podTooltip(pod)" :title="podTooltip(pod)" tabindex="0" role="button" @click="openRollout(pod)" @keydown.enter.prevent="openRollout(pod)" @keydown.space.prevent="openRollout(pod)">
|
||||
<div class="rollout-tile-head">
|
||||
<span class="rollout-dot" :class="`rollout-dot-${pod.rollout_status}`"></span>
|
||||
<span class="rollout-server" x-text="pod.endpoint_name || pod.node_name || pod.name"></span>
|
||||
@@ -230,6 +240,37 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template x-if="rolloutModal">
|
||||
<div class="modal-backdrop" @click.self="closeRollout()">
|
||||
<div class="modal servers-modal">
|
||||
<div class="modal-head">
|
||||
<div class="modal-title">
|
||||
<h3 x-text="podServerName(rolloutModal)"></h3>
|
||||
<div class="modal-subtitle" x-text="rolloutModal.endpoint || rolloutModal.node_name || rolloutModal.name"></div>
|
||||
</div>
|
||||
<button class="secondary" @click="closeRollout()">{{ t.admin_close }}</button>
|
||||
</div>
|
||||
<div class="detail-list">
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_rollout }}</span><span class="detail-value"><span class="rollout-badge" :class="`rollout-${rolloutModal.rollout_status}`" x-text="rolloutStatusLabel(rolloutModal.rollout_status)"></span></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_pod }}</span><span class="detail-value"><code x-text="rolloutModal.name"></code></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_node }}</span><span class="detail-value"><code x-text="rolloutModal.node_name || '{{ t.configs_never }}'"></code></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_endpoint }}</span><span class="detail-value"><code x-text="rolloutModal.endpoint || '{{ t.configs_never }}'"></code></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_ready }}</span><span class="detail-value" x-text="rolloutModal.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_phase }}</span><span class="detail-value" x-text="rolloutModal.phase || '{{ t.configs_status_unknown }}'"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_uptime }}</span><span class="detail-value" x-text="formatDuration(rolloutModal.uptime_seconds)"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_restarts }}</span><span class="detail-value" x-text="rolloutModal.restart_count"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_config_updated }}</span><span class="detail-value" x-text="formatTime(rollout && rollout.config_updated_at_ms)"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_config_applied }}</span><span class="detail-value" x-text="formatTime(rolloutModal.config_applied_at_ms)"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_reload_status }}</span><span class="detail-value" x-text="reloadStatusLabel(rolloutModal.config_reload_status)"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_config_hash }}</span><span class="detail-value"><code x-text="shortHash(rolloutModal.config_applied_hash) || '{{ t.configs_never }}'"></code></span></div>
|
||||
<template x-if="rolloutModal.message">
|
||||
<div class="detail-row"><span class="detail-label">{{ t.configs_message }}</span><span class="detail-value" x-text="rolloutModal.message"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="serverModal">
|
||||
<div class="modal-backdrop" @click.self="closeServers()">
|
||||
<div class="modal servers-modal">
|
||||
@@ -294,6 +335,7 @@ function configsPage() {
|
||||
rolloutBusy: false,
|
||||
rolloutError: '',
|
||||
rolloutTimer: null,
|
||||
rolloutModal: null,
|
||||
serverConfigs: {},
|
||||
serverModal: null,
|
||||
qrModal: null,
|
||||
@@ -514,6 +556,12 @@ function configsPage() {
|
||||
this.rolloutBusy = false;
|
||||
}
|
||||
},
|
||||
openRollout(pod) {
|
||||
this.rolloutModal = pod;
|
||||
},
|
||||
closeRollout() {
|
||||
this.rolloutModal = null;
|
||||
},
|
||||
clientServers(client) {
|
||||
return (this.serverConfigs[client.id] && this.serverConfigs[client.id].servers) || [];
|
||||
},
|
||||
@@ -542,6 +590,11 @@ function configsPage() {
|
||||
if (!key || key.length <= 16) return key || '';
|
||||
return `${key.slice(0, 8)}...${key.slice(-8)}`;
|
||||
},
|
||||
shortHash(hash) {
|
||||
if (!hash) return '';
|
||||
if (hash.length <= 16) return hash;
|
||||
return `${hash.slice(0, 12)}...${hash.slice(-8)}`;
|
||||
},
|
||||
fileSlug(value) {
|
||||
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
|
||||
},
|
||||
@@ -550,22 +603,32 @@ function configsPage() {
|
||||
applied: '{{ t.configs_status_applied }}',
|
||||
starting: '{{ t.configs_status_starting }}',
|
||||
pending_restart: '{{ t.configs_status_pending_restart }}',
|
||||
error: '{{ t.configs_status_error }}',
|
||||
unknown: '{{ t.configs_status_unknown }}',
|
||||
};
|
||||
return labels[value] || value || '{{ t.configs_status_unknown }}';
|
||||
},
|
||||
reloadStatusLabel(value) {
|
||||
if (!value) return '{{ t.configs_never }}';
|
||||
return this.rolloutStatusLabel(value);
|
||||
},
|
||||
podServerName(pod) {
|
||||
return pod.endpoint_name || pod.node_name || pod.name || '{{ t.configs_status_unknown }}';
|
||||
},
|
||||
podTooltip(pod) {
|
||||
return [
|
||||
`{{ t.configs_server }}: ${pod.endpoint_name || pod.node_name || pod.name}`,
|
||||
`{{ t.configs_server }}: ${this.podServerName(pod)}`,
|
||||
`{{ t.configs_pod }}: ${pod.name}`,
|
||||
pod.endpoint ? `Endpoint: ${pod.endpoint}` : null,
|
||||
pod.endpoint ? `{{ t.configs_endpoint }}: ${pod.endpoint}` : null,
|
||||
`{{ t.configs_rollout }}: ${this.rolloutStatusLabel(pod.rollout_status)}`,
|
||||
`{{ t.configs_ready }}: ${pod.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'}`,
|
||||
`{{ t.configs_phase }}: ${pod.phase || '{{ t.configs_status_unknown }}'}`,
|
||||
`{{ t.configs_uptime }}: ${this.formatDuration(pod.uptime_seconds)}`,
|
||||
`{{ t.configs_restarts }}: ${pod.restart_count}`,
|
||||
`{{ t.configs_config_updated }}: ${this.formatTime(this.rollout && this.rollout.config_updated_at_ms)}`,
|
||||
pod.message ? `Message: ${pod.message}` : null,
|
||||
`{{ t.configs_config_applied }}: ${this.formatTime(pod.config_applied_at_ms)}`,
|
||||
pod.config_reload_status ? `{{ t.configs_reload_status }}: ${this.reloadStatusLabel(pod.config_reload_status)}` : null,
|
||||
pod.message ? `{{ t.configs_message }}: ${pod.message}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
},
|
||||
formatTime(ms) {
|
||||
|
||||
Reference in New Issue
Block a user