Add Cluster feature

This commit is contained in:
2026-04-26 19:24:18 +08:00
parent b5facd8d37
commit 6ba948bcc0
14 changed files with 1162 additions and 26 deletions

View File

@@ -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()
}

View File

@@ -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,
}),
)
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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;

View 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, &region, 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)
}
}

View File

@@ -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")

View File

@@ -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;

View File

@@ -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)),
}
}

View File

@@ -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;

View File

@@ -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"/>

View 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 %}

View File

@@ -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>

View File

@@ -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();