kroki_rs/server/
admin.rs

1use crate::server::AppState;
2use axum::{
3    extract::State,
4    response::{Html, IntoResponse, Json},
5    routing::get,
6    Router,
7};
8use serde_json::json;
9use tokio::net::TcpListener;
10
11/// Starts the admin server alongside the main application.
12pub async fn run_admin_server(state: AppState) -> anyhow::Result<()> {
13    let port = state.config.server.admin_port;
14    let metrics_export_enabled =
15        state.config.server.metrics.enabled && state.config.server.metrics.export_endpoint;
16
17    let mut app = Router::new()
18        .route("/health", get(health_check))
19        .route("/", get(dashboard));
20
21    // Add metrics endpoint if enabled and export is configured
22    if metrics_export_enabled {
23        app = app.route("/metrics", get(metrics_handler));
24    }
25
26    let host = state.config.server.host.clone();
27    let app = app
28        .layer(axum::middleware::from_fn_with_state(
29            state.clone(),
30            crate::server::middleware::auth::admin_auth_middleware,
31        ))
32        .with_state(state);
33
34    let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
35    tracing::info!("Admin dashboard available at http://{}:{}", host, port);
36    if metrics_export_enabled {
37        tracing::info!(
38            "Prometheus metrics available at http://{}:{}/metrics",
39            host,
40            port
41        );
42    }
43    axum::serve(listener, app).await?;
44    Ok(())
45}
46
47async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
48    let mut health_data = json!({
49        "status": "ok",
50        "version": env!("CARGO_PKG_VERSION")
51    });
52
53    if let Some(browser) = &state.browser_manager {
54        if let Ok(pool_health) = browser.get_pool_health().await {
55            health_data["browser_pool"] = pool_health;
56        } else {
57            health_data["browser_pool"] = json!({"status": "unhealthy"});
58        }
59    }
60
61    Json(health_data)
62}
63
64async fn dashboard(State(state): State<AppState>) -> impl IntoResponse {
65    let capabilities = state.registry.known_types();
66    let auth_status = if state.config.server.auth.enabled {
67        "Enabled"
68    } else {
69        "Disabled (Dev Mode)"
70    };
71    let rate_limit_status = if state.config.server.rate_limit.enabled {
72        "Enabled"
73    } else {
74        "Disabled"
75    };
76    let cb_status = if state.config.server.circuit_breaker.enabled {
77        "Enabled"
78    } else {
79        "Disabled"
80    };
81
82    let api_port = state.config.server.port;
83    let host = &state.config.server.host;
84
85    let html = format!(
86        r#"
87<!DOCTYPE html>
88<html lang="en">
89<head>
90    <meta charset="UTF-8">
91    <meta name="viewport" content="width=device-width, initial-scale=1.0">
92    <title>Kroki-rs | Admin Dashboard</title>
93    <style>
94        :root {{
95            --primary: #6366f1;
96            --primary-dark: #4f46e5;
97            --bg: #0f172a;
98            --card-bg: #1e293b;
99            --text: #f8fafc;
100            --text-muted: #94a3b8;
101            --success: #22c55e;
102            --warning: #eab308;
103        }}
104        body {{
105            font-family: 'Inter', -apple-system, sans-serif;
106            background-color: var(--bg);
107            color: var(--text);
108            margin: 0;
109            display: flex;
110            flex-direction: column;
111            align-items: center;
112            min-height: 100vh;
113            padding: 2rem;
114        }}
115        .container {{
116            max-width: 900px;
117            width: 100%;
118        }}
119        header {{
120            text-align: center;
121            margin-bottom: 3rem;
122        }}
123        h1 {{
124            font-size: 2.5rem;
125            margin: 0;
126            background: linear-gradient(to right, #818cf8, #c084fc);
127            -webkit-background-clip: text;
128            -webkit-text-fill-color: transparent;
129        }}
130        .version {{
131            font-size: 0.875rem;
132            color: var(--text-muted);
133            margin-top: 0.5rem;
134        }}
135        .grid {{
136            display: grid;
137            grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
138            gap: 1.5rem;
139            margin-bottom: 3rem;
140        }}
141        .card {{
142            background: var(--card-bg);
143            border-radius: 1rem;
144            padding: 1.5rem;
145            border: 1px solid #334155;
146        }}
147        .card h2 {{
148            margin-top: 0;
149            font-size: 1.25rem;
150            border-bottom: 1px solid #334155;
151            padding-bottom: 0.75rem;
152            margin-bottom: 1rem;
153        }}
154        .status-row {{
155            display: flex;
156            justify-content: space-between;
157            align-items: center;
158            margin-bottom: 0.75rem;
159        }}
160        .status-label {{ color: var(--text-muted); }}
161        .status-value {{ font-weight: 600; }}
162        .enabled {{ color: var(--success); }}
163        .disabled {{ color: var(--text-muted); }}
164        
165        ul {{
166            columns: 2;
167            list-style-type: none;
168            padding: 0;
169            margin: 0;
170        }}
171        li {{
172            padding: 0.25rem 0;
173            font-size: 0.875rem;
174        }}
175        
176        .nav-link {{
177            display: inline-flex;
178            align-items: center;
179            gap: 0.5rem;
180            color: var(--primary);
181            text-decoration: none;
182            font-weight: 500;
183            margin-bottom: 2rem;
184        }}
185        .nav-link:hover {{ text-decoration: underline; }}
186
187        .footer {{
188            margin-top: auto;
189            padding-top: 3rem;
190            color: var(--text-muted);
191            font-size: 0.875rem;
192            text-align: center;
193        }}
194    </style>
195</head>
196<body>
197    <div class="container">
198        <header>
199            <a href="http://{}:{}" class="nav-link">🏠 Back to Discovery</a>
200            <h1>Admin Dashboard</h1>
201            <div class="version">Kroki-rs v{}</div>
202        </header>
203        
204        <div class="grid">
205            <div class="card">
206                <h2>System Status</h2>
207                <div class="status-row">
208                    <span class="status-label">Service</span>
209                    <span class="status-value enabled">Online</span>
210                </div>
211                <div class="status-row">
212                    <span class="status-label">Authentication</span>
213                    <span class="status-value {}">{}</span>
214                </div>
215                <div class="status-row">
216                    <span class="status-label">Rate Limiting</span>
217                    <span class="status-value {}">{}</span>
218                </div>
219                <div class="status-row">
220                    <span class="status-label">Circuit Breaker</span>
221                    <span class="status-value {}">{}</span>
222                </div>
223            </div>
224
225            <div class="card">
226                <h2>Diagram Providers</h2>
227                <div style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem;">
228                    {} available tools registered
229                </div>
230                <ul>
231                    {}
232                </ul>
233            </div>
234        </div>
235
236        <div class="footer">
237            Built with Rust & Axum • <a href="https://github.com/softmentor/kroki-rs" style="color: inherit;">GitHub</a>
238        </div>
239    </div>
240</body>
241</html>
242"#,
243        host,
244        api_port,
245        env!("CARGO_PKG_VERSION"),
246        if state.config.server.auth.enabled {
247            "enabled"
248        } else {
249            "disabled"
250        },
251        auth_status,
252        if state.config.server.rate_limit.enabled {
253            "enabled"
254        } else {
255            "disabled"
256        },
257        rate_limit_status,
258        if state.config.server.circuit_breaker.enabled {
259            "enabled"
260        } else {
261            "disabled"
262        },
263        cb_status,
264        capabilities.len(),
265        capabilities
266            .iter()
267            .map(|c| format!("<li>✅ {}</li>", c))
268            .collect::<Vec<_>>()
269            .join("\n")
270    );
271
272    Html(html)
273}
274
275async fn metrics_handler(State(state): State<AppState>) -> impl IntoResponse {
276    if let Some(handle) = &state.metrics_handle {
277        handle.render()
278    } else {
279        "Metrics collection is disabled".to_string()
280    }
281}