Add Cluster feature
This commit is contained in:
@@ -31,13 +31,13 @@ fn hmac_sha256(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn aws_uri_encode(input: &str) -> String {
|
||||
pub fn aws_uri_encode(input: &str) -> String {
|
||||
percent_encode(input.as_bytes(), AWS_ENCODE_SET).to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -1412,3 +1412,170 @@ pub async fn integrity_history(
|
||||
None => json_response(StatusCode::OK, serde_json::json!({"executions": []})),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_admin_or_registered_peer(state: &AppState, principal: &Principal) -> Option<Response> {
|
||||
if principal.is_admin {
|
||||
return None;
|
||||
}
|
||||
let registry = match &state.site_registry {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Some(json_error(
|
||||
"AccessDenied",
|
||||
"Admin access required",
|
||||
StatusCode::FORBIDDEN,
|
||||
))
|
||||
}
|
||||
};
|
||||
for peer in registry.list_peers() {
|
||||
if let Some(conn_id) = peer.connection_id.as_deref() {
|
||||
if let Some(conn) = state.connections.get(conn_id) {
|
||||
if conn.access_key == principal.access_key {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(json_error(
|
||||
"AccessDenied",
|
||||
"Admin or registered peer required",
|
||||
StatusCode::FORBIDDEN,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn build_cluster_overview_public(state: &AppState) -> serde_json::Value {
|
||||
build_cluster_overview(state).await
|
||||
}
|
||||
|
||||
async fn build_cluster_overview(state: &AppState) -> serde_json::Value {
|
||||
let local_site = state
|
||||
.site_registry
|
||||
.as_ref()
|
||||
.and_then(|r| r.get_local_site());
|
||||
|
||||
let buckets = state.storage.list_buckets().await.unwrap_or_default();
|
||||
let bucket_count = buckets.len() as u64;
|
||||
let mut total_objects: u64 = 0;
|
||||
let mut size_bytes: u64 = 0;
|
||||
for b in &buckets {
|
||||
if let Ok(stats) = state.storage.bucket_stats(&b.name).await {
|
||||
total_objects += stats.total_objects();
|
||||
size_bytes += stats.total_bytes();
|
||||
}
|
||||
}
|
||||
|
||||
let (disk_total, disk_free) =
|
||||
crate::services::system_metrics::sample_disk(&state.config.storage_root);
|
||||
|
||||
let system = match state.system_metrics.as_ref() {
|
||||
Some(svc) => {
|
||||
let history = svc.get_history(Some(1)).await;
|
||||
history
|
||||
.last()
|
||||
.map(|s| {
|
||||
serde_json::json!({
|
||||
"cpu_percent": s.cpu_percent,
|
||||
"memory_percent": s.memory_percent,
|
||||
"disk_percent": s.disk_percent,
|
||||
"storage_bytes": s.storage_bytes,
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| serde_json::json!({}))
|
||||
}
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
|
||||
let sync_snapshot = state
|
||||
.site_sync
|
||||
.as_ref()
|
||||
.map(|w| w.snapshot_stats())
|
||||
.unwrap_or_default();
|
||||
let mut sync_errors: u64 = 0;
|
||||
let mut last_sync_at: Option<f64> = None;
|
||||
for s in sync_snapshot.values() {
|
||||
sync_errors += s.errors;
|
||||
if let Some(ts) = s.last_sync_at {
|
||||
last_sync_at = match last_sync_at {
|
||||
Some(prev) if prev > ts => Some(prev),
|
||||
_ => Some(ts),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().timestamp_millis() as f64 / 1000.0;
|
||||
serde_json::json!({
|
||||
"site_id": local_site.as_ref().map(|s| s.site_id.clone()),
|
||||
"display_name": local_site.as_ref().map(|s| s.display_name.clone()),
|
||||
"endpoint": local_site.as_ref().map(|s| s.endpoint.clone()),
|
||||
"region": local_site.as_ref().map(|s| s.region.clone()),
|
||||
"priority": local_site.as_ref().map(|s| s.priority),
|
||||
"capacity": {
|
||||
"total_bytes": disk_total,
|
||||
"available_bytes": disk_free,
|
||||
},
|
||||
"buckets": bucket_count,
|
||||
"objects": total_objects,
|
||||
"size_bytes": size_bytes,
|
||||
"system": system,
|
||||
"sync": {
|
||||
"errors": sync_errors,
|
||||
"last_sync_at": last_sync_at,
|
||||
},
|
||||
"generated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_cluster_overview(
|
||||
State(state): State<AppState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Response {
|
||||
if let Some(err) = require_admin_or_registered_peer(&state, &principal) {
|
||||
return err;
|
||||
}
|
||||
{
|
||||
let guard = state.cluster_overview_cache.lock();
|
||||
if let Some((at, ref value)) = *guard {
|
||||
if at.elapsed() < std::time::Duration::from_secs(10) {
|
||||
return json_response(StatusCode::OK, value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let value = build_cluster_overview(&state).await;
|
||||
*state.cluster_overview_cache.lock() =
|
||||
Some((std::time::Instant::now(), value.clone()));
|
||||
json_response(StatusCode::OK, value)
|
||||
}
|
||||
|
||||
pub async fn get_sync_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Response {
|
||||
if let Some(err) = require_admin(&principal) {
|
||||
return err;
|
||||
}
|
||||
let snapshot = match state.site_sync.as_ref() {
|
||||
Some(worker) => worker.snapshot_stats(),
|
||||
None => Default::default(),
|
||||
};
|
||||
let stats: Vec<serde_json::Value> = snapshot
|
||||
.into_iter()
|
||||
.map(|(bucket, s)| {
|
||||
serde_json::json!({
|
||||
"bucket": bucket,
|
||||
"last_sync_at": s.last_sync_at,
|
||||
"objects_pulled": s.objects_pulled,
|
||||
"objects_skipped": s.objects_skipped,
|
||||
"conflicts_resolved": s.conflicts_resolved,
|
||||
"deletions_applied": s.deletions_applied,
|
||||
"errors": s.errors,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json_response(
|
||||
StatusCode::OK,
|
||||
serde_json::json!({
|
||||
"enabled": state.site_sync.is_some(),
|
||||
"stats": stats,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ pub fn register_ui_endpoints(engine: &TemplateEngine) {
|
||||
("ui.sites_dashboard", "/ui/sites"),
|
||||
("ui.update_local_site", "/ui/sites/local"),
|
||||
("ui.add_peer_site", "/ui/sites/peers"),
|
||||
("ui.cluster_dashboard", "/ui/cluster"),
|
||||
("ui.metrics_dashboard", "/ui/metrics"),
|
||||
("ui.system_dashboard", "/ui/system"),
|
||||
("ui.system_gc_status", "/ui/system/gc/status"),
|
||||
@@ -1199,20 +1200,60 @@ pub async fn sites_dashboard(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let rules = state.replication.rules_snapshot();
|
||||
let sync_snapshot = state
|
||||
.site_sync
|
||||
.as_ref()
|
||||
.map(|w| w.snapshot_stats())
|
||||
.unwrap_or_default();
|
||||
|
||||
let peers_with_stats: Vec<Value> = peers
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|peer| {
|
||||
let has_connection = peer
|
||||
let connection_id = peer
|
||||
.get("connection_id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| !value.is_empty())
|
||||
.unwrap_or(false);
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string());
|
||||
let has_connection = connection_id.is_some();
|
||||
|
||||
let mut buckets_syncing: u64 = 0;
|
||||
let mut has_bidirectional = false;
|
||||
let mut last_sync_at: Option<f64> = None;
|
||||
let mut total_pulled: u64 = 0;
|
||||
let mut total_errors: u64 = 0;
|
||||
|
||||
if let Some(ref conn_id) = connection_id {
|
||||
for (bucket, rule) in &rules {
|
||||
if &rule.target_connection_id != conn_id || !rule.enabled {
|
||||
continue;
|
||||
}
|
||||
if rule.mode == crate::services::replication::MODE_BIDIRECTIONAL {
|
||||
has_bidirectional = true;
|
||||
buckets_syncing += 1;
|
||||
if let Some(stats) = sync_snapshot.get(bucket) {
|
||||
total_pulled += stats.objects_pulled;
|
||||
total_errors += stats.errors;
|
||||
if let Some(ts) = stats.last_sync_at {
|
||||
last_sync_at = match last_sync_at {
|
||||
Some(prev) if prev > ts => Some(prev),
|
||||
_ => Some(ts),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"peer": peer,
|
||||
"has_connection": has_connection,
|
||||
"buckets_syncing": 0,
|
||||
"has_bidirectional": false,
|
||||
"buckets_syncing": buckets_syncing,
|
||||
"has_bidirectional": has_bidirectional,
|
||||
"last_sync_at": last_sync_at,
|
||||
"objects_pulled": total_pulled,
|
||||
"errors": total_errors,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -1249,6 +1290,184 @@ pub async fn sites_dashboard(
|
||||
render(&state, "sites.html", &ctx)
|
||||
}
|
||||
|
||||
pub async fn cluster_data_json(
|
||||
State(state): State<AppState>,
|
||||
Extension(_session): Extension<SessionHandle>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let force = params
|
||||
.get("force")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
if force {
|
||||
*state.cluster_aggregate_cache.lock() = None;
|
||||
*state.cluster_overview_cache.lock() = None;
|
||||
}
|
||||
let sites = build_cluster_sites(&state).await;
|
||||
let totals = cluster_totals(&sites);
|
||||
let body = json!({ "sites": sites, "totals": totals });
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
body.to_string(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn cluster_totals(sites: &[Value]) -> Value {
|
||||
let total_buckets: u64 = sites
|
||||
.iter()
|
||||
.filter_map(|s| s.get("buckets").and_then(|v| v.as_u64()))
|
||||
.sum();
|
||||
let total_objects: u64 = sites
|
||||
.iter()
|
||||
.filter_map(|s| s.get("objects").and_then(|v| v.as_u64()))
|
||||
.sum();
|
||||
let total_size_bytes: u64 = sites
|
||||
.iter()
|
||||
.filter_map(|s| s.get("size_bytes").and_then(|v| v.as_u64()))
|
||||
.sum();
|
||||
let online = sites
|
||||
.iter()
|
||||
.filter(|s| s.get("online").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.count();
|
||||
json!({
|
||||
"buckets": total_buckets,
|
||||
"objects": total_objects,
|
||||
"size_bytes": total_size_bytes,
|
||||
"online_count": online,
|
||||
"total_count": sites.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cluster_dashboard(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<SessionHandle>,
|
||||
) -> Response {
|
||||
let mut ctx = page_context(&state, &session, "ui.cluster_dashboard");
|
||||
|
||||
let sites = build_cluster_sites(&state).await;
|
||||
|
||||
let total_buckets: u64 = sites
|
||||
.iter()
|
||||
.filter_map(|s| s.get("buckets").and_then(|v| v.as_u64()))
|
||||
.sum();
|
||||
let total_objects: u64 = sites
|
||||
.iter()
|
||||
.filter_map(|s| s.get("objects").and_then(|v| v.as_u64()))
|
||||
.sum();
|
||||
let total_size_bytes: u64 = sites
|
||||
.iter()
|
||||
.filter_map(|s| s.get("size_bytes").and_then(|v| v.as_u64()))
|
||||
.sum();
|
||||
let online_count = sites
|
||||
.iter()
|
||||
.filter(|s| s.get("online").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.count();
|
||||
|
||||
ctx.insert("cluster_sites", &sites);
|
||||
ctx.insert("cluster_total_buckets", &total_buckets);
|
||||
ctx.insert("cluster_total_objects", &total_objects);
|
||||
ctx.insert("cluster_total_size_bytes", &total_size_bytes);
|
||||
ctx.insert("cluster_online_count", &online_count);
|
||||
ctx.insert("cluster_total_count", &sites.len());
|
||||
render(&state, "cluster.html", &ctx)
|
||||
}
|
||||
|
||||
async fn build_cluster_sites(state: &AppState) -> Vec<Value> {
|
||||
{
|
||||
let guard = state.cluster_aggregate_cache.lock();
|
||||
if let Some((at, ref value)) = *guard {
|
||||
if at.elapsed() < std::time::Duration::from_secs(10) {
|
||||
if let Some(arr) = value.as_array() {
|
||||
return arr.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut sites: Vec<Value> = Vec::new();
|
||||
|
||||
let local = crate::handlers::admin::build_cluster_overview_public(state).await;
|
||||
let mut local_card = decorate_site(local, true, false, None);
|
||||
if local_card.get("site_id").and_then(|v| v.as_str()).is_none() {
|
||||
local_card["site_id"] = json!(state
|
||||
.config
|
||||
.site_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "local".to_string()));
|
||||
}
|
||||
local_card["is_local"] = json!(true);
|
||||
sites.push(local_card);
|
||||
|
||||
let peers = state
|
||||
.site_registry
|
||||
.as_ref()
|
||||
.map(|r| r.list_peers())
|
||||
.unwrap_or_default();
|
||||
|
||||
let connect_to = std::time::Duration::from_secs(2);
|
||||
let read_to = std::time::Duration::from_secs(3);
|
||||
let client = crate::services::peer_admin::PeerAdminClient::new(connect_to, read_to);
|
||||
|
||||
let mut peer_futures = Vec::new();
|
||||
for peer in peers {
|
||||
let conn = peer
|
||||
.connection_id
|
||||
.as_deref()
|
||||
.and_then(|id| state.connections.get(id));
|
||||
let endpoint = peer.endpoint.clone();
|
||||
let conn_clone = conn.clone();
|
||||
let client_ref = &client;
|
||||
peer_futures.push(async move {
|
||||
let value = match conn_clone {
|
||||
Some(c) => client_ref.fetch_cluster_overview(&endpoint, &c).await,
|
||||
None => Err("no connection configured".to_string()),
|
||||
};
|
||||
(peer, value)
|
||||
});
|
||||
}
|
||||
|
||||
let results = futures::future::join_all(peer_futures).await;
|
||||
for (peer, result) in results {
|
||||
let (overview, online, error) = match result {
|
||||
Ok(value) => (value, true, None),
|
||||
Err(err) => (json!({}), false, Some(err)),
|
||||
};
|
||||
let mut card = decorate_site(overview, online, !online, error);
|
||||
if card.get("site_id").and_then(|v| v.as_str()).is_none() {
|
||||
card["site_id"] = json!(peer.site_id.clone());
|
||||
}
|
||||
if card.get("display_name").and_then(|v| v.as_str()).is_none() {
|
||||
card["display_name"] = json!(peer.display_name.clone());
|
||||
}
|
||||
if card.get("endpoint").and_then(|v| v.as_str()).is_none() {
|
||||
card["endpoint"] = json!(peer.endpoint.clone());
|
||||
}
|
||||
card["is_local"] = json!(false);
|
||||
card["registered_priority"] = json!(peer.priority);
|
||||
card["registered_region"] = json!(peer.region);
|
||||
sites.push(card);
|
||||
}
|
||||
|
||||
*state.cluster_aggregate_cache.lock() =
|
||||
Some((std::time::Instant::now(), Value::Array(sites.clone())));
|
||||
sites
|
||||
}
|
||||
|
||||
fn decorate_site(mut value: Value, online: bool, stale: bool, error: Option<String>) -> Value {
|
||||
if !value.is_object() {
|
||||
value = json!({});
|
||||
}
|
||||
value["online"] = json!(online);
|
||||
value["stale"] = json!(stale);
|
||||
value["error"] = match error {
|
||||
Some(e) => json!(e),
|
||||
None => Value::Null,
|
||||
};
|
||||
value
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LocalSiteForm {
|
||||
pub site_id: String,
|
||||
|
||||
@@ -230,6 +230,8 @@ pub fn create_ui_router(state: state::AppState) -> Router {
|
||||
get(ui_api::connection_health),
|
||||
)
|
||||
.route("/ui/sites", get(ui_pages::sites_dashboard))
|
||||
.route("/ui/cluster", get(ui_pages::cluster_dashboard))
|
||||
.route("/ui/cluster/data", get(ui_pages::cluster_data_json))
|
||||
.route("/ui/sites/local", post(ui_pages::update_local_site))
|
||||
.route("/ui/sites/peers", post(ui_pages::add_peer_site))
|
||||
.route(
|
||||
@@ -481,6 +483,14 @@ pub fn create_router(state: state::AppState) -> Router {
|
||||
"/admin/sites/{site_id}/bidirectional-status",
|
||||
axum::routing::get(handlers::admin::check_bidirectional_status),
|
||||
)
|
||||
.route(
|
||||
"/admin/sync/stats",
|
||||
axum::routing::get(handlers::admin::get_sync_stats),
|
||||
)
|
||||
.route(
|
||||
"/admin/cluster/overview",
|
||||
axum::routing::get(handlers::admin::get_cluster_overview),
|
||||
)
|
||||
.route(
|
||||
"/admin/topology",
|
||||
axum::routing::get(handlers::admin::get_topology),
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod lifecycle;
|
||||
pub mod metrics;
|
||||
pub mod notifications;
|
||||
pub mod object_lock;
|
||||
pub mod peer_admin;
|
||||
pub mod peer_fetch;
|
||||
pub mod replication;
|
||||
pub mod s3_client;
|
||||
|
||||
127
crates/myfsio-server/src/services/peer_admin.rs
Normal file
127
crates/myfsio-server/src/services/peer_admin.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
|
||||
use myfsio_auth::sigv4::{
|
||||
aws_uri_encode, build_string_to_sign, compute_signature, derive_signing_key, sha256_hex,
|
||||
};
|
||||
|
||||
use crate::stores::connections::RemoteConnection;
|
||||
|
||||
pub struct PeerAdminClient {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl PeerAdminClient {
|
||||
pub fn new(connect_timeout: Duration, read_timeout: Duration) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(connect_timeout)
|
||||
.timeout(read_timeout)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub async fn fetch_cluster_overview(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
connection: &RemoteConnection,
|
||||
) -> Result<Value, String> {
|
||||
let url = format!(
|
||||
"{}/admin/cluster/overview",
|
||||
endpoint.trim_end_matches('/')
|
||||
);
|
||||
let parsed = reqwest::Url::parse(&url).map_err(|e| format!("invalid url: {}", e))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "missing host".to_string())?
|
||||
.to_string();
|
||||
let host_with_port = match parsed.port() {
|
||||
Some(p) => format!("{}:{}", host, p),
|
||||
None => host.clone(),
|
||||
};
|
||||
let canonical_uri = parsed.path().to_string();
|
||||
let canonical_uri = if canonical_uri.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
canonical_uri
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date_stamp = now.format("%Y%m%d").to_string();
|
||||
let region = if connection.region.is_empty() {
|
||||
"us-east-1".to_string()
|
||||
} else {
|
||||
connection.region.clone()
|
||||
};
|
||||
let service = "s3";
|
||||
let payload_hash = sha256_hex(b"");
|
||||
|
||||
let canonical_headers = format!(
|
||||
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
|
||||
host_with_port, payload_hash, amz_date
|
||||
);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
|
||||
let canonical_query = parsed
|
||||
.query()
|
||||
.map(|q| {
|
||||
let mut pairs: Vec<(String, String)> = q
|
||||
.split('&')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| {
|
||||
let mut it = p.splitn(2, '=');
|
||||
let k = it.next().unwrap_or("").to_string();
|
||||
let v = it.next().unwrap_or("").to_string();
|
||||
(k, v)
|
||||
})
|
||||
.collect();
|
||||
pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", aws_uri_encode(k), aws_uri_encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let canonical_request = format!(
|
||||
"GET\n{}\n{}\n{}\n{}\n{}",
|
||||
canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
let credential_scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
|
||||
let string_to_sign = build_string_to_sign(&amz_date, &credential_scope, &canonical_request);
|
||||
let signing_key =
|
||||
derive_signing_key(&connection.secret_key, &date_stamp, ®ion, service);
|
||||
let signature = compute_signature(&signing_key, &string_to_sign);
|
||||
|
||||
let authorization = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{},SignedHeaders={},Signature={}",
|
||||
connection.access_key, credential_scope, signed_headers, signature
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("host", &host_with_port)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("authorization", &authorization)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("peer returned status {}", status.as_u16()));
|
||||
}
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("invalid json: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ pub struct SyncState {
|
||||
pub last_full_sync: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SiteSyncStats {
|
||||
pub last_sync_at: Option<f64>,
|
||||
pub objects_pulled: u64,
|
||||
@@ -90,6 +90,7 @@ impl SiteSyncWorker {
|
||||
max_attempts: max_retries,
|
||||
},
|
||||
));
|
||||
let bucket_stats = Mutex::new(load_stats(&storage_root));
|
||||
Self {
|
||||
storage,
|
||||
connections,
|
||||
@@ -100,7 +101,7 @@ impl SiteSyncWorker {
|
||||
batch_size,
|
||||
clock_skew_tolerance,
|
||||
client_options,
|
||||
bucket_stats: Mutex::new(HashMap::new()),
|
||||
bucket_stats,
|
||||
shutdown: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
@@ -117,6 +118,15 @@ impl SiteSyncWorker {
|
||||
self.bucket_stats.lock().get(bucket).cloned()
|
||||
}
|
||||
|
||||
pub fn snapshot_stats(&self) -> HashMap<String, SiteSyncStats> {
|
||||
self.bucket_stats.lock().clone()
|
||||
}
|
||||
|
||||
fn save_stats(&self) {
|
||||
let snapshot = self.bucket_stats.lock().clone();
|
||||
save_stats(&self.storage_root, &snapshot);
|
||||
}
|
||||
|
||||
pub async fn run(self: Arc<Self>) {
|
||||
tracing::info!(
|
||||
"Site sync worker started (interval={}s)",
|
||||
@@ -136,6 +146,7 @@ impl SiteSyncWorker {
|
||||
|
||||
async fn run_cycle(&self) {
|
||||
let rules = self.replication.rules_snapshot();
|
||||
let mut mutated = false;
|
||||
for (bucket, rule) in rules {
|
||||
if rule.mode != MODE_BIDIRECTIONAL || !rule.enabled {
|
||||
continue;
|
||||
@@ -143,12 +154,16 @@ impl SiteSyncWorker {
|
||||
match self.sync_bucket(&rule).await {
|
||||
Ok(stats) => {
|
||||
self.bucket_stats.lock().insert(bucket, stats);
|
||||
mutated = true;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Site sync failed for bucket {}: {}", bucket, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if mutated {
|
||||
self.save_stats();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn trigger_sync(&self, bucket: &str) -> Option<SiteSyncStats> {
|
||||
@@ -161,6 +176,7 @@ impl SiteSyncWorker {
|
||||
self.bucket_stats
|
||||
.lock()
|
||||
.insert(bucket.to_string(), stats.clone());
|
||||
self.save_stats();
|
||||
Some(stats)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -454,6 +470,34 @@ fn now_secs() -> f64 {
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn stats_path(storage_root: &std::path::Path) -> PathBuf {
|
||||
storage_root
|
||||
.join(".myfsio.sys")
|
||||
.join("config")
|
||||
.join("site_sync_stats.json")
|
||||
}
|
||||
|
||||
fn load_stats(storage_root: &std::path::Path) -> HashMap<String, SiteSyncStats> {
|
||||
let path = stats_path(storage_root);
|
||||
if !path.exists() {
|
||||
return HashMap::new();
|
||||
}
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_stats(storage_root: &std::path::Path, stats: &HashMap<String, SiteSyncStats>) {
|
||||
let path = stats_path(storage_root);
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(text) = serde_json::to_string_pretty(stats) {
|
||||
let _ = std::fs::write(&path, text);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_not_found_error<E: std::fmt::Debug>(err: &aws_sdk_s3::error::SdkError<E>) -> bool {
|
||||
let msg = format!("{:?}", err);
|
||||
msg.contains("NoSuchBucket")
|
||||
|
||||
@@ -144,7 +144,7 @@ fn normalize_path_for_mount(path: &Path) -> String {
|
||||
stripped.to_lowercase()
|
||||
}
|
||||
|
||||
fn sample_disk(path: &Path) -> (u64, u64) {
|
||||
pub fn sample_disk(path: &Path) -> (u64, u64) {
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let path_str = normalize_path_for_mount(path);
|
||||
let mut best: Option<(usize, u64, u64)> = None;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::ServerConfig;
|
||||
use crate::services::access_logging::AccessLoggingService;
|
||||
@@ -40,6 +43,8 @@ pub struct AppState {
|
||||
pub templates: Option<Arc<TemplateEngine>>,
|
||||
pub sessions: Arc<SessionStore>,
|
||||
pub access_logging: Arc<AccessLoggingService>,
|
||||
pub cluster_overview_cache: Arc<Mutex<Option<(Instant, Value)>>>,
|
||||
pub cluster_aggregate_cache: Arc<Mutex<Option<(Instant, Value)>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -208,6 +213,8 @@ impl AppState {
|
||||
templates,
|
||||
sessions: Arc::new(SessionStore::new(session_ttl)),
|
||||
access_logging,
|
||||
cluster_overview_cache: Arc::new(Mutex::new(None)),
|
||||
cluster_aggregate_cache: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -404,6 +404,7 @@ html.sidebar-will-collapse .sidebar-user {
|
||||
min-height: 70px;
|
||||
gap: 0.5rem;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-header {
|
||||
@@ -516,10 +517,16 @@ html.sidebar-will-collapse .sidebar-user {
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -87,19 +87,18 @@
|
||||
</svg>
|
||||
<span>Connections</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.metrics_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.metrics_dashboard" %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"/>
|
||||
<path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"/>
|
||||
</svg>
|
||||
<span>Metrics</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.sites_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.sites_dashboard" %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||
</svg>
|
||||
<span>Sites</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.cluster_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.cluster_dashboard" %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434L7.752.066ZM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567L4.25 7.504ZM7.5 9.933l-2.75 1.571v3.134l2.75-1.571V9.933Zm1 3.134 2.75 1.571v-3.134L8.5 9.933v3.134Zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567-2.742 1.567Zm2.242-2.433V3.504L8.5 5.076V8.21l2.75-1.572ZM7.5 8.21V5.076L4.75 3.504v3.134L7.5 8.21ZM5.258 2.643 8 4.21l2.742-1.567L8 1.076 5.258 2.643ZM15 9.933l-2.75 1.571v3.134L15 13.067V9.933ZM3.75 14.638v-3.134L1 9.933v3.134l2.75 1.571Z"/>
|
||||
</svg>
|
||||
<span>Cluster</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if website_hosting_nav %}
|
||||
<a href="{{ url_for(endpoint="ui.website_domains_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.website_domains_dashboard" %}active{% endif %}">
|
||||
@@ -111,6 +110,13 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_manage_iam %}
|
||||
<a href="{{ url_for(endpoint="ui.metrics_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.metrics_dashboard" %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"/>
|
||||
<path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"/>
|
||||
</svg>
|
||||
<span>Metrics</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.system_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.system_dashboard" %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/>
|
||||
@@ -195,19 +201,18 @@
|
||||
</svg>
|
||||
<span class="sidebar-link-text">Connections</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.metrics_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.metrics_dashboard" %}active{% endif %}" data-tooltip="Metrics">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"/>
|
||||
<path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"/>
|
||||
</svg>
|
||||
<span class="sidebar-link-text">Metrics</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.sites_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.sites_dashboard" %}active{% endif %}" data-tooltip="Sites">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||
</svg>
|
||||
<span class="sidebar-link-text">Sites</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.cluster_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.cluster_dashboard" %}active{% endif %}" data-tooltip="Cluster">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434L7.752.066ZM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567L4.25 7.504ZM7.5 9.933l-2.75 1.571v3.134l2.75-1.571V9.933Zm1 3.134 2.75 1.571v-3.134L8.5 9.933v3.134Zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567-2.742 1.567Zm2.242-2.433V3.504L8.5 5.076V8.21l2.75-1.572ZM7.5 8.21V5.076L4.75 3.504v3.134L7.5 8.21ZM5.258 2.643 8 4.21l2.742-1.567L8 1.076 5.258 2.643ZM15 9.933l-2.75 1.571v3.134L15 13.067V9.933ZM3.75 14.638v-3.134L1 9.933v3.134l2.75 1.571Z"/>
|
||||
</svg>
|
||||
<span class="sidebar-link-text">Cluster</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if website_hosting_nav %}
|
||||
<a href="{{ url_for(endpoint="ui.website_domains_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.website_domains_dashboard" %}active{% endif %}" data-tooltip="Domains">
|
||||
@@ -219,6 +224,13 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_manage_iam %}
|
||||
<a href="{{ url_for(endpoint="ui.metrics_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.metrics_dashboard" %}active{% endif %}" data-tooltip="Metrics">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"/>
|
||||
<path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"/>
|
||||
</svg>
|
||||
<span class="sidebar-link-text">Metrics</span>
|
||||
</a>
|
||||
<a href="{{ url_for(endpoint="ui.system_dashboard") }}" class="sidebar-link {% if current_endpoint == "ui.system_dashboard" %}active{% endif %}" data-tooltip="System">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/>
|
||||
|
||||
461
crates/myfsio-server/templates/cluster.html
Normal file
461
crates/myfsio-server/templates/cluster.html
Normal file
@@ -0,0 +1,461 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Cluster - S3 Compatible Storage{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<p class="text-uppercase text-muted small mb-1">Cluster Overview</p>
|
||||
<h1 class="h3 mb-1 d-flex align-items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||
<path d="M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434L7.752.066ZM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567L4.25 7.504ZM7.5 9.933l-2.75 1.571v3.134l2.75-1.571V9.933Zm1 3.134 2.75 1.571v-3.134L8.5 9.933v3.134Zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567-2.742 1.567Zm2.242-2.433V3.504L8.5 5.076V8.21l2.75-1.572ZM7.5 8.21V5.076L4.75 3.504v3.134L7.5 8.21ZM5.258 2.643 8 4.21l2.742-1.567L8 1.076 5.258 2.643ZM15 9.933l-2.75 1.571v3.134L15 13.067V9.933ZM3.75 14.638v-3.134L1 9.933v3.134l2.75 1.571Z"/>
|
||||
</svg>
|
||||
Cluster
|
||||
</h1>
|
||||
<p class="text-muted mb-0 mt-1">Live view across this site and every registered peer.</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-success bg-opacity-10 text-success fs-6 px-3 py-2" id="cluster-online-badge">
|
||||
{{ cluster_online_count }} / {{ cluster_total_count }} online
|
||||
</span>
|
||||
<span class="text-muted small d-none d-md-inline" id="cluster-updated-at" title="Last refresh">just now</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1" id="cluster-refresh-btn" title="Refresh now (bypass 10s cache)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" id="cluster-refresh-icon">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center justify-content-center rounded-3 bg-primary bg-opacity-10 text-primary" style="width:44px;height:44px;flex-shrink:0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434L7.752.066Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-uppercase text-muted small">Sites</div>
|
||||
<div class="h3 mb-0" id="cluster-total-sites">{{ cluster_total_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center justify-content-center rounded-3 bg-info bg-opacity-10 text-info" style="width:44px;height:44px;flex-shrink:0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-uppercase text-muted small">Buckets</div>
|
||||
<div class="h3 mb-0" id="cluster-total-buckets">{{ cluster_total_buckets }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center justify-content-center rounded-3 bg-warning bg-opacity-10 text-warning" style="width:44px;height:44px;flex-shrink:0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-uppercase text-muted small">Objects</div>
|
||||
<div class="h3 mb-0" id="cluster-total-objects">{{ cluster_total_objects }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<div class="card shadow-sm border-0 h-100" style="border-radius: 1rem;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="d-flex align-items-center justify-content-center rounded-3 bg-success bg-opacity-10 text-success" style="width:44px;height:44px;flex-shrink:0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M0 10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8H0v2zm1.5 1a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm2 0a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zM0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3H0V4z"/>
|
||||
<path d="M1.5 6a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zm2 0a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-uppercase text-muted small">Size</div>
|
||||
<div class="h3 mb-0" id="cluster-total-size" data-bytes="{{ cluster_total_size_bytes }}">{{ cluster_total_size_bytes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4" id="cluster-sites-row">
|
||||
{% for site in cluster_sites %}
|
||||
<div class="col-xl-6">
|
||||
<div class="card shadow-sm border-0 h-100 site-card" data-site-id="{{ site.site_id }}" style="border-radius: 1rem;">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="badge bg-success bg-opacity-10 text-success site-status-online {% if not site.online %}d-none{% endif %}">
|
||||
<span class="d-inline-block rounded-circle bg-success me-1" style="width:6px;height:6px;"></span>online
|
||||
</span>
|
||||
<span class="badge bg-danger bg-opacity-10 text-danger site-status-offline {% if site.online %}d-none{% endif %}">
|
||||
<span class="d-inline-block rounded-circle bg-danger me-1" style="width:6px;height:6px;"></span>offline
|
||||
</span>
|
||||
<span class="badge bg-warning bg-opacity-10 text-warning site-status-stale {% if not site.stale %}d-none{% endif %}" title="Could not reach peer">stale</span>
|
||||
{% if site.is_local %}
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary">this site</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5 class="fw-semibold mb-0 text-truncate">
|
||||
{% if site.display_name and site.display_name != "" %}{{ site.display_name }}{% else %}{{ site.site_id }}{% endif %}
|
||||
</h5>
|
||||
<div class="text-muted small">
|
||||
<span class="font-monospace">{{ site.site_id }}</span>
|
||||
{% if site.region %} · {{ site.region }}{% elif site.registered_region %} · {{ site.registered_region }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if site.endpoint %}
|
||||
<code class="small text-muted text-end ms-2" style="word-break:break-all;">{{ site.endpoint }}</code>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="site-online-content {% if not site.online %}d-none{% endif %}">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-4">
|
||||
<div class="text-uppercase text-muted small mb-1 d-flex align-items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="currentColor" viewBox="0 0 16 16"><path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/></svg>
|
||||
Buckets
|
||||
</div>
|
||||
<div class="h4 mb-0 site-buckets">{{ site.buckets | default(value=0) }}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="text-uppercase text-muted small mb-1 d-flex align-items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="currentColor" viewBox="0 0 16 16"><path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2z"/></svg>
|
||||
Objects
|
||||
</div>
|
||||
<div class="h4 mb-0 site-objects">{{ site.objects | default(value=0) }}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="text-uppercase text-muted small mb-1 d-flex align-items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="currentColor" viewBox="0 0 16 16"><path d="M0 10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8H0v2zM0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v3H0V4z"/></svg>
|
||||
Size
|
||||
</div>
|
||||
<div class="h4 mb-0 site-size" data-bytes="{{ site.size_bytes | default(value=0) }}">{{ site.size_bytes | default(value=0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if site.capacity %}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="text-uppercase text-muted small">Disk Capacity</span>
|
||||
<span class="small text-muted">
|
||||
<span class="site-disk-used" data-bytes="0">0</span> / <span class="site-disk-total" data-bytes="{{ site.capacity.total_bytes | default(value=0) }}">{{ site.capacity.total_bytes | default(value=0) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress" style="height:6px;border-radius:3px;">
|
||||
<div class="progress-bar bg-primary site-disk-bar" role="progressbar" style="width:0%;" data-total="{{ site.capacity.total_bytes | default(value=0) }}" data-available="{{ site.capacity.available_bytes | default(value=0) }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if site.system and site.system.cpu_percent is defined %}
|
||||
<div class="mb-3">
|
||||
<div class="text-uppercase text-muted small mb-2">System</div>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">CPU</span>
|
||||
<span class="site-cpu-label">{{ site.system.cpu_percent | default(value=0) }}%</span>
|
||||
</div>
|
||||
<div class="progress" style="height:4px;border-radius:2px;">
|
||||
<div class="progress-bar site-cpu-bar" role="progressbar" style="width:{{ site.system.cpu_percent | default(value=0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Memory</span>
|
||||
<span class="site-mem-label">{{ site.system.memory_percent | default(value=0) }}%</span>
|
||||
</div>
|
||||
<div class="progress" style="height:4px;border-radius:2px;">
|
||||
<div class="progress-bar site-mem-bar" role="progressbar" style="width:{{ site.system.memory_percent | default(value=0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span class="text-muted">Disk</span>
|
||||
<span class="site-diskpct-label">{{ site.system.disk_percent | default(value=0) }}%</span>
|
||||
</div>
|
||||
<div class="progress" style="height:4px;border-radius:2px;">
|
||||
<div class="progress-bar site-diskpct-bar" role="progressbar" style="width:{{ site.system.disk_percent | default(value=0) }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if site.sync %}
|
||||
<div class="d-flex align-items-center justify-content-between border-top pt-3">
|
||||
<div class="d-flex align-items-center gap-2 small">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
<span class="text-muted">Sync</span>
|
||||
<span class="site-sync-label">
|
||||
{% if site.sync.last_sync_at %}
|
||||
<span data-last-sync-at="{{ site.sync.last_sync_at }}">last sync <span class="last-sync-rel">just now</span></span>
|
||||
{% else %}
|
||||
<span class="text-muted">no sync yet</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<span class="badge bg-danger bg-opacity-10 text-danger site-sync-errors {% if not site.sync.errors or site.sync.errors == 0 %}d-none{% endif %}">
|
||||
<span class="site-sync-errors-count">{{ site.sync.errors | default(value=0) }}</span> err
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="site-offline-content {% if site.online %}d-none{% endif %}">
|
||||
<div class="alert alert-light border-0 mb-0 py-2 px-3 small">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1 text-warning" viewBox="0 0 16 16">
|
||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
||||
</svg>
|
||||
<span class="site-offline-message">{% if site.error %}{{ site.error }}{% else %}Peer unreachable.{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if cluster_total_count <= 1 %}
|
||||
<div class="col-xl-6">
|
||||
<a href="{{ url_for(endpoint='ui.sites_dashboard') }}" class="card shadow-sm border-0 h-100 text-decoration-none text-reset" style="border-radius: 1rem; border: 2px dashed var(--bs-border-color) !important;">
|
||||
<div class="card-body d-flex flex-column align-items-center justify-content-center text-center p-5">
|
||||
<div class="d-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:64px;height:64px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h5 class="fw-semibold mb-1">Add a peer site</h5>
|
||||
<p class="text-muted small mb-0">Register another MyFSIO instance to see it appear here side-by-side.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function fmtBytes(n) {
|
||||
if (!n || n < 0) return "0 B";
|
||||
var u = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
var i = 0;
|
||||
var v = n;
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
||||
return v.toFixed(i === 0 ? 0 : (v >= 100 ? 0 : 1)) + " " + u[i];
|
||||
}
|
||||
function fmtRel(ts) {
|
||||
var diff = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (diff < 60) return diff + "s ago";
|
||||
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
|
||||
return Math.floor(diff / 86400) + "d ago";
|
||||
}
|
||||
function pctColor(p) {
|
||||
if (p >= 80) return "bg-danger";
|
||||
if (p >= 60) return "bg-warning";
|
||||
return "bg-success";
|
||||
}
|
||||
function applyBytesFormat(root) {
|
||||
(root || document).querySelectorAll("[data-bytes]").forEach(function (el) {
|
||||
var n = parseInt(el.getAttribute("data-bytes"), 10);
|
||||
if (!isNaN(n)) el.textContent = fmtBytes(n);
|
||||
});
|
||||
}
|
||||
function applyDiskBars(root) {
|
||||
(root || document).querySelectorAll(".site-disk-bar").forEach(function (bar) {
|
||||
var total = parseFloat(bar.getAttribute("data-total")) || 0;
|
||||
var avail = parseFloat(bar.getAttribute("data-available")) || 0;
|
||||
var used = Math.max(0, total - avail);
|
||||
var pct = total > 0 ? (used / total) * 100 : 0;
|
||||
bar.style.width = pct.toFixed(1) + "%";
|
||||
bar.classList.remove("bg-success", "bg-warning", "bg-danger", "bg-primary");
|
||||
bar.classList.add(pctColor(pct));
|
||||
var card = bar.closest(".site-card");
|
||||
if (card) {
|
||||
var usedEl = card.querySelector(".site-disk-used");
|
||||
if (usedEl) {
|
||||
usedEl.setAttribute("data-bytes", String(Math.floor(used)));
|
||||
usedEl.textContent = fmtBytes(used);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function applyPctBars(root) {
|
||||
var pairs = [
|
||||
[".site-cpu-bar", ".site-cpu-label"],
|
||||
[".site-mem-bar", ".site-mem-label"],
|
||||
[".site-diskpct-bar", ".site-diskpct-label"],
|
||||
];
|
||||
pairs.forEach(function (sel) {
|
||||
(root || document).querySelectorAll(sel[0]).forEach(function (bar) {
|
||||
var label = bar.closest(".site-card").querySelector(sel[1]);
|
||||
var pct = parseFloat(label ? label.textContent : "0") || 0;
|
||||
bar.classList.remove("bg-success", "bg-warning", "bg-danger");
|
||||
bar.classList.add(pctColor(pct));
|
||||
});
|
||||
});
|
||||
}
|
||||
function refreshRel() {
|
||||
document.querySelectorAll("[data-last-sync-at]").forEach(function (el) {
|
||||
var ts = parseFloat(el.getAttribute("data-last-sync-at"));
|
||||
var span = el.querySelector(".last-sync-rel");
|
||||
if (span && !isNaN(ts)) span.textContent = fmtRel(ts);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCard(card, site) {
|
||||
if (!card) return;
|
||||
var online = !!site.online;
|
||||
var stale = !!site.stale;
|
||||
|
||||
function toggle(sel, show) {
|
||||
var el = card.querySelector(sel);
|
||||
if (el) el.classList.toggle("d-none", !show);
|
||||
}
|
||||
toggle(".site-status-online", online);
|
||||
toggle(".site-status-offline", !online);
|
||||
toggle(".site-status-stale", stale);
|
||||
toggle(".site-online-content", online);
|
||||
toggle(".site-offline-content", !online);
|
||||
|
||||
if (online) {
|
||||
var setNum = function (sel, val) {
|
||||
var el = card.querySelector(sel);
|
||||
if (el) el.textContent = String(val == null ? 0 : val);
|
||||
};
|
||||
setNum(".site-buckets", site.buckets);
|
||||
setNum(".site-objects", site.objects);
|
||||
var sizeEl = card.querySelector(".site-size");
|
||||
if (sizeEl) {
|
||||
sizeEl.setAttribute("data-bytes", String(site.size_bytes || 0));
|
||||
sizeEl.textContent = fmtBytes(site.size_bytes || 0);
|
||||
}
|
||||
var capacity = site.capacity || {};
|
||||
var diskBar = card.querySelector(".site-disk-bar");
|
||||
if (diskBar) {
|
||||
diskBar.setAttribute("data-total", String(capacity.total_bytes || 0));
|
||||
diskBar.setAttribute("data-available", String(capacity.available_bytes || 0));
|
||||
}
|
||||
var diskTotalEl = card.querySelector(".site-disk-total");
|
||||
if (diskTotalEl) {
|
||||
diskTotalEl.setAttribute("data-bytes", String(capacity.total_bytes || 0));
|
||||
diskTotalEl.textContent = fmtBytes(capacity.total_bytes || 0);
|
||||
}
|
||||
var sys = site.system || {};
|
||||
var setPct = function (labelSel, val) {
|
||||
var label = card.querySelector(labelSel);
|
||||
if (label) label.textContent = (val == null ? 0 : val) + "%";
|
||||
};
|
||||
setPct(".site-cpu-label", sys.cpu_percent);
|
||||
setPct(".site-mem-label", sys.memory_percent);
|
||||
setPct(".site-diskpct-label", sys.disk_percent);
|
||||
var setBarPct = function (sel, val) {
|
||||
var bar = card.querySelector(sel);
|
||||
if (bar) bar.style.width = (val == null ? 0 : val) + "%";
|
||||
};
|
||||
setBarPct(".site-cpu-bar", sys.cpu_percent);
|
||||
setBarPct(".site-mem-bar", sys.memory_percent);
|
||||
setBarPct(".site-diskpct-bar", sys.disk_percent);
|
||||
|
||||
var sync = site.sync || {};
|
||||
var syncLabel = card.querySelector(".site-sync-label");
|
||||
if (syncLabel) {
|
||||
if (sync.last_sync_at) {
|
||||
syncLabel.innerHTML = '<span data-last-sync-at="' + sync.last_sync_at + '">last sync <span class="last-sync-rel">' + fmtRel(sync.last_sync_at) + '</span></span>';
|
||||
} else {
|
||||
syncLabel.innerHTML = '<span class="text-muted">no sync yet</span>';
|
||||
}
|
||||
}
|
||||
var errBadge = card.querySelector(".site-sync-errors");
|
||||
var errCount = sync.errors || 0;
|
||||
if (errBadge) {
|
||||
errBadge.classList.toggle("d-none", errCount === 0);
|
||||
var c = errBadge.querySelector(".site-sync-errors-count");
|
||||
if (c) c.textContent = errCount;
|
||||
}
|
||||
} else {
|
||||
var msg = card.querySelector(".site-offline-message");
|
||||
if (msg) msg.textContent = site.error || "Peer unreachable.";
|
||||
}
|
||||
}
|
||||
|
||||
function poll(force) {
|
||||
var url = "/ui/cluster/data" + (force ? "?force=1" : "");
|
||||
var icon = document.getElementById("cluster-refresh-icon");
|
||||
var btn = document.getElementById("cluster-refresh-btn");
|
||||
if (force && icon) icon.classList.add("spin");
|
||||
if (force && btn) btn.disabled = true;
|
||||
return fetch(url, { credentials: "same-origin", cache: "no-store" })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
var totals = data.totals || {};
|
||||
var setTotal = function (id, v) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = String(v == null ? 0 : v);
|
||||
};
|
||||
setTotal("cluster-total-sites", totals.total_count);
|
||||
setTotal("cluster-total-buckets", totals.buckets);
|
||||
setTotal("cluster-total-objects", totals.objects);
|
||||
var sizeEl = document.getElementById("cluster-total-size");
|
||||
if (sizeEl) {
|
||||
sizeEl.setAttribute("data-bytes", String(totals.size_bytes || 0));
|
||||
sizeEl.textContent = fmtBytes(totals.size_bytes || 0);
|
||||
}
|
||||
var onlineBadge = document.getElementById("cluster-online-badge");
|
||||
if (onlineBadge) onlineBadge.textContent = (totals.online_count || 0) + " / " + (totals.total_count || 0) + " online";
|
||||
|
||||
(data.sites || []).forEach(function (site) {
|
||||
var card = document.querySelector('.site-card[data-site-id="' + site.site_id + '"]');
|
||||
if (card) updateCard(card, site);
|
||||
});
|
||||
|
||||
applyDiskBars();
|
||||
applyPctBars();
|
||||
var stamp = document.getElementById("cluster-updated-at");
|
||||
if (stamp) stamp.textContent = "updated " + new Date().toLocaleTimeString();
|
||||
})
|
||||
.catch(function () { /* silent — keep last good state */ })
|
||||
.finally(function () {
|
||||
if (icon) icon.classList.remove("spin");
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
applyBytesFormat();
|
||||
applyDiskBars();
|
||||
applyPctBars();
|
||||
refreshRel();
|
||||
setInterval(refreshRel, 5000);
|
||||
setInterval(function () { poll(false); }, 10000);
|
||||
|
||||
var refreshBtn = document.getElementById("cluster-refresh-btn");
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", function () { poll(true); });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
@keyframes cluster-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spin { animation: cluster-spin 0.8s linear infinite; transform-origin: 50% 50%; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -257,7 +257,16 @@
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if item.errors and item.errors > 0 %}
|
||||
<span class="badge bg-danger bg-opacity-10 text-danger" title="Sync errors across bidirectional buckets">{{ item.errors }} err</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.last_sync_at %}
|
||||
<div class="text-muted small mt-1" data-last-sync-at="{{ item.last_sync_at }}">
|
||||
last sync: <span class="last-sync-rel">just now</span>
|
||||
{% if item.objects_pulled %} · {{ item.objects_pulled }} pulled{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="sync-stats-detail d-none mt-2 small" id="stats-{{ peer.site_id }}">
|
||||
<span class="spinner-border spinner-border-sm text-muted" style="width: 12px; height: 12px;"></span>
|
||||
</div>
|
||||
@@ -870,6 +879,25 @@
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(function () {
|
||||
function fmtRel(ts) {
|
||||
var diff = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (diff < 60) return diff + "s ago";
|
||||
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
|
||||
return Math.floor(diff / 86400) + "d ago";
|
||||
}
|
||||
function refresh() {
|
||||
document.querySelectorAll("[data-last-sync-at]").forEach(function (el) {
|
||||
var ts = parseFloat(el.getAttribute("data-last-sync-at"));
|
||||
var span = el.querySelector(".last-sync-rel");
|
||||
if (span && !isNaN(ts)) span.textContent = fmtRel(ts);
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -219,6 +219,59 @@ fn render_sites() {
|
||||
render_or_panic("sites.html", &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_cluster_empty() {
|
||||
let mut ctx = base_ctx();
|
||||
ctx.insert("cluster_sites", &Vec::<Value>::new());
|
||||
ctx.insert("cluster_total_buckets", &0u64);
|
||||
ctx.insert("cluster_total_objects", &0u64);
|
||||
ctx.insert("cluster_total_size_bytes", &0u64);
|
||||
ctx.insert("cluster_online_count", &0usize);
|
||||
ctx.insert("cluster_total_count", &0usize);
|
||||
render_or_panic("cluster.html", &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_cluster_with_sites() {
|
||||
let mut ctx = base_ctx();
|
||||
let sites = json!([
|
||||
{
|
||||
"site_id": "local-1",
|
||||
"display_name": "Local",
|
||||
"endpoint": "http://127.0.0.1:8000",
|
||||
"region": "us-east-1",
|
||||
"online": true,
|
||||
"stale": false,
|
||||
"is_local": true,
|
||||
"buckets": 3,
|
||||
"objects": 42,
|
||||
"size_bytes": 1048576,
|
||||
"capacity": {"total_bytes": 100000000, "available_bytes": 50000000},
|
||||
"system": {"cpu_percent": 12.5, "memory_percent": 33.0, "disk_percent": 50.0, "storage_bytes": 1048576},
|
||||
"sync": {"errors": 0, "last_sync_at": 1700000000.0},
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"site_id": "peer-1",
|
||||
"display_name": "Peer",
|
||||
"endpoint": "http://peer.example.com",
|
||||
"online": false,
|
||||
"stale": true,
|
||||
"is_local": false,
|
||||
"registered_region": "us-west-2",
|
||||
"registered_priority": 100,
|
||||
"error": "request failed: timeout"
|
||||
}
|
||||
]);
|
||||
ctx.insert("cluster_sites", &sites);
|
||||
ctx.insert("cluster_total_buckets", &3u64);
|
||||
ctx.insert("cluster_total_objects", &42u64);
|
||||
ctx.insert("cluster_total_size_bytes", &1048576u64);
|
||||
ctx.insert("cluster_online_count", &1usize);
|
||||
ctx.insert("cluster_total_count", &2usize);
|
||||
render_or_panic("cluster.html", &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_website_domains() {
|
||||
let mut ctx = base_ctx();
|
||||
|
||||
Reference in New Issue
Block a user