fraiseql_server/routes/studio/
mod.rs1pub 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#[derive(RustEmbed)]
28#[folder = "$FRAISEQL_STUDIO_DIST"]
29pub struct StudioAssets;
30
31#[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
136pub async fn studio_handler() -> impl IntoResponse {
140 Html(studio_shell_html())
141}
142
143pub 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
158pub(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;