Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3def3afeda | ||
|
|
c3db347da6 | ||
|
|
266b493966 |
Generated
+1
-1
@@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "amnezia-fellow"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cot",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "amnezia-fellow"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync"
|
||||
|
||||
|
||||
+123
-24
@@ -22,7 +22,28 @@ use crate::{auth, vpn};
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||
let body = serde_json::json!({ "error": message });
|
||||
json_error_typed(
|
||||
status,
|
||||
status.canonical_reason().unwrap_or("request_failed"),
|
||||
status.canonical_reason().unwrap_or("Request failed"),
|
||||
message,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
fn json_error_typed(
|
||||
status: cot::http::StatusCode,
|
||||
code: &str,
|
||||
title: &str,
|
||||
message: &str,
|
||||
detail: &str,
|
||||
) -> cot::response::Response {
|
||||
let body = serde_json::json!({
|
||||
"code": code,
|
||||
"title": title,
|
||||
"error": message,
|
||||
"detail": detail,
|
||||
});
|
||||
cot::http::Response::builder()
|
||||
.status(status)
|
||||
.header(cot::http::header::CONTENT_TYPE, "application/json")
|
||||
@@ -76,6 +97,7 @@ struct CreateVpnClientRequest {
|
||||
struct MutateVpnClientResponse {
|
||||
client: vpn::VpnClientView,
|
||||
sync: vpn::SecretSyncResult,
|
||||
notice: Option<ApiNotice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -86,6 +108,16 @@ struct SetEnabledRequest {
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct DeleteVpnClientResponse {
|
||||
sync: vpn::SecretSyncResult,
|
||||
notice: Option<ApiNotice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
struct ApiNotice {
|
||||
kind: String,
|
||||
code: String,
|
||||
title: String,
|
||||
message: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -154,9 +186,22 @@ async fn vpn_status_handler(
|
||||
tracing::debug!(user_id = user.id, "VPN rollout status requested");
|
||||
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let status = vpn::read_rollout_status_from_kubernetes(&config)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to read VPN rollout status: {e}")))?;
|
||||
let mut status = match vpn::read_rollout_status_from_kubernetes(&config).await {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
return Ok(json_error_typed(
|
||||
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"vpn_status_unavailable",
|
||||
"Server status unavailable",
|
||||
"Could not load VPN server status.",
|
||||
&e,
|
||||
));
|
||||
}
|
||||
};
|
||||
if user.role != auth::Role::Admin {
|
||||
let disabled = vpn::disabled_endpoint_names(&config);
|
||||
status.pods.retain(|pod| !disabled.contains(&pod.node_name));
|
||||
}
|
||||
|
||||
Json(status).into_response()
|
||||
}
|
||||
@@ -177,18 +222,32 @@ async fn create_vpn_client_handler(
|
||||
};
|
||||
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let client =
|
||||
vpn::VpnClient::create_for_owner(&db, user.id, &request.name, &config.vpn_client_cidr)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create client: {e}")))?;
|
||||
let sync = vpn::sync_from_database(&db, &config)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
||||
let client = match vpn::VpnClient::create_for_owner(
|
||||
&db,
|
||||
user.id,
|
||||
&request.name,
|
||||
&config.vpn_client_cidr,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
return Ok(json_error_typed(
|
||||
cot::http::StatusCode::BAD_REQUEST,
|
||||
"client_create_failed",
|
||||
"Could not create key",
|
||||
"The key was not created.",
|
||||
&e.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
||||
|
||||
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
||||
Json(MutateVpnClientResponse {
|
||||
client: client_view_with_owner(client, &owner_map),
|
||||
sync,
|
||||
notice,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -221,14 +280,13 @@ async fn set_vpn_client_enabled_handler(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?;
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let sync = vpn::sync_from_database(&db, &config)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
||||
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
||||
|
||||
let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?;
|
||||
Json(MutateVpnClientResponse {
|
||||
client: client_view_with_owner(client, &owner_map),
|
||||
sync,
|
||||
notice,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -259,11 +317,9 @@ async fn delete_vpn_client_handler(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?;
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let sync = vpn::sync_from_database(&db, &config)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
||||
let (sync, notice) = sync_after_client_mutation(&db, &config).await;
|
||||
|
||||
Json(DeleteVpnClientResponse { sync }).into_response()
|
||||
Json(DeleteVpnClientResponse { sync, notice }).into_response()
|
||||
}
|
||||
|
||||
async fn vpn_client_config_handler(
|
||||
@@ -289,9 +345,18 @@ async fn vpn_client_config_handler(
|
||||
};
|
||||
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let runtime = vpn::read_runtime_from_kubernetes(&config)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to read VPN runtime: {e}")))?;
|
||||
let runtime = match vpn::read_runtime_from_kubernetes(&config).await {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
return Ok(json_error_typed(
|
||||
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"vpn_runtime_unavailable",
|
||||
"VPN servers are unavailable",
|
||||
"Could not load VPN server list.",
|
||||
&e,
|
||||
));
|
||||
}
|
||||
};
|
||||
let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config);
|
||||
if endpoints.is_empty() {
|
||||
return Ok(json_error(
|
||||
@@ -381,6 +446,31 @@ fn client_view_with_owner(
|
||||
view
|
||||
}
|
||||
|
||||
async fn sync_after_client_mutation(
|
||||
db: &Database,
|
||||
config: &AppConfig,
|
||||
) -> (vpn::SecretSyncResult, Option<ApiNotice>) {
|
||||
match vpn::sync_from_database(db, config).await {
|
||||
Ok(sync) => (sync, None),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "client data changed but Secret sync failed");
|
||||
(
|
||||
vpn::SecretSyncResult {
|
||||
changed: false,
|
||||
message: "client data saved; Secret sync failed".to_owned(),
|
||||
},
|
||||
Some(ApiNotice {
|
||||
kind: "warning".to_owned(),
|
||||
code: "secret_sync_failed".to_owned(),
|
||||
title: "Saved locally".to_owned(),
|
||||
message: "The key list was updated, but VPN servers did not receive the new config yet.".to_owned(),
|
||||
detail: e,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_qr_svg(value: &str) -> cot::Result<String> {
|
||||
let code = QrCode::new(value.as_bytes())
|
||||
.map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?;
|
||||
@@ -411,9 +501,18 @@ async fn sync_vpn_clients_handler(
|
||||
);
|
||||
|
||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||
let sync = vpn::sync_from_database(&db, &config)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?;
|
||||
let sync = match vpn::sync_from_database(&db, &config).await {
|
||||
Ok(sync) => sync,
|
||||
Err(e) => {
|
||||
return Ok(json_error_typed(
|
||||
cot::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"secret_sync_failed",
|
||||
"Secret sync failed",
|
||||
"Could not apply client config to VPN servers.",
|
||||
&e,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Json(sync).into_response()
|
||||
}
|
||||
|
||||
+31
-1
@@ -88,6 +88,7 @@ translations! {
|
||||
configs_create: "Create" , "Создать";
|
||||
configs_sync: "Sync Secret" , "Синхронизировать Secret";
|
||||
configs_rollout_heading: "Apply status" , "Статус применения";
|
||||
configs_server_status: "Server status" , "Статус серверов";
|
||||
configs_refresh: "Refresh" , "Обновить";
|
||||
configs_config_updated: "Config updated" , "Конфиг обновлён";
|
||||
configs_loading_status: "Loading status..." , "Загрузка статуса...";
|
||||
@@ -108,9 +109,12 @@ translations! {
|
||||
configs_config_hash: "Config hash" , "Хэш конфига";
|
||||
configs_status_applied: "applied" , "применён";
|
||||
configs_status_starting: "starting" , "стартует";
|
||||
configs_status_pending_restart: "pending restart" , "ждёт рестарт";
|
||||
configs_status_pending_apply: "pending apply" , "ждёт применения";
|
||||
configs_status_error: "reload error" , "ошибка reload";
|
||||
configs_status_unknown: "unknown" , "неизвестно";
|
||||
configs_status_online: "online" , "онлайн";
|
||||
configs_status_offline: "offline" , "оффлайн";
|
||||
configs_status_unavailable: "status unavailable" , "статус недоступен";
|
||||
configs_never: "n/a" , "н/д";
|
||||
configs_servers: "Servers" , "Серверы";
|
||||
configs_loading_servers: "Loading servers..." , "Загрузка серверов...";
|
||||
@@ -125,6 +129,32 @@ translations! {
|
||||
configs_no: "no" , "нет";
|
||||
configs_empty: "No configs yet." , "Конфигов пока нет.";
|
||||
configs_name_placeholder: "Client name" , "Имя клиента";
|
||||
configs_portal_kicker: "Client portal" , "Клиентский портал";
|
||||
configs_portal_heading: "My VPN keys" , "Мои VPN-ключи";
|
||||
configs_key_status: "Key status" , "Статус ключей";
|
||||
configs_active_keys: "Active" , "Активные";
|
||||
configs_total_keys: "Total" , "Всего";
|
||||
configs_new_key: "New key" , "Новый ключ";
|
||||
configs_empty_title: "Create your first key" , "Создайте первый ключ";
|
||||
configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь.";
|
||||
configs_enabled_state: "active" , "активен";
|
||||
configs_disabled_state: "disabled" , "отключён";
|
||||
configs_copy_link: "Copy" , "Копировать";
|
||||
configs_qr_code: "QR code" , "QR-код";
|
||||
configs_choose_server: "Choose server" , "Выбрать сервер";
|
||||
configs_updated: "Updated" , "Обновлён";
|
||||
configs_key_ready: "Ready" , "Готов";
|
||||
notice_success_title: "Done" , "Готово";
|
||||
notice_warning_title: "Needs attention" , "Нужно внимание";
|
||||
notice_error_title: "Something went wrong" , "Что-то сломалось";
|
||||
notice_sync_warning_title: "Saved, but not applied" , "Сохранено, но не применено";
|
||||
notice_sync_warning_message: "The key list changed, but VPN servers did not receive the new config yet." , "Список ключей изменён, но VPN-серверы пока не получили новый конфиг.";
|
||||
notice_create_success: "Key created." , "Ключ создан.";
|
||||
notice_update_success: "Key updated." , "Ключ обновлён.";
|
||||
notice_delete_success: "Key deleted." , "Ключ удалён.";
|
||||
notice_server_list_error_title: "Server list unavailable" , "Список серверов недоступен";
|
||||
notice_server_list_error_message: "Could not load VPN servers." , "Не удалось загрузить VPN-серверы.";
|
||||
notice_detail: "Detail" , "Детали";
|
||||
|
||||
// VPN server management
|
||||
servers_empty: "No registered servers." , "Нет зарегистрированных серверов.";
|
||||
|
||||
+31
-4
@@ -55,6 +55,15 @@ struct ConfigsTemplate {
|
||||
app_version: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "client_portal.html")]
|
||||
struct ClientPortalTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
app_version: &'static str,
|
||||
}
|
||||
|
||||
async fn configs_page(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -64,12 +73,30 @@ async fn configs_page(
|
||||
Ok(user) => user,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let is_admin = user.role == auth::Role::Admin;
|
||||
let user_name = user.name;
|
||||
let user_role = user.role.code().to_owned();
|
||||
|
||||
if is_admin {
|
||||
return Html::new(
|
||||
ConfigsTemplate {
|
||||
t: i18n.t,
|
||||
user_name,
|
||||
user_role,
|
||||
is_admin,
|
||||
app_version: env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
.render()?,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
Html::new(
|
||||
ConfigsTemplate {
|
||||
ClientPortalTemplate {
|
||||
t: i18n.t,
|
||||
user_name: user.name,
|
||||
user_role: user.role.code().to_owned(),
|
||||
is_admin: user.role == auth::Role::Admin,
|
||||
user_name,
|
||||
user_role,
|
||||
app_version: env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
.render()?,
|
||||
|
||||
+55
-10
@@ -95,15 +95,11 @@ impl VpnClient {
|
||||
.await
|
||||
.map_err(db_custom_error)?;
|
||||
let now = now_timestamp();
|
||||
let name = if name.trim().is_empty() {
|
||||
"Amnezia client"
|
||||
} else {
|
||||
name.trim()
|
||||
};
|
||||
let name = client_name_for_storage(name)?;
|
||||
let mut client = Self {
|
||||
id: Auto::auto(),
|
||||
owner_user_id,
|
||||
name: LimitedString::new(name).unwrap(),
|
||||
name,
|
||||
address: LimitedString::new(address.as_str()).unwrap(),
|
||||
public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(),
|
||||
private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(),
|
||||
@@ -207,7 +203,7 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String {
|
||||
"# id={} owner={} name={}\n",
|
||||
client.id_val(),
|
||||
client.owner_user_id(),
|
||||
client.name_str()
|
||||
escaped_peer_comment_value(client.name_str())
|
||||
));
|
||||
out.push_str(&format!("PublicKey = {}\n", client.public_key_str()));
|
||||
out.push_str(&format!("AllowedIPs = {}/32\n", client.address_str()));
|
||||
@@ -215,6 +211,26 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn client_name_for_storage(name: &str) -> cot::db::Result<LimitedString<255>> {
|
||||
let name = if name.is_empty() {
|
||||
"Amnezia client"
|
||||
} else {
|
||||
name
|
||||
};
|
||||
LimitedString::new(name.to_owned())
|
||||
.map_err(|e| db_custom_error(format!("client name is too long: {e}")))
|
||||
}
|
||||
|
||||
fn escaped_peer_comment_value(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len() + 2);
|
||||
escaped.push('"');
|
||||
for ch in value.chars() {
|
||||
escaped.extend(ch.escape_default());
|
||||
}
|
||||
escaped.push('"');
|
||||
escaped
|
||||
}
|
||||
|
||||
pub fn render_client_config(
|
||||
client: &VpnClient,
|
||||
server_public_key: &str,
|
||||
@@ -820,10 +836,10 @@ fn rollout_status(
|
||||
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(_), None) => "pending_apply".to_owned(),
|
||||
(Some(updated), Some(started)) if started >= updated && ready => "applied".to_owned(),
|
||||
(Some(updated), Some(started)) if started >= updated => "starting".to_owned(),
|
||||
(Some(_), Some(_)) => "pending_restart".to_owned(),
|
||||
(Some(_), Some(_)) => "pending_apply".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1062,6 +1078,35 @@ mod tests {
|
||||
assert_eq!(vpn_url_description(" ", "phone"), "phone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_name_for_storage_preserves_weird_names() {
|
||||
let raw = r#" витя хуй !! "" 233; 3№№## ''' @ "#;
|
||||
let stored = client_name_for_storage(raw).unwrap();
|
||||
assert_eq!(stored.to_string(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_secret_escapes_client_name_comment() {
|
||||
let raw = "витя хуй !! \"\" 233; 3№№## ''' @\nAllowedIPs = 0.0.0.0/0";
|
||||
let client = VpnClient {
|
||||
id: Auto::Fixed(7),
|
||||
owner_user_id: 1,
|
||||
name: LimitedString::new(raw).unwrap(),
|
||||
address: LimitedString::new("10.8.0.2").unwrap(),
|
||||
public_key: LimitedString::new("client-public-key").unwrap(),
|
||||
private_key: LimitedString::new("client-private-key").unwrap(),
|
||||
enabled: true,
|
||||
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
|
||||
};
|
||||
|
||||
let rendered = render_peer_secret(&[client]);
|
||||
assert!(rendered.contains(r#"name="\u{432}\u{438}\u{442}\u{44f} "#));
|
||||
assert!(rendered.contains(r#"\nAllowedIPs = 0.0.0.0/0""#));
|
||||
assert!(!rendered.contains("\nAllowedIPs = 0.0.0.0/0\n"));
|
||||
assert_eq!(rendered.matches("[Peer]").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_name_overrides_parse_json_map() {
|
||||
let config = AppConfig {
|
||||
@@ -1088,7 +1133,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
rollout_status(Some(2_000), Some(1_000), None, None, true),
|
||||
"pending_restart"
|
||||
"pending_apply"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.configs_portal_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
[x-cloak] { display: none !important; }
|
||||
body { margin: 0; min-height: 100vh; background: #edf3ef; color: #18232d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
button, input { font: inherit; }
|
||||
button { min-height: 40px; border: 1px solid #18342f; border-radius: 8px; padding: .55rem .85rem; background: #18342f; color: #fff; cursor: pointer; font-weight: 750; }
|
||||
button.secondary { background: #fff; color: #18342f; border-color: #c7d4ce; }
|
||||
button.ghost { background: transparent; color: #42515d; border-color: transparent; }
|
||||
button.danger { background: #a8312d; border-color: #a8312d; color: #fff; }
|
||||
button:disabled { opacity: .48; cursor: default; }
|
||||
input { width: 100%; min-height: 42px; border: 1px solid #c7d4ce; border-radius: 8px; padding: .55rem .7rem; color: #18232d; background: #fff; }
|
||||
code { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px; background: #f1f4f2; color: #26313a; padding: .18rem .4rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .82rem; }
|
||||
.portal { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,.94); border-bottom: 1px solid #d6dfda; backdrop-filter: blur(12px); }
|
||||
.topbar-inner { width: min(1040px, 100%); margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: .85rem; padding: .75rem 1rem; }
|
||||
.brand { display: flex; align-items: center; gap: .65rem; min-width: 0; color: inherit; text-decoration: none; }
|
||||
.brand-mark { width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center; background: #18342f; color: #cce8da; font-weight: 900; }
|
||||
.brand-text { display: grid; gap: .05rem; min-width: 0; }
|
||||
.brand-title { font-weight: 850; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.brand-version { color: #7a858d; font-size: .75rem; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: .35rem; flex-wrap: wrap; }
|
||||
.user-pill { color: #53616c; font-size: .86rem; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lang-switch { display: inline-flex; align-items: center; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; overflow: hidden; }
|
||||
.lang-switch a { min-width: 38px; padding: .38rem .5rem; color: #53616c; text-align: center; text-decoration: none; font-size: .82rem; font-weight: 800; }
|
||||
.lang-switch a.active { background: #18342f; color: #fff; }
|
||||
.logout-link { color: #53616c; text-decoration: none; font-size: .88rem; font-weight: 750; padding: .45rem .55rem; border-radius: 8px; }
|
||||
.logout-link:hover { background: #e4ebe7; color: #18232d; }
|
||||
.page { width: min(1040px, 100%); margin: 0 auto; padding: 1rem; display: grid; gap: 1rem; }
|
||||
.panel { border: 1px solid #d6dfda; border-radius: 8px; background: #fff; box-shadow: 0 10px 30px rgba(24, 52, 47, .08); }
|
||||
.intro { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 1rem; align-items: end; padding: 1rem; }
|
||||
.eyebrow { margin: 0 0 .3rem; color: #5b6b64; font-size: .78rem; font-weight: 850; letter-spacing: 0; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: 2rem; line-height: 1.08; letter-spacing: 0; }
|
||||
.counters { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.counter { min-width: 92px; border: 1px solid #dce5e0; border-radius: 8px; padding: .55rem .65rem; background: #f7faf8; display: grid; gap: .1rem; }
|
||||
.counter strong { font-size: 1.25rem; line-height: 1; }
|
||||
.counter span { color: #69777f; font-size: .78rem; font-weight: 750; }
|
||||
.portal-server-status { display: grid; gap: .45rem; padding: 0 1rem 1rem; }
|
||||
.portal-server-head { display: flex; align-items: center; justify-content: space-between; gap: .65rem; color: #34424b; font-size: .83rem; font-weight: 850; }
|
||||
.mini-button { min-height: 28px; border-radius: 7px; padding: .2rem .5rem; background: #fff; color: #18342f; border-color: #c7d4ce; font-size: .76rem; }
|
||||
.server-status-grid { max-height: 138px; overflow: auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); gap: .4rem; padding-right: .12rem; }
|
||||
.server-status-chip { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .42rem .5rem; align-items: center; border: 1px solid #dce5e0; border-radius: 8px; background: #f7faf8; padding: .45rem .52rem; }
|
||||
.server-status-dot { width: .58rem; height: .58rem; border-radius: 999px; background: #8a969e; }
|
||||
.server-status-chip.tone-online .server-status-dot { background: #2f8f4e; }
|
||||
.server-status-chip.tone-warning .server-status-dot { background: #d39a00; }
|
||||
.server-status-chip.tone-error .server-status-dot { background: #a8312d; }
|
||||
.server-status-chip.tone-offline .server-status-dot { background: #69777f; }
|
||||
.server-status-text { min-width: 0; display: grid; gap: .08rem; }
|
||||
.server-status-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #18232d; font-size: .86rem; font-weight: 850; }
|
||||
.server-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #69777f; font-size: .74rem; font-weight: 750; }
|
||||
.server-status-muted { color: #69777f; font-size: .82rem; }
|
||||
.create { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .65rem; padding: 0 1rem 1rem; align-items: end; }
|
||||
.field { display: grid; gap: .32rem; min-width: 0; }
|
||||
.field label { color: #34424b; font-size: .83rem; font-weight: 800; }
|
||||
.notice { border: 1px solid #d6dfda; border-left-width: 4px; border-radius: 8px; background: #fff; padding: .75rem .85rem; display: grid; gap: .25rem; box-shadow: 0 8px 24px rgba(24, 52, 47, .06); }
|
||||
.notice strong { color: #18232d; }
|
||||
.notice span { color: #53616c; font-size: .9rem; }
|
||||
.notice details { color: #69777f; font-size: .82rem; }
|
||||
.notice summary { cursor: pointer; font-weight: 750; }
|
||||
.notice.tone-success { border-left-color: #2f8f4e; }
|
||||
.notice.tone-warning { border-left-color: #d39a00; }
|
||||
.notice.tone-error { border-left-color: #a8312d; }
|
||||
.empty { padding: 1rem; color: #53616c; }
|
||||
.empty strong { display: block; margin-bottom: .25rem; color: #18232d; }
|
||||
.key-list { display: grid; gap: .75rem; }
|
||||
.key-card { border: 1px solid #d6dfda; border-radius: 8px; background: #fff; padding: .85rem; display: grid; gap: .75rem; }
|
||||
.key-card.is-disabled { background: #fafafa; }
|
||||
.key-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; min-width: 0; }
|
||||
.key-title { min-width: 0; display: grid; gap: .32rem; }
|
||||
.key-name { margin: 0; color: #18232d; font-size: 1.08rem; line-height: 1.2; overflow-wrap: anywhere; }
|
||||
.status-chip { width: max-content; max-width: 100%; display: inline-flex; align-items: center; min-height: 24px; border-radius: 999px; padding: .15rem .5rem; font-size: .78rem; font-weight: 850; }
|
||||
.status-chip.enabled { color: #155d36; background: #dcefe4; }
|
||||
.status-chip.disabled { color: #69777f; background: #e8eeeb; }
|
||||
.meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
|
||||
.meta-item { min-width: 0; display: grid; gap: .24rem; }
|
||||
.meta-label { color: #69777f; font-size: .77rem; font-weight: 800; }
|
||||
.key-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; }
|
||||
.key-actions .server-button { justify-self: stretch; }
|
||||
.key-updated { color: #69777f; font-size: .82rem; overflow-wrap: anywhere; }
|
||||
.backdrop { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 1rem; background: rgba(16, 28, 36, .42); }
|
||||
.sheet { width: min(720px, 100%); max-height: min(760px, calc(100vh - 2rem)); border: 1px solid #d6dfda; border-radius: 8px; background: #fff; box-shadow: 0 20px 60px rgba(16, 28, 36, .25); padding: 1rem; display: flex; flex-direction: column; gap: .8rem; }
|
||||
.sheet-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .8rem; }
|
||||
.sheet-title { min-width: 0; display: grid; gap: .2rem; }
|
||||
.sheet-title h2 { margin: 0; font-size: 1.18rem; line-height: 1.2; }
|
||||
.sheet-title span { color: #69777f; font-size: .86rem; overflow-wrap: anywhere; }
|
||||
.server-list { border: 1px solid #d6dfda; border-radius: 8px; overflow: auto; }
|
||||
.server-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .65rem; align-items: center; padding: .62rem .7rem; border-bottom: 1px solid #e7eee9; background: #fff; }
|
||||
.server-row:last-child { border-bottom: 0; }
|
||||
.server-row:hover { background: #f7faf8; }
|
||||
.server-main { min-width: 0; display: grid; gap: .28rem; }
|
||||
.server-main strong { overflow-wrap: anywhere; }
|
||||
.server-actions { display: flex; align-items: center; gap: .32rem; }
|
||||
.icon-button { position: relative; width: 36px; min-width: 36px; height: 36px; min-height: 36px; padding: 0; display: grid; place-items: center; border-radius: 8px; }
|
||||
.icon-button svg { width: 17px; height: 17px; display: block; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.icon-button .qr-mark { font-size: .72rem; line-height: 1; font-weight: 900; letter-spacing: 0; }
|
||||
.icon-button::after { content: attr(data-tip); position: absolute; right: calc(100% + .35rem); top: 50%; z-index: 4; width: max-content; max-width: 180px; padding: .38rem .5rem; border-radius: 6px; background: #18232d; color: #fff; font-size: .76rem; line-height: 1.2; font-weight: 750; opacity: 0; transform: translateY(-50%); pointer-events: none; transition: opacity .12s ease; }
|
||||
.icon-button:hover::after, .icon-button:focus-visible::after { opacity: 1; }
|
||||
.qr-modal { width: min(380px, 100%); border: 1px solid #d6dfda; border-radius: 8px; background: #fff; padding: 1rem; box-shadow: 0 20px 60px rgba(16, 28, 36, .25); display: grid; gap: .8rem; }
|
||||
.qr-modal h2 { margin: 0; font-size: 1.08rem; overflow-wrap: anywhere; }
|
||||
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #d6dfda; border-radius: 8px; background: #fff; }
|
||||
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; }
|
||||
@media (max-width: 720px) {
|
||||
.topbar-inner { align-items: flex-start; flex-direction: column; }
|
||||
.top-actions { width: 100%; justify-content: space-between; }
|
||||
.user-pill { max-width: 100%; }
|
||||
.intro { grid-template-columns: 1fr; align-items: start; }
|
||||
h1 { font-size: 1.55rem; }
|
||||
.counters { justify-content: stretch; }
|
||||
.counter { flex: 1 1 120px; }
|
||||
.server-status-grid { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); max-height: 156px; }
|
||||
.create { grid-template-columns: 1fr; }
|
||||
.create button { width: 100%; }
|
||||
.meta-grid { grid-template-columns: 1fr; }
|
||||
.key-head { flex-direction: column; }
|
||||
.key-actions { grid-template-columns: 1fr 1fr; }
|
||||
.key-actions .server-button { grid-column: 1 / -1; }
|
||||
.key-actions button { width: 100%; }
|
||||
.backdrop { align-items: end; padding: 0; }
|
||||
.sheet { max-height: 92vh; border-radius: 8px 8px 0 0; }
|
||||
.server-row { grid-template-columns: minmax(0, 1fr) auto; padding: .58rem .62rem; }
|
||||
.server-actions { gap: .25rem; }
|
||||
.icon-button { width: 34px; min-width: 34px; height: 34px; min-height: 34px; }
|
||||
.qr-modal { width: 100%; border-radius: 8px 8px 0 0; }
|
||||
}
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<div class="portal" x-data="clientPortal()" x-init="init()">
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<a class="brand" href="/configs">
|
||||
<span class="brand-mark">A</span>
|
||||
<span class="brand-text">
|
||||
<span class="brand-title">{{ t.site_name }}</span>
|
||||
<span class="brand-version">v{{ app_version }}</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="top-actions">
|
||||
<span class="user-pill">{{ user_name }} ({{ user_role }})</span>
|
||||
<div class="lang-switch">
|
||||
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
|
||||
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
|
||||
</div>
|
||||
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<section class="panel">
|
||||
<div class="intro">
|
||||
<div>
|
||||
<p class="eyebrow">{{ t.configs_portal_kicker }}</p>
|
||||
<h1>{{ t.configs_portal_heading }}</h1>
|
||||
</div>
|
||||
<div class="counters" aria-label="{{ t.configs_key_status }}">
|
||||
<div class="counter">
|
||||
<strong x-text="activeClients()"></strong>
|
||||
<span>{{ t.configs_active_keys }}</span>
|
||||
</div>
|
||||
<div class="counter">
|
||||
<strong x-text="clients.length"></strong>
|
||||
<span>{{ t.configs_total_keys }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-server-status">
|
||||
<div class="portal-server-head">
|
||||
<span>{{ t.configs_server_status }}</span>
|
||||
<button type="button" class="mini-button" @click="loadServerStatus()" :disabled="serverStatusBusy">{{ t.configs_refresh }}</button>
|
||||
</div>
|
||||
<template x-if="serverStatusBusy && serverPods().length === 0">
|
||||
<div class="server-status-muted">{{ t.configs_loading_status }}</div>
|
||||
</template>
|
||||
<template x-if="serverStatusError && serverPods().length === 0 && !serverStatusBusy">
|
||||
<div class="server-status-muted">{{ t.configs_status_unavailable }}</div>
|
||||
</template>
|
||||
<template x-if="serverStatus && serverPods().length === 0 && !serverStatusBusy && !serverStatusError">
|
||||
<div class="server-status-muted">{{ t.configs_no_servers }}</div>
|
||||
</template>
|
||||
<template x-if="serverPods().length > 0">
|
||||
<div class="server-status-grid">
|
||||
<template x-for="pod in serverPods()" :key="pod.name">
|
||||
<div class="server-status-chip" :class="`tone-${serverTone(pod)}`" :title="serverTooltip(pod)">
|
||||
<span class="server-status-dot"></span>
|
||||
<span class="server-status-text">
|
||||
<span class="server-status-name" x-text="serverName(pod)"></span>
|
||||
<span class="server-status-label" x-text="serverStatusLabel(pod)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="create">
|
||||
<div class="field">
|
||||
<label for="client-name">{{ t.configs_new_key }}</label>
|
||||
<input id="client-name" x-model="newName" @keydown.enter.prevent="createClient()" placeholder="{{ t.configs_name_placeholder }}">
|
||||
</div>
|
||||
<button type="button" @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template x-if="notice">
|
||||
<section class="notice" :class="`tone-${notice.kind}`">
|
||||
<strong x-text="notice.title"></strong>
|
||||
<span x-text="notice.message"></span>
|
||||
<details x-show="notice.detail">
|
||||
<summary>{{ t.notice_detail }}</summary>
|
||||
<span x-text="notice.detail"></span>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template x-if="!busy && clients.length === 0">
|
||||
<section class="panel empty">
|
||||
<strong>{{ t.configs_empty_title }}</strong>
|
||||
<span>{{ t.configs_empty_hint }}</span>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template x-if="clients.length > 0">
|
||||
<section class="key-list">
|
||||
<template x-for="client in sortedClients()" :key="client.id">
|
||||
<article class="key-card" :class="{ 'is-disabled': !client.enabled }">
|
||||
<div class="key-head">
|
||||
<div class="key-title">
|
||||
<h2 class="key-name" x-text="client.name"></h2>
|
||||
<span class="status-chip" :class="client.enabled ? 'enabled' : 'disabled'" x-text="client.enabled ? '{{ t.configs_enabled_state }}' : '{{ t.configs_disabled_state }}'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ t.configs_address }}</span>
|
||||
<code x-text="client.address + '/32'"></code>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ t.configs_public_key }}</span>
|
||||
<code x-text="shortKey(client.public_key)"></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="key-actions">
|
||||
<button type="button" class="server-button" @click="openServers(client)" :disabled="busy || !client.enabled">{{ t.configs_choose_server }}</button>
|
||||
<button type="button" class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
|
||||
<button type="button" class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
|
||||
</div>
|
||||
|
||||
<span class="key-updated" x-text="client.updated_at ? '{{ t.configs_updated }}: ' + formatDate(client.updated_at) : '{{ t.configs_key_ready }}'"></span>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<template x-if="serverModal">
|
||||
<div class="backdrop" x-cloak @click.self="closeServers()">
|
||||
<section class="sheet">
|
||||
<div class="sheet-head">
|
||||
<div class="sheet-title">
|
||||
<h2>{{ t.configs_choose_server }}</h2>
|
||||
<span x-text="serverModal.client.name"></span>
|
||||
</div>
|
||||
<button type="button" class="ghost" @click="closeServers()">{{ t.admin_close }}</button>
|
||||
</div>
|
||||
<input x-model="serverModal.filter" placeholder="{{ t.configs_server_search }}">
|
||||
<template x-if="serverModal.loading">
|
||||
<div class="empty">{{ t.configs_loading_servers }}</div>
|
||||
</template>
|
||||
<template x-if="!serverModal.loading && filteredServers().length === 0">
|
||||
<div class="empty">{{ t.configs_no_servers }}</div>
|
||||
</template>
|
||||
<template x-if="!serverModal.loading && filteredServers().length > 0">
|
||||
<div class="server-list">
|
||||
<template x-for="server in filteredServers()" :key="server.endpoint_id || server.endpoint_name">
|
||||
<article class="server-row">
|
||||
<div class="server-main">
|
||||
<strong x-text="server.endpoint_name"></strong>
|
||||
<code x-text="server.endpoint"></code>
|
||||
</div>
|
||||
<div class="server-actions">
|
||||
<button type="button" class="icon-button" @click="copyVpnUrl(serverModal.client, server)" :disabled="busy" data-tip="{{ t.configs_copy_link }}" title="{{ t.configs_copy_link }}" aria-label="{{ t.configs_copy_link }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M10 13a5 5 0 0 1 0-7l1-1a5 5 0 0 1 7 7l-1 1"></path>
|
||||
<path d="M14 11a5 5 0 0 1 0 7l-1 1a5 5 0 0 1-7-7l1-1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="secondary icon-button" @click="openQr(serverModal.client, server)" :disabled="busy" data-tip="{{ t.configs_qr_code }}" title="{{ t.configs_qr_code }}" aria-label="{{ t.configs_qr_code }}">
|
||||
<span class="qr-mark" aria-hidden="true">QR</span>
|
||||
</button>
|
||||
<button type="button" class="secondary icon-button" @click="downloadConfig(serverModal.client, server)" :disabled="busy" data-tip="{{ t.configs_download }}" title="{{ t.configs_download }}" aria-label="{{ t.configs_download }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M12 3v12"></path>
|
||||
<path d="m7 10 5 5 5-5"></path>
|
||||
<path d="M5 21h14"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="qrModal">
|
||||
<div class="backdrop" x-cloak @click.self="closeQr()">
|
||||
<section class="qr-modal">
|
||||
<h2 x-text="qrModal.title"></h2>
|
||||
<div class="qr-box" x-html="qrModal.svg"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="copyText(qrModal.url)">{{ t.configs_copy_link }}</button>
|
||||
<button type="button" @click="closeQr()">{{ t.admin_close }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function clientPortal() {
|
||||
return {
|
||||
clients: [],
|
||||
newName: '',
|
||||
busy: false,
|
||||
status: '',
|
||||
error: '',
|
||||
notice: null,
|
||||
serverStatus: null,
|
||||
serverStatusBusy: false,
|
||||
serverStatusError: '',
|
||||
serverStatusTimer: null,
|
||||
serverConfigs: {},
|
||||
serverModal: null,
|
||||
qrModal: null,
|
||||
init() {
|
||||
this.load();
|
||||
this.loadServerStatus();
|
||||
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
|
||||
},
|
||||
async request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.error || this.httpStatusMessage(response.status));
|
||||
error.title = data.title || this.httpStatusTitle(response.status);
|
||||
error.detail = data.detail || '';
|
||||
error.code = data.code || '';
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
async load() {
|
||||
this.error = '';
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request('/api/vpn-clients');
|
||||
this.clients = data.clients || [];
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async loadServerStatus(silent = false) {
|
||||
this.serverStatusError = '';
|
||||
if (!silent) this.serverStatusBusy = true;
|
||||
try {
|
||||
this.serverStatus = await this.request('/api/vpn-status');
|
||||
} catch (e) {
|
||||
this.serverStatusError = e.message;
|
||||
} finally {
|
||||
this.serverStatusBusy = false;
|
||||
}
|
||||
},
|
||||
async createClient() {
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request('/api/vpn-clients', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: this.newName }),
|
||||
});
|
||||
this.clients.push(data.client);
|
||||
delete this.serverConfigs[data.client.id];
|
||||
this.newName = '';
|
||||
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async setEnabled(client, enabled) {
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
const index = this.clients.findIndex((item) => item.id === client.id);
|
||||
if (index !== -1) this.clients[index] = data.client;
|
||||
delete this.serverConfigs[client.id];
|
||||
this.applyResponseNotice(data, '{{ t.notice_update_success }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async deleteClient(client) {
|
||||
if (!confirm('{{ t.users_delete_confirm }}')) return;
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request(`/api/vpn-clients/${client.id}`, { method: 'DELETE' });
|
||||
this.clients = this.clients.filter((item) => item.id !== client.id);
|
||||
delete this.serverConfigs[client.id];
|
||||
this.applyResponseNotice(data, '{{ t.notice_delete_success }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async openServers(client) {
|
||||
this.clearNotice();
|
||||
const cached = this.serverConfigs[client.id];
|
||||
this.serverModal = {
|
||||
client,
|
||||
filter: '',
|
||||
loading: !cached || cached.loading,
|
||||
servers: cached ? cached.servers : [],
|
||||
};
|
||||
try {
|
||||
const servers = await this.loadClientServers(client);
|
||||
if (this.serverModal && this.serverModal.client.id === client.id) {
|
||||
this.serverModal.loading = false;
|
||||
this.serverModal.servers = servers;
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.serverModal && this.serverModal.client.id === client.id) {
|
||||
this.serverModal.loading = false;
|
||||
this.serverModal.servers = [];
|
||||
}
|
||||
this.showError(e, {
|
||||
title: '{{ t.notice_server_list_error_title }}',
|
||||
message: '{{ t.notice_server_list_error_message }}',
|
||||
});
|
||||
}
|
||||
},
|
||||
async loadClientServers(client) {
|
||||
const cached = this.serverConfigs[client.id];
|
||||
if (cached && !cached.loading) return cached.servers || [];
|
||||
|
||||
this.serverConfigs[client.id] = { loading: true, servers: [] };
|
||||
try {
|
||||
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
|
||||
const servers = data.servers || [];
|
||||
this.serverConfigs[client.id] = { loading: false, servers };
|
||||
return servers;
|
||||
} catch (e) {
|
||||
this.serverConfigs[client.id] = { loading: false, servers: [] };
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
closeServers() {
|
||||
this.serverModal = null;
|
||||
},
|
||||
filteredServers() {
|
||||
if (!this.serverModal) return [];
|
||||
const query = this.serverModal.filter.trim().toLowerCase();
|
||||
if (!query) return this.serverModal.servers;
|
||||
return this.serverModal.servers.filter((server) => {
|
||||
return [server.endpoint_name, server.endpoint, server.endpoint_id]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(query));
|
||||
});
|
||||
},
|
||||
async copyVpnUrl(client, server) {
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
await this.copyText(server.vpn_url);
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`);
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async downloadConfig(client, server) {
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const blob = new Blob([server.config], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: ${server.endpoint}`);
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
openQr(client, server) {
|
||||
this.qrModal = {
|
||||
title: `${client.name || 'amnezia-client'} - ${server.endpoint_name}`,
|
||||
svg: server.qr_svg,
|
||||
url: server.vpn_url,
|
||||
};
|
||||
},
|
||||
closeQr() {
|
||||
this.qrModal = null;
|
||||
},
|
||||
async copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
textarea.remove();
|
||||
if (!copied) {
|
||||
throw new Error('copy failed');
|
||||
}
|
||||
},
|
||||
sortedClients() {
|
||||
return [...this.clients].sort((left, right) => {
|
||||
if (left.enabled !== right.enabled) return left.enabled ? -1 : 1;
|
||||
return String(left.name || '').localeCompare(String(right.name || ''));
|
||||
});
|
||||
},
|
||||
activeClients() {
|
||||
return this.clients.filter((client) => client.enabled).length;
|
||||
},
|
||||
serverPods() {
|
||||
return (this.serverStatus && this.serverStatus.pods) || [];
|
||||
},
|
||||
serverName(pod) {
|
||||
return pod.endpoint_name || pod.node_name || pod.name || '{{ t.configs_status_unknown }}';
|
||||
},
|
||||
serverTone(pod) {
|
||||
if (!pod.ready) return 'offline';
|
||||
if (pod.rollout_status === 'applied') return 'online';
|
||||
if (pod.rollout_status === 'error') return 'error';
|
||||
if (pod.rollout_status === 'pending_apply' || pod.rollout_status === 'pending_restart' || pod.rollout_status === 'starting') return 'warning';
|
||||
return 'offline';
|
||||
},
|
||||
serverStatusLabel(pod) {
|
||||
if (!pod.ready) return '{{ t.configs_status_offline }}';
|
||||
const labels = {
|
||||
applied: '{{ t.configs_status_online }}',
|
||||
starting: '{{ t.configs_status_starting }}',
|
||||
pending_apply: '{{ t.configs_status_pending_apply }}',
|
||||
pending_restart: '{{ t.configs_status_pending_apply }}',
|
||||
error: '{{ t.configs_status_error }}',
|
||||
unknown: '{{ t.configs_status_unknown }}',
|
||||
};
|
||||
return labels[pod.rollout_status] || pod.rollout_status || '{{ t.configs_status_unknown }}';
|
||||
},
|
||||
serverTooltip(pod) {
|
||||
return [
|
||||
this.serverName(pod),
|
||||
pod.endpoint || null,
|
||||
pod.ready ? '{{ t.configs_ready }}' : '{{ t.configs_not_ready }}',
|
||||
this.serverStatusLabel(pod),
|
||||
].filter(Boolean).join(' - ');
|
||||
},
|
||||
shortKey(key) {
|
||||
if (!key || key.length <= 16) return key || '';
|
||||
return `${key.slice(0, 8)}...${key.slice(-8)}`;
|
||||
},
|
||||
fileSlug(value) {
|
||||
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
|
||||
},
|
||||
clearNotice() {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.notice = null;
|
||||
},
|
||||
setNotice(kind, title, message, detail = '') {
|
||||
this.notice = { kind, title, message, detail };
|
||||
this.error = kind === 'error' ? message : '';
|
||||
this.status = kind === 'error' ? '' : message;
|
||||
},
|
||||
applyResponseNotice(data, fallbackMessage) {
|
||||
if (data.notice) {
|
||||
const notice = this.normalizeNotice(data.notice);
|
||||
this.setNotice(notice.kind, notice.title, notice.message, notice.detail || '');
|
||||
return;
|
||||
}
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', (data.sync && data.sync.message) || fallbackMessage);
|
||||
},
|
||||
normalizeNotice(notice) {
|
||||
if (notice.code === 'secret_sync_failed') {
|
||||
return {
|
||||
kind: 'warning',
|
||||
title: '{{ t.notice_sync_warning_title }}',
|
||||
message: '{{ t.notice_sync_warning_message }}',
|
||||
detail: notice.detail || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: notice.kind || 'warning',
|
||||
title: notice.title || '{{ t.notice_warning_title }}',
|
||||
message: notice.message || '',
|
||||
detail: notice.detail || '',
|
||||
};
|
||||
},
|
||||
showError(error, fallback = {}) {
|
||||
this.setNotice(
|
||||
'error',
|
||||
fallback.title || error.title || '{{ t.notice_error_title }}',
|
||||
fallback.message || error.message || '{{ t.notice_error_title }}',
|
||||
error.detail || ''
|
||||
);
|
||||
},
|
||||
httpStatusTitle(status) {
|
||||
if (status === 401) return '{{ t.login_heading }}';
|
||||
if (status === 403) return '{{ t.notice_error_title }}';
|
||||
if (status === 404) return '{{ t.configs_empty }}';
|
||||
if (status === 409) return '{{ t.notice_warning_title }}';
|
||||
if (status === 503) return '{{ t.notice_server_list_error_title }}';
|
||||
return '{{ t.notice_error_title }}';
|
||||
},
|
||||
httpStatusMessage(status) {
|
||||
if (status === 401) return 'not authenticated';
|
||||
if (status === 403) return 'access denied';
|
||||
if (status === 404) return 'not found';
|
||||
if (status === 409) return '{{ t.configs_no_servers }}';
|
||||
if (status === 503) return '{{ t.notice_server_list_error_message }}';
|
||||
return '{{ t.notice_error_title }}';
|
||||
},
|
||||
formatDate(value) {
|
||||
if (!value) return '{{ t.configs_never }}';
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isNaN(parsed)) return value;
|
||||
return new Date(parsed).toLocaleDateString();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock body %}
|
||||
+158
-60
@@ -14,7 +14,7 @@
|
||||
.sidebar a { display: block; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; }
|
||||
.sidebar a:hover, .sidebar a.active { background: #263544; color: #fff; }
|
||||
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
|
||||
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; flex-wrap: wrap; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
|
||||
.user-info { font-size: .875rem; color: #53606d; }
|
||||
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
|
||||
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
|
||||
@@ -42,6 +42,14 @@
|
||||
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
|
||||
.error { color: #9d2323; }
|
||||
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
|
||||
.notice { margin: .75rem 0; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .7rem .8rem; display: grid; gap: .25rem; }
|
||||
.notice strong { color: #1d252d; }
|
||||
.notice span { color: #53606d; font-size: .9rem; }
|
||||
.notice details { color: #6a7682; font-size: .82rem; }
|
||||
.notice summary { cursor: pointer; font-weight: 700; }
|
||||
.notice.tone-success { border-left-color: #2f8f4e; }
|
||||
.notice.tone-warning { border-left-color: #d39a00; }
|
||||
.notice.tone-error { border-left-color: #9d2323; }
|
||||
.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; }
|
||||
@@ -51,21 +59,24 @@
|
||||
.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-pending_apply { border-left-color: #d39a00; }
|
||||
.rollout-tile-pending_restart { border-left-color: #d39a00; }
|
||||
.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-pending_apply { background: #d39a00; }
|
||||
.rollout-dot-pending_restart { background: #d39a00; }
|
||||
.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-pending_apply { background: #fff0c2; color: #6f4e00; }
|
||||
.rollout-pending_restart { background: #fff0c2; color: #6f4e00; }
|
||||
.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; }
|
||||
@@ -81,9 +92,10 @@
|
||||
.modal-subtitle { color: #53606d; font-size: .86rem; overflow-wrap: anywhere; }
|
||||
.server-filter { width: 100%; }
|
||||
.server-picker-list { display: grid; gap: .6rem; overflow: auto; padding-right: .15rem; }
|
||||
.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 { border: 1px solid #dde2e6; border-radius: 8px; padding: .8rem; display: grid; gap: .7rem; align-items: start; }
|
||||
.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; }
|
||||
.server-card-actions { display: grid; grid-template-columns: repeat(3, minmax(118px, 1fr)); gap: .45rem; align-items: center; }
|
||||
.server-card-actions button { min-width: 0; }
|
||||
.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; }
|
||||
@@ -104,8 +116,7 @@
|
||||
table { display: block; overflow-x: auto; }
|
||||
.row-actions { align-items: stretch; }
|
||||
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
|
||||
.server-card { grid-template-columns: 1fr; }
|
||||
.server-card-actions { justify-content: stretch; }
|
||||
.server-card-actions { grid-template-columns: 1fr; }
|
||||
.server-card-actions button { flex: 1 1 100%; }
|
||||
.detail-row { grid-template-columns: 1fr; gap: .2rem; }
|
||||
.rollout-grid { grid-template-columns: 1fr; }
|
||||
@@ -146,7 +157,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status" :class="{ 'error': error }" x-text="error || status"></div>
|
||||
<template x-if="notice">
|
||||
<section class="notice" :class="`tone-${notice.kind}`">
|
||||
<strong x-text="notice.title"></strong>
|
||||
<span x-text="notice.message"></span>
|
||||
<details x-show="notice.detail">
|
||||
<summary>{{ t.notice_detail }}</summary>
|
||||
<span x-text="notice.detail"></span>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section class="rollout">
|
||||
<div class="panel-head">
|
||||
@@ -331,6 +351,7 @@ function configsPage() {
|
||||
busy: false,
|
||||
status: '',
|
||||
error: '',
|
||||
notice: null,
|
||||
rollout: null,
|
||||
rolloutBusy: false,
|
||||
rolloutError: '',
|
||||
@@ -339,10 +360,13 @@ function configsPage() {
|
||||
serverConfigs: {},
|
||||
serverModal: null,
|
||||
qrModal: null,
|
||||
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
|
||||
init() {
|
||||
this.load();
|
||||
this.loadRolloutStatus();
|
||||
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
|
||||
if (this.isAdmin) {
|
||||
this.loadRolloutStatus();
|
||||
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
|
||||
}
|
||||
},
|
||||
async request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
@@ -351,7 +375,12 @@ function configsPage() {
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || response.statusText);
|
||||
const error = new Error(data.error || this.httpStatusMessage(response.status));
|
||||
error.title = data.title || this.httpStatusTitle(response.status);
|
||||
error.detail = data.detail || '';
|
||||
error.code = data.code || '';
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
@@ -368,8 +397,7 @@ function configsPage() {
|
||||
}
|
||||
},
|
||||
async createClient() {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request('/api/vpn-clients', {
|
||||
@@ -379,17 +407,16 @@ function configsPage() {
|
||||
this.clients.push(data.client);
|
||||
delete this.serverConfigs[data.client.id];
|
||||
this.newName = '';
|
||||
this.status = data.sync.message;
|
||||
await this.loadRolloutStatus();
|
||||
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
|
||||
if (this.isAdmin) await this.loadRolloutStatus();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async setEnabled(client, enabled) {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
|
||||
@@ -399,18 +426,17 @@ function configsPage() {
|
||||
const index = this.clients.findIndex((item) => item.id === client.id);
|
||||
if (index !== -1) this.clients[index] = data.client;
|
||||
delete this.serverConfigs[client.id];
|
||||
this.status = data.sync.message;
|
||||
await this.loadRolloutStatus();
|
||||
this.applyResponseNotice(data, '{{ t.notice_update_success }}');
|
||||
if (this.isAdmin) await this.loadRolloutStatus();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async deleteClient(client) {
|
||||
if (!confirm('{{ t.users_delete_confirm }}')) return;
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request(`/api/vpn-clients/${client.id}`, {
|
||||
@@ -418,41 +444,53 @@ function configsPage() {
|
||||
});
|
||||
this.clients = this.clients.filter((item) => item.id !== client.id);
|
||||
delete this.serverConfigs[client.id];
|
||||
this.status = data.sync.message;
|
||||
await this.loadRolloutStatus();
|
||||
this.applyResponseNotice(data, '{{ t.notice_delete_success }}');
|
||||
if (this.isAdmin) await this.loadRolloutStatus();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async openServers(client) {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.clearNotice();
|
||||
const cached = this.serverConfigs[client.id];
|
||||
this.serverModal = {
|
||||
client,
|
||||
filter: '',
|
||||
loading: !cached,
|
||||
loading: !cached || cached.loading,
|
||||
servers: cached ? cached.servers : [],
|
||||
};
|
||||
if (cached) return;
|
||||
|
||||
this.serverConfigs[client.id] = { loading: true, servers: [] };
|
||||
try {
|
||||
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
|
||||
this.serverConfigs[client.id] = { loading: false, servers: data.servers || [] };
|
||||
const servers = await this.loadClientServers(client);
|
||||
if (this.serverModal && this.serverModal.client.id === client.id) {
|
||||
this.serverModal.loading = false;
|
||||
this.serverModal.servers = data.servers || [];
|
||||
this.serverModal.servers = servers;
|
||||
}
|
||||
} catch (e) {
|
||||
this.serverConfigs[client.id] = { loading: false, servers: [] };
|
||||
if (this.serverModal && this.serverModal.client.id === client.id) {
|
||||
this.serverModal.loading = false;
|
||||
this.serverModal.servers = [];
|
||||
}
|
||||
this.error = e.message;
|
||||
this.showError(e, {
|
||||
title: '{{ t.notice_server_list_error_title }}',
|
||||
message: '{{ t.notice_server_list_error_message }}',
|
||||
});
|
||||
}
|
||||
},
|
||||
async loadClientServers(client) {
|
||||
const cached = this.serverConfigs[client.id];
|
||||
if (cached && !cached.loading) return cached.servers || [];
|
||||
|
||||
this.serverConfigs[client.id] = { loading: true, servers: [] };
|
||||
try {
|
||||
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
|
||||
const servers = data.servers || [];
|
||||
this.serverConfigs[client.id] = { loading: false, servers };
|
||||
return servers;
|
||||
} catch (e) {
|
||||
this.serverConfigs[client.id] = { loading: false, servers: [] };
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
closeServers() {
|
||||
@@ -469,35 +507,36 @@ function configsPage() {
|
||||
});
|
||||
},
|
||||
async downloadConfig(client, server) {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const blob = new Blob([server.config], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
this.status = `${server.endpoint_name}: ${server.endpoint}`;
|
||||
this.saveConfigFile(client, server);
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
saveConfigFile(client, server) {
|
||||
const blob = new Blob([server.config], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: ${server.endpoint}`);
|
||||
},
|
||||
async copyVpnUrl(client, server) {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
await this.copyText(server.vpn_url);
|
||||
this.status = `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`);
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
@@ -532,20 +571,20 @@ function configsPage() {
|
||||
}
|
||||
},
|
||||
async sync() {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
try {
|
||||
const data = await this.request('/api/vpn-clients/sync', { method: 'POST' });
|
||||
this.status = data.message;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', data.message);
|
||||
await this.loadRolloutStatus();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async loadRolloutStatus() {
|
||||
if (!this.isAdmin) return;
|
||||
this.rolloutError = '';
|
||||
this.rolloutBusy = true;
|
||||
try {
|
||||
@@ -598,11 +637,70 @@ function configsPage() {
|
||||
fileSlug(value) {
|
||||
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
|
||||
},
|
||||
clearNotice() {
|
||||
this.error = '';
|
||||
this.status = '';
|
||||
this.notice = null;
|
||||
},
|
||||
setNotice(kind, title, message, detail = '') {
|
||||
this.notice = { kind, title, message, detail };
|
||||
this.error = kind === 'error' ? message : '';
|
||||
this.status = kind === 'error' ? '' : message;
|
||||
},
|
||||
applyResponseNotice(data, fallbackMessage) {
|
||||
if (data.notice) {
|
||||
const notice = this.normalizeNotice(data.notice);
|
||||
this.setNotice(notice.kind, notice.title, notice.message, notice.detail || '');
|
||||
return;
|
||||
}
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', (data.sync && data.sync.message) || fallbackMessage);
|
||||
},
|
||||
normalizeNotice(notice) {
|
||||
if (notice.code === 'secret_sync_failed') {
|
||||
return {
|
||||
kind: 'warning',
|
||||
title: '{{ t.notice_sync_warning_title }}',
|
||||
message: '{{ t.notice_sync_warning_message }}',
|
||||
detail: notice.detail || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: notice.kind || 'warning',
|
||||
title: notice.title || '{{ t.notice_warning_title }}',
|
||||
message: notice.message || '',
|
||||
detail: notice.detail || '',
|
||||
};
|
||||
},
|
||||
showError(error, fallback = {}) {
|
||||
this.setNotice(
|
||||
'error',
|
||||
fallback.title || error.title || '{{ t.notice_error_title }}',
|
||||
fallback.message || error.message || '{{ t.notice_error_title }}',
|
||||
error.detail || ''
|
||||
);
|
||||
},
|
||||
httpStatusTitle(status) {
|
||||
if (status === 401) return '{{ t.login_heading }}';
|
||||
if (status === 403) return '{{ t.notice_error_title }}';
|
||||
if (status === 404) return '{{ t.configs_empty }}';
|
||||
if (status === 409) return '{{ t.notice_warning_title }}';
|
||||
if (status === 503) return '{{ t.notice_server_list_error_title }}';
|
||||
return '{{ t.notice_error_title }}';
|
||||
},
|
||||
httpStatusMessage(status) {
|
||||
if (status === 401) return 'not authenticated';
|
||||
if (status === 403) return 'access denied';
|
||||
if (status === 404) return 'not found';
|
||||
if (status === 409) return '{{ t.configs_no_servers }}';
|
||||
if (status === 503) return '{{ t.notice_server_list_error_message }}';
|
||||
return '{{ t.notice_error_title }}';
|
||||
},
|
||||
rolloutStatusLabel(value) {
|
||||
const labels = {
|
||||
applied: '{{ t.configs_status_applied }}',
|
||||
starting: '{{ t.configs_status_starting }}',
|
||||
pending_restart: '{{ t.configs_status_pending_restart }}',
|
||||
pending_apply: '{{ t.configs_status_pending_apply }}',
|
||||
pending_restart: '{{ t.configs_status_pending_apply }}',
|
||||
error: '{{ t.configs_status_error }}',
|
||||
unknown: '{{ t.configs_status_unknown }}',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user