1use std::rc::Rc;
8use std::sync::Arc;
9
10use gtk::prelude::*;
11use libadwaita::prelude::*;
12
13use crate::app_context::AppContext;
14
15pub fn present(ctx: Arc<AppContext>, parent: &libadwaita::ApplicationWindow) {
20 let dialog = libadwaita::AlertDialog::builder()
21 .heading("Server statistics")
22 .body("")
23 .content_width(280)
24 .build();
25 dialog.add_response("close", "Close");
26 dialog.set_close_response("close");
27 dialog.set_default_response(Some("close"));
28
29 let content = gtk::Box::builder()
30 .orientation(gtk::Orientation::Vertical)
31 .spacing(16)
32 .build();
33
34 let loading = gtk::Box::builder()
35 .orientation(gtk::Orientation::Horizontal)
36 .spacing(8)
37 .halign(gtk::Align::Center)
38 .build();
39 let spinner = gtk::Spinner::builder().spinning(true).build();
40 let loading_label = gtk::Label::new(Some("Loading\u{2026}"));
41 loading.append(&spinner);
42 loading.append(&loading_label);
43 content.append(&loading);
44
45 dialog.set_extra_child(Some(&content));
46 dialog.present(Some(parent));
47
48 let content = Rc::new(content);
49 glib::MainContext::default().spawn_local({
50 let ctx = ctx.clone();
51 let content = content.clone();
52 async move {
53 let server_stats = ctx.api_client.fetch_server_statistics().await;
54 let asset_stats = ctx.api_client.fetch_server_stats().await;
55 let about = ctx.api_client.fetch_server_about().await;
56
57 while let Some(child) = content.first_child() {
58 content.remove(&child);
59 }
60
61 if let Ok(about) = &about {
62 append_version_badge(&content, &about.version);
63 }
64
65 match (&server_stats, &asset_stats) {
66 (Ok(stats), _) => append_server_statistics(&content, stats),
67 (Err(_), Ok(assets)) => append_asset_statistics(&content, assets),
68 (Err(server_err), Err(asset_err)) => {
69 append_error_ui(&content, asset_err, server_err)
70 }
71 }
72 }
73 });
74}
75
76fn append_version_badge(content: >k::Box, version: &str) {
77 let badge_label = gtk::Label::builder()
78 .label(format!("Immich {}", version))
79 .css_classes(vec!["caption".to_string()])
80 .build();
81 let badge = gtk::Box::builder()
82 .halign(gtk::Align::Center)
83 .css_classes(vec!["mimick-version-badge".to_string()])
84 .build();
85 badge.append(&badge_label);
86 content.append(&badge);
87}
88
89fn append_server_statistics(content: >k::Box, stats: &crate::api_client::ServerStatistics) {
90 let cards = stat_card_row(&[
91 ("Photos", format_number(stats.photos), "photo-card"),
92 ("Videos", format_number(stats.videos), "video-card"),
93 ("Storage", format_size(stats.usage), "storage-card"),
94 ]);
95 content.append(&cards);
96
97 if !stats.usage_by_user.is_empty() {
98 let user_group = libadwaita::PreferencesGroup::builder()
99 .title("By user")
100 .build();
101 for user in &stats.usage_by_user {
102 let row = user_row(user);
103 user_group.add(&row);
104 }
105 content.append(&user_group);
106 }
107}
108
109fn append_asset_statistics(content: >k::Box, asset_stats: &crate::api_client::ServerStats) {
110 let cards = stat_card_row(&[
111 ("Photos", format_number(asset_stats.images), "photo-card"),
112 ("Videos", format_number(asset_stats.videos), "video-card"),
113 ("Total", format_number(asset_stats.total), "storage-card"),
114 ]);
115 content.append(&cards);
116
117 let note = gtk::Label::builder()
118 .label("Server-wide statistics require administrator access.")
119 .css_classes(vec!["dim-label".to_string(), "caption".to_string()])
120 .wrap(true)
121 .halign(gtk::Align::Center)
122 .build();
123 content.append(¬e);
124}
125
126fn append_error_ui(content: >k::Box, asset_err: &str, server_err: &str) {
127 let is_permission_error = (asset_err.contains("HTTP 401") || asset_err.contains("HTTP 403"))
128 || (server_err.contains("HTTP 401") || server_err.contains("HTTP 403"));
129
130 let (title, subtitle) = if is_permission_error {
131 (
132 "Missing API Permissions",
133 "Your API key must include the 'server.about', 'server.versionCheck', 'server.statistics', and 'asset.statistics' permissions to view stats.",
134 )
135 } else {
136 (
137 "Unable to load statistics",
138 "Check your connection and try again.",
139 )
140 };
141
142 let err_row = libadwaita::ActionRow::builder()
143 .title(title)
144 .subtitle(subtitle)
145 .subtitle_lines(0)
146 .build();
147 let group = libadwaita::PreferencesGroup::builder().build();
148 group.add(&err_row);
149 content.append(&group);
150}
151
152fn stat_card_row(items: &[(&str, String, &str)]) -> gtk::FlowBox {
154 let flow = gtk::FlowBox::builder()
155 .selection_mode(gtk::SelectionMode::None)
156 .homogeneous(true)
157 .row_spacing(8)
158 .column_spacing(8)
159 .min_children_per_line(1)
160 .max_children_per_line(3)
161 .build();
162
163 for (label, value, card_class) in items {
164 let card = gtk::Box::builder()
165 .orientation(gtk::Orientation::Vertical)
166 .spacing(2)
167 .halign(gtk::Align::Fill)
168 .css_classes(vec!["mimick-stat-card".to_string(), card_class.to_string()])
169 .build();
170
171 let value_label = gtk::Label::builder()
172 .label(value.as_str())
173 .halign(gtk::Align::Start)
174 .css_classes(vec!["mimick-stat-value".to_string()])
175 .build();
176 let name_label = gtk::Label::builder()
177 .label(*label)
178 .halign(gtk::Align::Start)
179 .css_classes(vec!["mimick-stat-label".to_string()])
180 .build();
181 card.append(&value_label);
182 card.append(&name_label);
183 flow.insert(&card, -1);
184 }
185
186 flow
187}
188
189fn user_row(user: &crate::api_client::UsageByUser) -> libadwaita::ActionRow {
191 let row = libadwaita::ActionRow::builder()
192 .title(&user.user_name)
193 .subtitle(format!(
194 "{} photos \u{b7} {} videos \u{b7} {}",
195 format_number(user.photos),
196 format_number(user.videos),
197 format_size(user.usage),
198 ))
199 .subtitle_lines(2)
200 .build();
201
202 if let Some(quota) = user.quota_size_in_bytes
203 && quota > 0
204 {
205 let fraction = (user.usage as f64 / quota as f64).min(1.0);
206 let bar = gtk::ProgressBar::builder()
207 .fraction(fraction)
208 .valign(gtk::Align::Center)
209 .tooltip_text(format!(
210 "{} of {}",
211 format_size(user.usage),
212 format_size(quota)
213 ))
214 .css_classes(vec!["mimick-quota-bar".to_string()])
215 .build();
216 row.add_suffix(&bar);
217 }
218
219 row
220}
221
222fn format_number(n: u64) -> String {
223 let s = n.to_string();
224 let bytes = s.as_bytes();
225 let mut out = String::with_capacity(s.len() + s.len() / 3);
226 for (i, &b) in bytes.iter().enumerate() {
227 if i > 0 && (bytes.len() - i).is_multiple_of(3) {
228 out.push(',');
229 }
230 out.push(b as char);
231 }
232 out
233}
234
235fn format_size(bytes: u64) -> String {
236 const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
237 let mut size = bytes as f64;
238 let mut unit = 0;
239 while size >= 1024.0 && unit < UNITS.len() - 1 {
240 size /= 1024.0;
241 unit += 1;
242 }
243 if unit == 0 {
244 format!("{} {}", bytes, UNITS[unit])
245 } else {
246 format!("{:.2} {}", size, UNITS[unit])
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn format_number_groups_thousands() {
256 assert_eq!(format_number(0), "0");
257 assert_eq!(format_number(42), "42");
258 assert_eq!(format_number(1_000), "1,000");
259 assert_eq!(format_number(1_234_567), "1,234,567");
260 assert_eq!(format_number(u64::MAX), "18,446,744,073,709,551,615");
261 }
262
263 #[test]
264 fn format_size_picks_smallest_meaningful_unit() {
265 assert_eq!(format_size(0), "0 B");
266 assert_eq!(format_size(512), "512 B");
267 assert_eq!(format_size(1023), "1023 B");
268 assert_eq!(format_size(1024), "1.00 KiB");
269 assert_eq!(format_size(1024 * 1024), "1.00 MiB");
270 assert_eq!(format_size(1024_u64.pow(3)), "1.00 GiB");
271 }
272
273 #[test]
274 fn format_size_stops_at_tib() {
275 let pib = 1024_u64.pow(5);
277 assert!(format_size(pib).ends_with(" TiB"));
278 }
279}