postrust_proxy/admin/
ui.rs1use crate::ProxyState;
4use axum::{extract::State, response::Html, routing::get, Router};
5use std::sync::Arc;
6
7fn esc(text: &str) -> String {
15 let mut out = String::with_capacity(text.len());
16 for c in text.chars() {
17 match c {
18 '&' => out.push_str("&"),
19 '<' => out.push_str("<"),
20 '>' => out.push_str(">"),
21 '"' => out.push_str("""),
22 '\'' => out.push_str("'"),
23 c => out.push(c),
24 }
25 }
26 out
27}
28
29pub fn admin_ui_router() -> Router<Arc<ProxyState>> {
31 Router::new()
32 .route("/", get(proxy_dashboard))
33 .route("/routes", get(routes_page))
34 .route("/upstreams", get(upstreams_page))
35 .route("/health", get(health_page))
36 .route("/certificates", get(certificates_page))
37}
38
39async fn proxy_dashboard(State(state): State<Arc<ProxyState>>) -> Html<String> {
40 let config = state.config.read().await;
41
42 let backends_count: usize = config.upstreams.iter().map(|u| u.backends.len()).sum();
43
44 let html = format!(
45 r#"<!DOCTYPE html>
46<html>
47<head>
48 <title>Proxy Dashboard - Postrust</title>
49 <style>
50 body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
51 .container {{ max-width: 1200px; margin: 0 auto; }}
52 h1 {{ color: #333; }}
53 .stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }}
54 .stat-card {{ background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
55 .stat-card h3 {{ margin: 0 0 10px 0; color: #666; font-size: 14px; }}
56 .stat-card .value {{ font-size: 36px; font-weight: bold; color: #333; }}
57 .nav {{ margin: 20px 0; }}
58 .nav a {{ display: inline-block; padding: 10px 20px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
59 .nav a:hover {{ background: #0056b3; }}
60 </style>
61</head>
62<body>
63 <div class="container">
64 <h1>Proxy Dashboard</h1>
65
66 <div class="nav">
67 <a href="/admin/proxy/routes">Routes</a>
68 <a href="/admin/proxy/upstreams">Upstreams</a>
69 <a href="/admin/proxy/health">Health</a>
70 <a href="/admin/proxy/certificates">Certificates</a>
71 </div>
72
73 <div class="stats">
74 <div class="stat-card">
75 <h3>Routes</h3>
76 <div class="value">{}</div>
77 </div>
78 <div class="stat-card">
79 <h3>Upstreams</h3>
80 <div class="value">{}</div>
81 </div>
82 <div class="stat-card">
83 <h3>Backends</h3>
84 <div class="value">{}</div>
85 </div>
86 </div>
87 </div>
88</body>
89</html>"#,
90 config.routes.len(),
91 config.upstreams.len(),
92 backends_count,
93 );
94
95 Html(html)
96}
97
98async fn routes_page(State(state): State<Arc<ProxyState>>) -> Html<String> {
99 let config = state.config.read().await;
100
101 let routes_html: String = config
102 .routes
103 .iter()
104 .map(|r| {
105 format!(
106 r#"<tr>
107 <td>{}</td>
108 <td>{}</td>
109 <td>{}</td>
110 <td>{}</td>
111 <td>{}</td>
112 </tr>"#,
113 r.id.map(|id| id.to_string()).unwrap_or_default(),
114 esc(&r.name),
115 esc(r.match_.host.as_deref().unwrap_or("*")),
116 esc(r.match_.path.as_deref().unwrap_or("/")),
117 r.priority,
118 )
119 })
120 .collect();
121
122 let html = format!(
123 r#"<!DOCTYPE html>
124<html>
125<head>
126 <title>Routes - Postrust Proxy</title>
127 <style>
128 body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
129 .container {{ max-width: 1200px; margin: 0 auto; }}
130 h1 {{ color: #333; }}
131 table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
132 th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #eee; }}
133 th {{ background: #f8f9fa; font-weight: 600; }}
134 .nav {{ margin: 20px 0; }}
135 .nav a {{ display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
136 .nav a:hover {{ background: #5a6268; }}
137 </style>
138</head>
139<body>
140 <div class="container">
141 <h1>Routes</h1>
142
143 <div class="nav">
144 <a href="/admin/proxy/">Back to Dashboard</a>
145 </div>
146
147 <table>
148 <thead>
149 <tr>
150 <th>ID</th>
151 <th>Name</th>
152 <th>Host</th>
153 <th>Path Prefix</th>
154 <th>Priority</th>
155 </tr>
156 </thead>
157 <tbody>
158 {}
159 </tbody>
160 </table>
161 </div>
162</body>
163</html>"#,
164 routes_html
165 );
166
167 Html(html)
168}
169
170async fn upstreams_page(State(state): State<Arc<ProxyState>>) -> Html<String> {
171 let config = state.config.read().await;
172
173 let upstreams_html: String = config
174 .upstreams
175 .iter()
176 .map(|u| {
177 format!(
178 r#"<tr>
179 <td>{}</td>
180 <td>{}</td>
181 <td>{}</td>
182 <td>{}</td>
183 </tr>"#,
184 u.id.map(|id| id.to_string()).unwrap_or_default(),
185 esc(&u.name),
186 esc(&format!("{:?}", u.lb_strategy)),
187 u.backends.len(),
188 )
189 })
190 .collect();
191
192 let html = format!(
193 r#"<!DOCTYPE html>
194<html>
195<head>
196 <title>Upstreams - Postrust Proxy</title>
197 <style>
198 body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
199 .container {{ max-width: 1200px; margin: 0 auto; }}
200 h1 {{ color: #333; }}
201 table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
202 th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #eee; }}
203 th {{ background: #f8f9fa; font-weight: 600; }}
204 .nav {{ margin: 20px 0; }}
205 .nav a {{ display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
206 .nav a:hover {{ background: #5a6268; }}
207 </style>
208</head>
209<body>
210 <div class="container">
211 <h1>Upstreams</h1>
212
213 <div class="nav">
214 <a href="/admin/proxy/">Back to Dashboard</a>
215 </div>
216
217 <table>
218 <thead>
219 <tr>
220 <th>ID</th>
221 <th>Name</th>
222 <th>Load Balance</th>
223 <th>Backends</th>
224 </tr>
225 </thead>
226 <tbody>
227 {}
228 </tbody>
229 </table>
230 </div>
231</body>
232</html>"#,
233 upstreams_html
234 );
235
236 Html(html)
237}
238
239async fn health_page(State(state): State<Arc<ProxyState>>) -> Html<String> {
240 let config = state.config.read().await;
241
242 let mut health_html = String::new();
243 for upstream in &config.upstreams {
244 for backend in &upstream.backends {
245 let (status, status_class) = if let Some(id) = backend.id {
246 if state.health_checker.is_healthy(id) {
247 ("Healthy", "healthy")
248 } else {
249 ("Unhealthy", "unhealthy")
250 }
251 } else {
252 ("Unknown", "unknown")
253 };
254
255 health_html.push_str(&format!(
256 r#"<tr>
257 <td>{}</td>
258 <td>{}</td>
259 <td>{}</td>
260 <td class="{}">{}</td>
261 </tr>"#,
262 esc(&upstream.name),
263 esc(&backend.address),
264 esc(&backend.scheme),
265 status_class,
266 status,
267 ));
268 }
269 }
270
271 let html = format!(
272 r#"<!DOCTYPE html>
273<html>
274<head>
275 <title>Health Status - Postrust Proxy</title>
276 <style>
277 body {{ font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
278 .container {{ max-width: 1200px; margin: 0 auto; }}
279 h1 {{ color: #333; }}
280 table {{ width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
281 th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid #eee; }}
282 th {{ background: #f8f9fa; font-weight: 600; }}
283 .healthy {{ color: #28a745; font-weight: bold; }}
284 .unhealthy {{ color: #dc3545; font-weight: bold; }}
285 .unknown {{ color: #6c757d; }}
286 .nav {{ margin: 20px 0; }}
287 .nav a {{ display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }}
288 .nav a:hover {{ background: #5a6268; }}
289 </style>
290</head>
291<body>
292 <div class="container">
293 <h1>Backend Health Status</h1>
294
295 <div class="nav">
296 <a href="/admin/proxy/">Back to Dashboard</a>
297 </div>
298
299 <table>
300 <thead>
301 <tr>
302 <th>Upstream</th>
303 <th>Backend Address</th>
304 <th>Scheme</th>
305 <th>Status</th>
306 </tr>
307 </thead>
308 <tbody>
309 {}
310 </tbody>
311 </table>
312 </div>
313</body>
314</html>"#,
315 health_html
316 );
317
318 Html(html)
319}
320
321async fn certificates_page(State(_state): State<Arc<ProxyState>>) -> Html<String> {
322 let html = r#"<!DOCTYPE html>
324<html>
325<head>
326 <title>Certificates - Postrust Proxy</title>
327 <style>
328 body { font-family: system-ui, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
329 .container { max-width: 1200px; margin: 0 auto; }
330 h1 { color: #333; }
331 .placeholder { background: white; padding: 40px; text-align: center; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); color: #666; }
332 .nav { margin: 20px 0; }
333 .nav a { display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 4px; margin-right: 10px; }
334 .nav a:hover { background: #5a6268; }
335 </style>
336</head>
337<body>
338 <div class="container">
339 <h1>SSL/TLS Certificates</h1>
340
341 <div class="nav">
342 <a href="/admin/proxy/">Back to Dashboard</a>
343 </div>
344
345 <div class="placeholder">
346 <p>Certificate management will be available here.</p>
347 <p>Certificates are automatically managed via Let's Encrypt ACME.</p>
348 </div>
349 </div>
350</body>
351</html>"#;
352
353 Html(html.to_string())
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn markup_is_escaped() {
362 assert_eq!(
363 esc(r#"<script>alert("x")</script> & 'y'"#),
364 "<script>alert("x")</script> & 'y'"
365 );
366 assert_eq!(esc("plain-name_1"), "plain-name_1");
367 }
368}