Skip to main content

mimick/library/
server_stats_dialog.rs

1//! Server statistics dialog.
2//!
3//! Presented when the user clicks the bottom-sidebar connection or server row.
4//! Pulls `/api/server/statistics` (admin) plus `/api/assets/statistics` (per-user)
5//! and `/api/server/about` to render a single summary view.
6
7use std::rc::Rc;
8use std::sync::Arc;
9
10use gtk::prelude::*;
11use libadwaita::prelude::*;
12
13use crate::app_context::AppContext;
14
15/// Present the server statistics dialog to the user.
16///
17/// Fetches both server-wide and user-specific usage statistics, along with
18/// the server version, and displays them in a modal dialog.
19pub 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: &gtk::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: &gtk::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: &gtk::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(&note);
124}
125
126fn append_error_ui(content: &gtk::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
152/// Build a row of colored stat cards arranged in a responsive `FlowBox`.
153fn 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
189/// Build a row for a single user with quota progress bar if applicable.
190fn 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        // 5 * 1 PiB rounds up to multi-thousand TiB, but we cap units at TiB.
276        let pib = 1024_u64.pow(5);
277        assert!(format_size(pib).ends_with(" TiB"));
278    }
279}