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
25 format!(
26 r#"<!doctype html>
27<html lang="en">
28<head>
29<meta charset="utf-8">
30<meta name="viewport" content="width=device-width, initial-scale=1">
31<title>{namespace} | LFSX</title>
32<style>{STYLE}</style>
33</head>
34<body>
35<main>
36<h1>{namespace}</h1>
37<dl>
38<div><dt>Objects</dt><dd>{objects}</dd></div>
39<div><dt>On disk</dt><dd>{}</dd></div>
40<div><dt>Your access</dt><dd>{}</dd></div>
41</dl>
42<h2>Locks</h2>
43{}
44<footer>Read only. Objects are reclaimed with <code>lfsx gc</code>, locks are released with
45<code>git lfs unlock</code>.</footer>
46</main>
47</body>
48</html>
49"#,
50 human_bytes(*bytes),
51 if *writable { "read and write" } else { "read" },
52 locks_table(locks, *lock_max_age),
53 )
54}
55
56const LOCKS_SHOWN: usize = 50;
64
65fn locks_table(locks: &[Lock], max_age: Option<Duration>) -> String {
66 if locks.is_empty() {
67 return "<p class=\"empty\">Nothing is locked.</p>".to_owned();
68 }
69
70 let rows: String = locks
71 .iter()
72 .take(LOCKS_SHOWN)
73 .map(|lock| match locks::stale_for(lock, max_age) {
74 Some(age) => format!(
75 "<tr class=\"stale\"><td>{}</td><td>{}</td><td>{} (untouched for {}, anyone can take it)</td></tr>",
76 escape(&lock.path),
77 escape(&lock.owner.name),
78 escape(&lock.locked_at),
79 human_age(age)
80 ),
81 None => format!(
82 "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
83 escape(&lock.path),
84 escape(&lock.owner.name),
85 escape(&lock.locked_at)
86 ),
87 })
88 .collect();
89
90 let remainder = locks.len().saturating_sub(LOCKS_SHOWN);
91 let footer = match remainder {
92 0 => String::new(),
93 more => format!(
94 "<tr class=\"more\"><td colspan=\"3\">and {more} more, list them with `git lfs locks`</td></tr>"
95 ),
96 };
97
98 format!(
99 "<table><thead><tr><th>Path</th><th>Held by</th><th>Since</th></tr></thead><tbody>{rows}{footer}</tbody></table>"
100 )
101}
102
103fn human_age(age: Duration) -> String {
104 const MINUTE: u64 = 60;
105 const HOUR: u64 = 60 * MINUTE;
106 const DAY: u64 = 24 * HOUR;
107
108 let seconds = age.as_secs();
109 let (count, unit) = match seconds {
110 s if s >= 7 * DAY => (s / (7 * DAY), "week"),
111 s if s >= DAY => (s / DAY, "day"),
112 s if s >= HOUR => (s / HOUR, "hour"),
113 s if s >= MINUTE => (s / MINUTE, "minute"),
114 s => (s, "second"),
117 };
118
119 format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
120}
121
122fn human_bytes(bytes: u64) -> String {
123 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
124 let mut size = bytes as f64;
125 let mut unit = 0;
126
127 while size >= 1024.0 && unit < UNITS.len() - 1 {
128 size /= 1024.0;
129 unit += 1;
130 }
131
132 if unit == 0 {
133 format!("{bytes} B")
134 } else {
135 format!("{size:.1} {}", UNITS[unit])
136 }
137}
138
139fn escape(raw: &str) -> String {
140 raw.replace('&', "&")
141 .replace('<', "<")
142 .replace('>', ">")
143 .replace('"', """)
144}
145
146const STYLE: &str = "\
147:root{color-scheme:light dark}\
148body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:2rem}\
149main{max-width:52rem;margin:0 auto}\
150h1{font-size:1.5rem;margin:0 0 1.5rem}\
151h2{font-size:1.1rem;margin:2rem 0 .75rem}\
152dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:1rem;margin:0}\
153dt{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.65}\
154dd{margin:.25rem 0 0;font-size:1.5rem;font-variant-numeric:tabular-nums}\
155table{width:100%;border-collapse:collapse}\
156th{text-align:left;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.65;font-weight:400}\
157th,td{padding:.5rem 0;border-bottom:1px solid color-mix(in srgb,currentColor 12%,transparent)}\
158td{font-variant-numeric:tabular-nums}\
159.empty{opacity:.65}\n.stale td{color:#b4690e}\
160footer{margin-top:2.5rem;font-size:.85rem;opacity:.65}\
161code{font-family:ui-monospace,monospace;font-size:.85em}";
162
163#[cfg(test)]
164mod tests;