Skip to main content

lfsx_server/
dashboard.rs

1use crate::locks::Lock;
2use crate::namespace::Namespace;
3
4pub struct Overview {
5    pub namespace: Namespace,
6    pub objects: u64,
7    pub bytes: u64,
8    pub locks: Vec<Lock>,
9    pub writable: bool,
10}
11
12pub fn render(overview: &Overview) -> String {
13    let Overview {
14        namespace,
15        objects,
16        bytes,
17        locks,
18        writable,
19    } = overview;
20
21    format!(
22        r#"<!doctype html>
23<html lang="en">
24<head>
25<meta charset="utf-8">
26<meta name="viewport" content="width=device-width, initial-scale=1">
27<title>{namespace} — LFSX</title>
28<style>{STYLE}</style>
29</head>
30<body>
31<main>
32<h1>{namespace}</h1>
33<dl>
34<div><dt>Objects</dt><dd>{objects}</dd></div>
35<div><dt>On disk</dt><dd>{}</dd></div>
36<div><dt>Your access</dt><dd>{}</dd></div>
37</dl>
38<h2>Locks</h2>
39{}
40<footer>Read only. Objects are reclaimed with <code>lfsx gc</code>, locks are released with
41<code>git lfs unlock</code>.</footer>
42</main>
43</body>
44</html>
45"#,
46        human_bytes(*bytes),
47        if *writable { "read and write" } else { "read" },
48        locks_table(locks),
49    )
50}
51
52fn locks_table(locks: &[Lock]) -> String {
53    if locks.is_empty() {
54        return "<p class=\"empty\">Nothing is locked.</p>".to_owned();
55    }
56
57    let rows: String = locks
58        .iter()
59        .map(|lock| {
60            format!(
61                "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
62                escape(&lock.path),
63                escape(&lock.owner.name),
64                escape(&lock.locked_at)
65            )
66        })
67        .collect();
68
69    format!(
70        "<table><thead><tr><th>Path</th><th>Held by</th><th>Since</th></tr></thead><tbody>{rows}</tbody></table>"
71    )
72}
73
74fn human_bytes(bytes: u64) -> String {
75    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
76    let mut size = bytes as f64;
77    let mut unit = 0;
78
79    while size >= 1024.0 && unit < UNITS.len() - 1 {
80        size /= 1024.0;
81        unit += 1;
82    }
83
84    if unit == 0 {
85        format!("{bytes} B")
86    } else {
87        format!("{size:.1} {}", UNITS[unit])
88    }
89}
90
91fn escape(raw: &str) -> String {
92    raw.replace('&', "&amp;")
93        .replace('<', "&lt;")
94        .replace('>', "&gt;")
95        .replace('"', "&quot;")
96}
97
98const STYLE: &str = "\
99:root{color-scheme:light dark}\
100body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:2rem}\
101main{max-width:52rem;margin:0 auto}\
102h1{font-size:1.5rem;margin:0 0 1.5rem}\
103h2{font-size:1.1rem;margin:2rem 0 .75rem}\
104dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:1rem;margin:0}\
105dt{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.65}\
106dd{margin:.25rem 0 0;font-size:1.5rem;font-variant-numeric:tabular-nums}\
107table{width:100%;border-collapse:collapse}\
108th{text-align:left;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.65;font-weight:400}\
109th,td{padding:.5rem 0;border-bottom:1px solid color-mix(in srgb,currentColor 12%,transparent)}\
110td{font-variant-numeric:tabular-nums}\
111.empty{opacity:.65}\
112footer{margin-top:2.5rem;font-size:.85rem;opacity:.65}\
113code{font-family:ui-monospace,monospace;font-size:.85em}";
114
115#[cfg(test)]
116mod tests;