mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-06 14:31:57 +02:00
* rust volume: share the I/O-error tracker between Volume and EcVolume Volume and EcVolume each carried the same three fields - a mutex-held last error, a consecutive count and a sticky quarantine flag - and the same four methods over them, identical except for the path qualifier on is_storage_io_error. The tolerance the count is compared against was a fourth copy: heartbeat.rs held VOLUME_IO_ERROR_TOLERANCE for volumes, ec_volume.rs held IO_ERROR_TOLERANCE for EC, and the volume test helper open-coded the same 3, so the two paths could drift apart silently. Go keeps this in one place already: weed/storage/io_error.go holds IoErrorTracker, IoErrorTolerance and isStorageIoError, and Volume embeds the tracker. Go's EcVolume has to re-implement it only because those fields are unexported and EC lives in another package. storage::io_error::IoErrorTracker now owns that state, with record / state / should_quarantine / mark_quarantined / reset and the single IO_ERROR_TOLERANCE. is_storage_io_error moves into the same file, so it sits with the tracker that is now its only caller, the way io_error.go is laid out. Both volume kinds embed one tracker and keep their existing method names as delegates, so the ~16 internal call sites and the readers in heartbeat.rs, store.rs and grpc_server.rs change only where the two threshold comparisons become should_quarantine(). Volume::last_io_error and EcVolume::reset_io_error_state had no callers and are gone. Unchanged: what counts as a storage-media error - is_storage_io_error changed file, not body, and is still the single predicate both volume kinds share, where Go's EcVolume tests EIO directly and so misses the Windows codes. Also unchanged: the tolerance value, the metric increment on every counted error, and the sticky quarantine - a success clears the count and the last error but never the flag, which only reset lifts. In the heartbeat the state read moved inside the quarantine branch, so the common path no longer takes the tracker's mutex or clones the last-error string; should_quarantine's two relaxed loads run either way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * rust volume: hoist absolute_display_path into server handlers.rs and ui.rs each held a byte-identical copy of the helper that turns a configured -dir into an absolute path for display. The status JSON and the status page are meant to show the same directory, so the two copies had to be edited together to stay that way. The helper now lives in server/mod.rs as pub(crate) and both callers use it. No behaviour change: same body, same call sites. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * rust volume: keep EcVolume::reset_io_error_state Moving both volume types onto the shared IoErrorTracker dropped EcVolume's public reset while Volume kept its own, so the two sides of the tracker drifted apart. mark_quarantined is sticky: a later successful read clears the error count through record(), but the quarantine flag only comes down through reset(). Without the delegate an EC volume that hit sustained media errors could not be returned to service in place once the storage was repaired. Go exposes the same method as EcVolume.ResetIoErrorState (weed/storage/erasure_coding/ec_volume.go:114). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * rust volume: name the shared tracker after Go's IoErrorTracker - check_read_write_error, get_io_error_state, mark_io_quarantined, reset_io_error_state match weed/storage/io_error.go one to one - io_error module is pub(crate) like the io module beside it - restore EcVolume::reset_io_error_state so both volume kinds expose the same recovery surface - trim comments that restate the code Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
498 lines
16 KiB
Rust
498 lines
16 KiB
Rust
use std::fmt::Write as _;
|
|
|
|
use crate::server::absolute_display_path;
|
|
use crate::server::server_stats;
|
|
use crate::server::volume_server::VolumeServerState;
|
|
use crate::storage::store::Store;
|
|
|
|
pub struct EmbeddedAsset {
|
|
pub content_type: &'static str,
|
|
pub bytes: &'static [u8],
|
|
}
|
|
|
|
struct UiDiskRow {
|
|
dir: String,
|
|
disk_type: String,
|
|
all: u64,
|
|
free: u64,
|
|
used: u64,
|
|
}
|
|
|
|
struct UiVolumeRow {
|
|
id: u32,
|
|
collection: String,
|
|
disk_type: String,
|
|
size: u64,
|
|
file_count: i64,
|
|
delete_count: i64,
|
|
deleted_byte_count: u64,
|
|
ttl: String,
|
|
read_only: bool,
|
|
version: u32,
|
|
remote_storage_name: String,
|
|
remote_storage_key: String,
|
|
}
|
|
|
|
struct UiEcShardRow {
|
|
shard_id: u8,
|
|
size: u64,
|
|
}
|
|
|
|
struct UiEcVolumeRow {
|
|
volume_id: u32,
|
|
collection: String,
|
|
size: u64,
|
|
shards: Vec<UiEcShardRow>,
|
|
created_at: String,
|
|
}
|
|
|
|
pub fn favicon_asset() -> EmbeddedAsset {
|
|
EmbeddedAsset {
|
|
content_type: "image/x-icon",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/favicon.ico"
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn lookup_static_asset(path: &str) -> Option<EmbeddedAsset> {
|
|
let path = path.trim_start_matches('/');
|
|
let asset = match path {
|
|
"bootstrap/3.3.1/css/bootstrap.min.css" => EmbeddedAsset {
|
|
content_type: "text/css; charset=utf-8",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/bootstrap/3.3.1/css/bootstrap.min.css"
|
|
)),
|
|
},
|
|
"bootstrap/3.3.1/fonts/glyphicons-halflings-regular.eot" => EmbeddedAsset {
|
|
content_type: "application/vnd.ms-fontobject",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.eot"
|
|
)),
|
|
},
|
|
"bootstrap/3.3.1/fonts/glyphicons-halflings-regular.svg" => EmbeddedAsset {
|
|
content_type: "image/svg+xml",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.svg"
|
|
)),
|
|
},
|
|
"bootstrap/3.3.1/fonts/glyphicons-halflings-regular.ttf" => EmbeddedAsset {
|
|
content_type: "font/ttf",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.ttf"
|
|
)),
|
|
},
|
|
"bootstrap/3.3.1/fonts/glyphicons-halflings-regular.woff" => EmbeddedAsset {
|
|
content_type: "font/woff",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.woff"
|
|
)),
|
|
},
|
|
"images/folder.gif" => EmbeddedAsset {
|
|
content_type: "image/gif",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/images/folder.gif"
|
|
)),
|
|
},
|
|
"javascript/jquery-3.6.0.min.js" => EmbeddedAsset {
|
|
content_type: "application/javascript; charset=utf-8",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/javascript/jquery-3.6.0.min.js"
|
|
)),
|
|
},
|
|
"javascript/jquery-sparklines/2.1.2/jquery.sparkline.min.js" => EmbeddedAsset {
|
|
content_type: "application/javascript; charset=utf-8",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/javascript/jquery-sparklines/2.1.2/jquery.sparkline.min.js"
|
|
)),
|
|
},
|
|
"seaweed50x50.png" => EmbeddedAsset {
|
|
content_type: "image/png",
|
|
bytes: include_bytes!(concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../weed/static/seaweed50x50.png"
|
|
)),
|
|
},
|
|
_ => return None,
|
|
};
|
|
Some(asset)
|
|
}
|
|
|
|
pub fn render_volume_server_html(state: &VolumeServerState) -> String {
|
|
let counters = server_stats::snapshot();
|
|
let (disk_rows, volume_rows, remote_volume_rows, ec_volume_rows) = {
|
|
let store = state.store.read().unwrap();
|
|
collect_ui_data(&store)
|
|
};
|
|
|
|
let masters = if state.master_urls.is_empty() {
|
|
"[]".to_string()
|
|
} else {
|
|
format!("[{}]", state.master_urls.join(" "))
|
|
};
|
|
let uptime = server_stats::uptime_string();
|
|
let read_week = join_i64(&counters.read_requests.week_counter.to_list());
|
|
let read_day = join_i64(&counters.read_requests.day_counter.to_list());
|
|
let read_hour = join_i64(&counters.read_requests.hour_counter.to_list());
|
|
let read_minute = join_i64(&counters.read_requests.minute_counter.to_list());
|
|
|
|
let mut disk_rows_html = String::new();
|
|
for disk in &disk_rows {
|
|
let _ = write!(
|
|
disk_rows_html,
|
|
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{:.2}%</td></tr>",
|
|
escape_html(&disk.dir),
|
|
escape_html(&disk.disk_type),
|
|
bytes_to_human_readable(disk.all),
|
|
bytes_to_human_readable(disk.free),
|
|
percent_from(disk.all, disk.used),
|
|
);
|
|
}
|
|
|
|
let mut volume_rows_html = String::new();
|
|
for volume in &volume_rows {
|
|
let _ = write!(
|
|
volume_rows_html,
|
|
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{} / {}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
|
volume.id,
|
|
escape_html(&volume.collection),
|
|
escape_html(&volume.disk_type),
|
|
bytes_to_human_readable(volume.size),
|
|
volume.file_count,
|
|
volume.delete_count,
|
|
bytes_to_human_readable(volume.deleted_byte_count),
|
|
escape_html(&volume.ttl),
|
|
volume.read_only,
|
|
volume.version,
|
|
);
|
|
}
|
|
|
|
let remote_section = if remote_volume_rows.is_empty() {
|
|
String::new()
|
|
} else {
|
|
let mut remote_rows_html = String::new();
|
|
for volume in &remote_volume_rows {
|
|
let _ = write!(
|
|
remote_rows_html,
|
|
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td>{} / {}</td><td>{}</td><td>{}</td></tr>",
|
|
volume.id,
|
|
escape_html(&volume.collection),
|
|
bytes_to_human_readable(volume.size),
|
|
volume.file_count,
|
|
volume.delete_count,
|
|
bytes_to_human_readable(volume.deleted_byte_count),
|
|
escape_html(&volume.remote_storage_name),
|
|
escape_html(&volume.remote_storage_key),
|
|
);
|
|
}
|
|
format!(
|
|
r#"<div class="row">
|
|
<h2>Remote Volumes</h2>
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Id</th>
|
|
<th>Collection</th>
|
|
<th>Size</th>
|
|
<th>Files</th>
|
|
<th>Trash</th>
|
|
<th>Remote</th>
|
|
<th>Key</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{}</tbody>
|
|
</table>
|
|
</div>"#,
|
|
remote_rows_html
|
|
)
|
|
};
|
|
|
|
let ec_section = if ec_volume_rows.is_empty() {
|
|
String::new()
|
|
} else {
|
|
let mut ec_rows_html = String::new();
|
|
for ec in &ec_volume_rows {
|
|
let mut shard_labels = String::new();
|
|
for shard in &ec.shards {
|
|
let _ = write!(
|
|
shard_labels,
|
|
"<span class=\"label label-info\" style=\"margin-right: 5px;\">{}: {}</span>",
|
|
shard.shard_id,
|
|
bytes_to_human_readable(shard.size)
|
|
);
|
|
}
|
|
let _ = write!(
|
|
ec_rows_html,
|
|
"<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
|
ec.volume_id,
|
|
escape_html(&ec.collection),
|
|
bytes_to_human_readable(ec.size),
|
|
shard_labels,
|
|
escape_html(&ec.created_at),
|
|
);
|
|
}
|
|
format!(
|
|
r#"<div class="row">
|
|
<h2>Erasure Coding Shards</h2>
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Id</th>
|
|
<th>Collection</th>
|
|
<th>Total Size</th>
|
|
<th>Shard Details</th>
|
|
<th>CreatedAt</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{}</tbody>
|
|
</table>
|
|
</div>"#,
|
|
ec_rows_html
|
|
)
|
|
};
|
|
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>SeaweedFS {version}</title>
|
|
<link rel="stylesheet" href="/seaweedfsstatic/bootstrap/3.3.1/css/bootstrap.min.css">
|
|
<script type="text/javascript" src="/seaweedfsstatic/javascript/jquery-3.6.0.min.js"></script>
|
|
<script type="text/javascript" src="/seaweedfsstatic/javascript/jquery-sparklines/2.1.2/jquery.sparkline.min.js"></script>
|
|
<script type="text/javascript">
|
|
$(function () {{
|
|
var periods = ['second', 'minute', 'hour', 'day'];
|
|
for (var i = 0; i < periods.length; i++) {{
|
|
var period = periods[i];
|
|
$('.inlinesparkline-' + period).sparkline('html', {{
|
|
type: 'line',
|
|
barColor: 'red',
|
|
tooltipSuffix: ' request per ' + period
|
|
}});
|
|
}}
|
|
}});
|
|
</script>
|
|
<style>
|
|
#jqstooltip {{
|
|
height: 28px !important;
|
|
width: 150px !important;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="page-header">
|
|
<h1>
|
|
<a href="https://github.com/seaweedfs/seaweedfs"><img src="/seaweedfsstatic/seaweed50x50.png" alt="SeaweedFS"></a>
|
|
SeaweedFS <small>{version}</small>
|
|
</h1>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-sm-6">
|
|
<h2>Disk Stats</h2>
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Path</th>
|
|
<th>Disk</th>
|
|
<th>Total</th>
|
|
<th>Free</th>
|
|
<th>Usage</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{disk_rows_html}</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="col-sm-6">
|
|
<h2>System Stats</h2>
|
|
<table class="table table-condensed table-striped">
|
|
<tr><th>Masters</th><td>{masters}</td></tr>
|
|
<tr><th>Weekly # ReadRequests</th><td><span class="inlinesparkline-day">{read_week}</span></td></tr>
|
|
<tr><th>Daily # ReadRequests</th><td><span class="inlinesparkline-hour">{read_day}</span></td></tr>
|
|
<tr><th>Hourly # ReadRequests</th><td><span class="inlinesparkline-minute">{read_hour}</span></td></tr>
|
|
<tr><th>Last Minute # ReadRequests</th><td><span class="inlinesparkline-second">{read_minute}</span></td></tr>
|
|
<tr><th>Up Time</th><td>{uptime}</td></tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<h2>Volumes</h2>
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Id</th>
|
|
<th>Collection</th>
|
|
<th>Disk</th>
|
|
<th>Data Size</th>
|
|
<th>Files</th>
|
|
<th>Trash</th>
|
|
<th>TTL</th>
|
|
<th>ReadOnly</th>
|
|
<th>Version</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{volume_rows_html}</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{remote_section}
|
|
{ec_section}
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
version = escape_html(crate::version::version()),
|
|
disk_rows_html = disk_rows_html,
|
|
masters = escape_html(&masters),
|
|
read_week = read_week,
|
|
read_day = read_day,
|
|
read_hour = read_hour,
|
|
read_minute = read_minute,
|
|
uptime = escape_html(&uptime),
|
|
volume_rows_html = volume_rows_html,
|
|
remote_section = remote_section,
|
|
ec_section = ec_section,
|
|
)
|
|
}
|
|
|
|
fn collect_ui_data(
|
|
store: &Store,
|
|
) -> (
|
|
Vec<UiDiskRow>,
|
|
Vec<UiVolumeRow>,
|
|
Vec<UiVolumeRow>,
|
|
Vec<UiEcVolumeRow>,
|
|
) {
|
|
let mut disk_rows = Vec::new();
|
|
let mut volumes = Vec::new();
|
|
let mut remote_volumes = Vec::new();
|
|
let mut ec_volumes = Vec::new();
|
|
|
|
for loc in &store.locations {
|
|
let dir = absolute_display_path(&loc.directory);
|
|
let (all, free) = crate::storage::disk_location::get_disk_stats(&dir);
|
|
disk_rows.push(UiDiskRow {
|
|
dir,
|
|
disk_type: loc.disk_type.to_string(),
|
|
all,
|
|
free,
|
|
used: all.saturating_sub(free),
|
|
});
|
|
|
|
for (_, volume) in loc.volumes() {
|
|
let (remote_storage_name, remote_storage_key) = volume.remote_storage_name_key();
|
|
let row = UiVolumeRow {
|
|
id: volume.id.0,
|
|
collection: volume.collection.clone(),
|
|
disk_type: loc.disk_type.to_string(),
|
|
size: volume.content_size(),
|
|
file_count: volume.file_count(),
|
|
delete_count: volume.deleted_count(),
|
|
deleted_byte_count: volume.deleted_size(),
|
|
ttl: volume.super_block.ttl.to_string(),
|
|
read_only: volume.is_read_only(),
|
|
version: volume.version().0 as u32,
|
|
remote_storage_name,
|
|
remote_storage_key,
|
|
};
|
|
if row.remote_storage_name.is_empty() {
|
|
volumes.push(row);
|
|
} else {
|
|
remote_volumes.push(row);
|
|
}
|
|
}
|
|
|
|
for (_, ec_volume) in loc.ec_volumes() {
|
|
let mut shards = Vec::new();
|
|
let mut total_size = 0u64;
|
|
let mut created_at = String::from("-");
|
|
for shard in ec_volume.shards.iter().flatten() {
|
|
let shard_size = shard.file_size().max(0) as u64;
|
|
total_size = total_size.saturating_add(shard_size);
|
|
shards.push(UiEcShardRow {
|
|
shard_id: shard.shard_id,
|
|
size: shard_size,
|
|
});
|
|
if created_at == "-"
|
|
&& let Ok(metadata) = std::fs::metadata(shard.file_name())
|
|
&& let Ok(modified) = metadata.modified()
|
|
{
|
|
let ts: chrono::DateTime<chrono::Local> = modified.into();
|
|
created_at = ts.format("%Y-%m-%d %H:%M").to_string();
|
|
}
|
|
}
|
|
let preferred_size = ec_volume.dat_file_size.max(0) as u64;
|
|
ec_volumes.push(UiEcVolumeRow {
|
|
volume_id: ec_volume.volume_id.0,
|
|
collection: ec_volume.collection.clone(),
|
|
size: preferred_size.max(total_size),
|
|
shards,
|
|
created_at,
|
|
});
|
|
}
|
|
}
|
|
|
|
disk_rows.sort_by(|left, right| left.dir.cmp(&right.dir));
|
|
volumes.sort_by_key(|row| row.id);
|
|
remote_volumes.sort_by_key(|row| row.id);
|
|
ec_volumes.sort_by_key(|row| row.volume_id);
|
|
|
|
(disk_rows, volumes, remote_volumes, ec_volumes)
|
|
}
|
|
|
|
fn join_i64(values: &[i64]) -> String {
|
|
values
|
|
.iter()
|
|
.map(std::string::ToString::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
}
|
|
|
|
fn percent_from(total: u64, part: u64) -> f64 {
|
|
if total == 0 {
|
|
return 0.0;
|
|
}
|
|
(part as f64 / total as f64) * 100.0
|
|
}
|
|
|
|
fn bytes_to_human_readable(bytes: u64) -> String {
|
|
const UNIT: u64 = 1024;
|
|
if bytes < UNIT {
|
|
return format!("{} B", bytes);
|
|
}
|
|
|
|
let mut div = UNIT;
|
|
let mut exp = 0usize;
|
|
let mut n = bytes / UNIT;
|
|
while n >= UNIT {
|
|
div *= UNIT;
|
|
n /= UNIT;
|
|
exp += 1;
|
|
}
|
|
|
|
format!(
|
|
"{:.2} {}iB",
|
|
bytes as f64 / div as f64,
|
|
["K", "M", "G", "T", "P", "E"][exp]
|
|
)
|
|
}
|
|
|
|
fn escape_html(input: &str) -> String {
|
|
input
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|