Skip to main content

lfsx_server/
dashboard.rs

1use std::time::Duration;
2
3use crate::locks::{self, Lock};
4use crate::namespace::Namespace;
5
6pub struct Overview {
7    pub namespace: Namespace,
8    pub objects: u64,
9    pub bytes: u64,
10    pub locks: Vec<Lock>,
11    pub lock_max_age: Option<Duration>,
12    pub writable: bool,
13}
14
15pub fn render(overview: &Overview) -> String {
16    let Overview {
17        namespace,
18        objects,
19        bytes,
20        locks,
21        lock_max_age,
22        writable,
23    } = overview;
24    let name = title(namespace);
25
26    format!(
27        r#"<!doctype html>
28<html lang="en">
29<head>
30<meta charset="utf-8">
31<meta name="viewport" content="width=device-width, initial-scale=1">
32<title>{name} | LFSX</title>
33<style>{STYLE}</style>
34</head>
35<body>
36<main>
37<h1>{name}</h1>
38<dl>
39<div><dt>Objects</dt><dd>{objects}</dd></div>
40<div><dt>On disk</dt><dd>{}</dd></div>
41<div><dt>Your access</dt><dd>{}</dd></div>
42</dl>
43<h2>Locks</h2>
44{}
45<footer>Read only. Objects are reclaimed with <code>lfsx gc</code>, locks are released with
46<code>git lfs unlock</code>.</footer>
47</main>
48</body>
49</html>
50"#,
51        human_bytes(*bytes),
52        if *writable { "read and write" } else { "read" },
53        locks_table(locks, *lock_max_age),
54    )
55}
56
57// This is the only place a person is told a lock has gone stale. `git lfs locks`
58// prints the path, the owner and the id, and has no field for anything else, so
59// a client cannot be made to show it however the server phrases the JSON.
60// The page is a summary, so it shows a page's worth. Rendering every lock made
61// the response cost whatever the repository's locks weigh, paid by whoever
62// opened it, and a thousand rows answer no question the first fifty and a
63// count do not.
64const LOCKS_SHOWN: usize = 50;
65
66fn title(namespace: &Namespace) -> String {
67    match namespace.forge() {
68        Some(forge) => format!("{} ({forge})", namespace.upstream()),
69        None => namespace.upstream(),
70    }
71}
72
73fn locks_table(locks: &[Lock], max_age: Option<Duration>) -> String {
74    if locks.is_empty() {
75        return "<p class=\"empty\">Nothing is locked.</p>".to_owned();
76    }
77
78    let rows: String = locks
79        .iter()
80        .take(LOCKS_SHOWN)
81        .map(|lock| match locks::stale_for(lock, max_age) {
82            Some(age) => format!(
83                "<tr class=\"stale\"><td>{}</td><td>{}</td><td>{} (untouched for {}, anyone can take it)</td></tr>",
84                escape(&lock.path),
85                escape(&lock.owner.name),
86                escape(&lock.locked_at),
87                human_age(age)
88            ),
89            None => format!(
90                "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
91                escape(&lock.path),
92                escape(&lock.owner.name),
93                escape(&lock.locked_at)
94            ),
95        })
96        .collect();
97
98    let remainder = locks.len().saturating_sub(LOCKS_SHOWN);
99    let footer = match remainder {
100        0 => String::new(),
101        more => format!(
102            "<tr class=\"more\"><td colspan=\"3\">and {more} more, list them with `git lfs locks`</td></tr>"
103        ),
104    };
105
106    format!(
107        "<table><thead><tr><th>Path</th><th>Held by</th><th>Since</th></tr></thead><tbody>{rows}{footer}</tbody></table>"
108    )
109}
110
111fn human_age(age: Duration) -> String {
112    const MINUTE: u64 = 60;
113    const HOUR: u64 = 60 * MINUTE;
114    const DAY: u64 = 24 * HOUR;
115
116    let seconds = age.as_secs();
117    let (count, unit) = match seconds {
118        s if s >= 7 * DAY => (s / (7 * DAY), "week"),
119        s if s >= DAY => (s / DAY, "day"),
120        s if s >= HOUR => (s / HOUR, "hour"),
121        s if s >= MINUTE => (s / MINUTE, "minute"),
122        // A short ceiling is a strange thing to configure, but "untouched for 0
123        // minutes" is a strange thing to print.
124        s => (s, "second"),
125    };
126
127    format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
128}
129
130fn human_bytes(bytes: u64) -> String {
131    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
132    let mut size = bytes as f64;
133    let mut unit = 0;
134
135    while size >= 1024.0 && unit < UNITS.len() - 1 {
136        size /= 1024.0;
137        unit += 1;
138    }
139
140    if unit == 0 {
141        format!("{bytes} B")
142    } else {
143        format!("{size:.1} {}", UNITS[unit])
144    }
145}
146
147fn escape(raw: &str) -> String {
148    raw.replace('&', "&amp;")
149        .replace('<', "&lt;")
150        .replace('>', "&gt;")
151        .replace('"', "&quot;")
152}
153
154const STYLE: &str = "\
155:root{color-scheme:light dark}\
156body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:2rem}\
157main{max-width:52rem;margin:0 auto}\
158h1{font-size:1.5rem;margin:0 0 1.5rem}\
159h2{font-size:1.1rem;margin:2rem 0 .75rem}\
160dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:1rem;margin:0}\
161dt{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.65}\
162dd{margin:.25rem 0 0;font-size:1.5rem;font-variant-numeric:tabular-nums}\
163table{width:100%;border-collapse:collapse}\
164th{text-align:left;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.65;font-weight:400}\
165th,td{padding:.5rem 0;border-bottom:1px solid color-mix(in srgb,currentColor 12%,transparent)}\
166td{font-variant-numeric:tabular-nums}\
167.empty{opacity:.65}\n.stale td{color:#b4690e}\
168footer{margin-top:2.5rem;font-size:.85rem;opacity:.65}\
169code{font-family:ui-monospace,monospace;font-size:.85em}";
170
171#[cfg(test)]
172mod tests;