Skip to main content

fraiseql_server/routes/studio/
mod.rs

1//! Studio admin dashboard routes.
2//!
3//! Serves the FraiseQL Studio SPA at `/studio` with:
4//! - `GET /studio` — HTML shell (client-side routing entry point)
5//! - `GET /studio/assets/{file}` — bundled JS/CSS assets
6//! - `GET /studio/*path` — wildcard fallback returning the same HTML shell
7
8pub mod admin;
9pub mod auth_users;
10pub mod data;
11pub mod function_ops;
12pub mod metrics_summary;
13pub mod realtime_monitor;
14pub mod storage_browser;
15
16use axum::{
17    extract::Path,
18    http::{StatusCode, header},
19    response::{Html, IntoResponse, Response},
20};
21use rust_embed::RustEmbed;
22
23/// Embedded studio assets bundled by `build.rs` via esbuild.
24///
25/// The folder path uses an environment variable set by `build.rs`
26/// so the path resolves correctly during both normal builds and tests.
27#[derive(RustEmbed)]
28#[folder = "$FRAISEQL_STUDIO_DIST"]
29pub struct StudioAssets;
30
31/// Returns the HTML shell for the Studio SPA.
32///
33/// The shell bootstraps Luxen UI tab navigation and loads the bundled JS.
34/// All section content is fetched from `/admin/v1/*` at runtime.
35#[must_use]
36pub fn studio_shell_html() -> String {
37    r#"<!DOCTYPE html>
38<html lang="en">
39<head>
40  <meta charset="utf-8" />
41  <meta name="viewport" content="width=device-width, initial-scale=1" />
42  <title>FraiseQL Studio</title>
43  <link rel="stylesheet" href="/studio/assets/app.css" />
44  <style>
45    *, *::before, *::after { box-sizing: border-box; }
46    html, body {
47      margin: 0;
48      height: 100%;
49      font-family: system-ui, sans-serif;
50      background: var(--color-surface, #f8f8f8);
51      color: var(--color-text, #1a1a1a);
52    }
53    #app {
54      display: flex;
55      flex-direction: column;
56      height: 100%;
57    }
58    header {
59      display: flex;
60      align-items: center;
61      gap: 1rem;
62      padding: 0.75rem 1.5rem;
63      background: var(--color-surface-raised, #fff);
64      border-bottom: 1px solid var(--color-border, #e2e2e2);
65    }
66    header h1 {
67      font-size: 1rem;
68      font-weight: 600;
69      margin: 0;
70    }
71    #section-content {
72      flex: 1;
73      padding: 1.5rem;
74      overflow: auto;
75    }
76    .section-placeholder { padding: 1rem 0; }
77    .empty-state {
78      color: var(--color-text-secondary, #666);
79      font-style: italic;
80    }
81    .log-viewer {
82      background: #1e1e1e;
83      color: #d4d4d4;
84      font-family: "Cascadia Code", "Fira Code", monospace;
85      font-size: 0.8rem;
86      padding: 1rem;
87      border-radius: 6px;
88      overflow: auto;
89      max-height: 70vh;
90      white-space: pre;
91    }
92  </style>
93</head>
94<body>
95  <div id="app">
96    <header>
97      <h1>FraiseQL Studio</h1>
98      <l-badge variant="neutral" id="version-badge">runtime</l-badge>
99    </header>
100
101    <l-tabs value="data" style="border-bottom:1px solid var(--color-border,#e2e2e2)">
102      <l-tab-item value="data">Data</l-tab-item>
103      <l-tab-item value="auth">Auth</l-tab-item>
104      <l-tab-item value="storage">Storage</l-tab-item>
105      <l-tab-item value="functions">Functions</l-tab-item>
106      <l-tab-item value="realtime">Realtime</l-tab-item>
107      <l-tab-item value="metrics">Metrics</l-tab-item>
108    </l-tabs>
109
110    <div id="section-content">
111      <l-spinner></l-spinner>
112    </div>
113  </div>
114
115  <!-- Login dialog -->
116  <dialog id="login-dialog" style="border:none;border-radius:8px;padding:2rem;min-width:320px">
117    <h2 style="margin-top:0">Admin login</h2>
118    <form id="login-form">
119      <label for="token-input" style="display:block;margin-bottom:0.5rem;font-size:0.9rem">Admin token</label>
120      <input id="token-input" type="password" placeholder="Bearer token" autocomplete="off"
121             style="width:100%;padding:0.5rem;border:1px solid #ccc;border-radius:4px;font-size:1rem;margin-bottom:1rem" />
122      <button type="submit"
123              style="width:100%;padding:0.6rem;background:#0070f3;color:#fff;border:none;border-radius:4px;font-size:1rem;cursor:pointer">
124        Sign in
125      </button>
126    </form>
127  </dialog>
128
129  <l-toast id="login-toast"></l-toast>
130
131  <script src="/studio/assets/app.js" type="module"></script>
132</body>
133</html>"#.to_owned()
134}
135
136/// Handler for `GET /studio` and `GET /studio/*path` — serves the SPA shell.
137///
138/// All sub-paths return the same HTML shell to support client-side routing.
139pub async fn studio_handler() -> impl IntoResponse {
140    Html(studio_shell_html())
141}
142
143/// Handler for `GET /studio/assets/{file}` — serves embedded static assets.
144///
145/// # Errors
146///
147/// Returns `404 Not Found` if the requested asset does not exist in the embedded bundle.
148pub async fn studio_asset_handler(Path(file): Path<String>) -> Response {
149    match StudioAssets::get(&file) {
150        Some(asset) => {
151            let mime = mime_for_filename(&file);
152            ([(header::CONTENT_TYPE, mime)], asset.data.as_ref().to_vec()).into_response()
153        },
154        None => StatusCode::NOT_FOUND.into_response(),
155    }
156}
157
158/// Derive MIME type from file extension.
159pub(crate) fn mime_for_filename(name: &str) -> &'static str {
160    let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("");
161    if ext.eq_ignore_ascii_case("js") {
162        "application/javascript; charset=utf-8"
163    } else if ext.eq_ignore_ascii_case("css") {
164        "text/css; charset=utf-8"
165    } else if ext.eq_ignore_ascii_case("map") {
166        "application/json"
167    } else {
168        "application/octet-stream"
169    }
170}
171
172#[cfg(test)]
173mod tests;