@celsian/vura-cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/bin.js +15 -0
- package/dist/bin.d.ts +12 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +16 -0
- package/dist/bin.js.map +1 -0
- package/dist/commands/admin.d.ts +29 -0
- package/dist/commands/admin.d.ts.map +1 -0
- package/dist/commands/admin.js +1385 -0
- package/dist/commands/admin.js.map +1 -0
- package/dist/commands/build.d.ts +14 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +267 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/deploy.d.ts +8 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +22 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts +31 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +473 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/manifest.d.ts +8 -0
- package/dist/commands/manifest.d.ts.map +1 -0
- package/dist/commands/manifest.js +44 -0
- package/dist/commands/manifest.js.map +1 -0
- package/dist/config-loader.d.ts +13 -0
- package/dist/config-loader.d.ts.map +1 -0
- package/dist/config-loader.js +47 -0
- package/dist/config-loader.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vura admin` — Launch the Vura admin dashboard.
|
|
3
|
+
*
|
|
4
|
+
* A local web UI for managing deployments, routes, environment
|
|
5
|
+
* variables, and domain configuration. Inspired by Vercel's dashboard.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* vura admin — Start on 127.0.0.1:4000
|
|
9
|
+
* vura admin --port 9000 — Start on 127.0.0.1:9000
|
|
10
|
+
* vura admin --host 127.0.0.1 — Bind explicitly to loopback
|
|
11
|
+
*/
|
|
12
|
+
import { buildManifest } from '@celsian/vura-core';
|
|
13
|
+
import { loadConfig } from '../config-loader.js';
|
|
14
|
+
export const ADMIN_ENV_MAX_BODY_BYTES = 128 * 1024;
|
|
15
|
+
export function parseAdminOptions(args, projectRoot = process.cwd()) {
|
|
16
|
+
const portArg = args.find((_, i) => args[i - 1] === '--port');
|
|
17
|
+
const hostArg = args.find((_, i) => args[i - 1] === '--host');
|
|
18
|
+
return {
|
|
19
|
+
port: portArg ? parseInt(portArg, 10) : 4000,
|
|
20
|
+
host: hostArg || '127.0.0.1',
|
|
21
|
+
projectRoot,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function isLocalAdminHost(host) {
|
|
25
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
26
|
+
}
|
|
27
|
+
export function assertSafeAdminBindHost(host) {
|
|
28
|
+
if (!isLocalAdminHost(host)) {
|
|
29
|
+
throw new Error(`vura admin must bind to localhost/loopback. Refusing unsafe host "${host}" because the dashboard manages local secrets.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function adminApiHeaders(contentType = 'application/json') {
|
|
33
|
+
return {
|
|
34
|
+
'Content-Type': contentType,
|
|
35
|
+
'Cache-Control': 'no-store',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function isAllowedAdminRequest(headers, bindHost, port) {
|
|
39
|
+
const hostHeader = Array.isArray(headers.host) ? headers.host[0] : headers.host;
|
|
40
|
+
if (!hostHeader)
|
|
41
|
+
return false;
|
|
42
|
+
const allowedHosts = new Set([
|
|
43
|
+
`127.0.0.1:${port}`,
|
|
44
|
+
`localhost:${port}`,
|
|
45
|
+
`[::1]:${port}`,
|
|
46
|
+
]);
|
|
47
|
+
if (!isLocalAdminHost(bindHost))
|
|
48
|
+
return false;
|
|
49
|
+
allowedHosts.add(`${bindHost}:${port}`);
|
|
50
|
+
if (!allowedHosts.has(hostHeader))
|
|
51
|
+
return false;
|
|
52
|
+
const origin = Array.isArray(headers.origin) ? headers.origin[0] : headers.origin;
|
|
53
|
+
if (!origin)
|
|
54
|
+
return true;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(origin);
|
|
57
|
+
return allowedHosts.has(parsed.host) && (parsed.protocol === 'http:' || parsed.protocol === 'https:');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function adminCommand(args) {
|
|
64
|
+
const opts = parseAdminOptions(args);
|
|
65
|
+
assertSafeAdminBindHost(opts.host);
|
|
66
|
+
const { createServer } = await import('node:http');
|
|
67
|
+
const { randomBytes } = await import('node:crypto');
|
|
68
|
+
const { readFile, writeFile, readdir, stat, access } = await import('node:fs/promises');
|
|
69
|
+
const { join, basename } = await import('node:path');
|
|
70
|
+
// Scan project
|
|
71
|
+
let manifest = await buildManifest(opts.projectRoot);
|
|
72
|
+
const config = await loadConfig(opts.projectRoot);
|
|
73
|
+
const projectName = basename(opts.projectRoot);
|
|
74
|
+
const adminToken = randomBytes(32).toString('base64url');
|
|
75
|
+
const server = createServer(async (req, res) => {
|
|
76
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
77
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
78
|
+
const isAdminApi = url.pathname.startsWith('/__admin/api/');
|
|
79
|
+
const sameOrigin = isAllowedAdminRequest(req.headers, opts.host, opts.port);
|
|
80
|
+
const apiHeaders = adminApiHeaders();
|
|
81
|
+
if (method === 'OPTIONS') {
|
|
82
|
+
res.writeHead(sameOrigin ? 204 : 403, {
|
|
83
|
+
...apiHeaders,
|
|
84
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
85
|
+
'Access-Control-Allow-Headers': 'Content-Type, X-Then-Admin-Token',
|
|
86
|
+
});
|
|
87
|
+
res.end();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (isAdminApi) {
|
|
91
|
+
if (!sameOrigin || req.headers['x-then-admin-token'] !== adminToken) {
|
|
92
|
+
res.writeHead(403, apiHeaders);
|
|
93
|
+
res.end(JSON.stringify({ error: 'Forbidden' }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ─── API Endpoints ───
|
|
98
|
+
if (url.pathname === '/__admin/api/manifest' && method === 'GET') {
|
|
99
|
+
manifest = await buildManifest(opts.projectRoot);
|
|
100
|
+
res.writeHead(200, apiHeaders);
|
|
101
|
+
res.end(JSON.stringify(manifest));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (url.pathname === '/__admin/api/project' && method === 'GET') {
|
|
105
|
+
const pkg = await readFile(join(opts.projectRoot, 'package.json'), 'utf-8').catch(() => '{}');
|
|
106
|
+
const pkgJson = JSON.parse(pkg);
|
|
107
|
+
// Check for build output
|
|
108
|
+
let lastBuild = null;
|
|
109
|
+
let hasServerEntry = false;
|
|
110
|
+
let hasFunctions = false;
|
|
111
|
+
let hasStatic = false;
|
|
112
|
+
let functionCount = 0;
|
|
113
|
+
let staticPageCount = 0;
|
|
114
|
+
let taskEntryCount = 0;
|
|
115
|
+
try {
|
|
116
|
+
const manifestFile = await readFile(join(opts.projectRoot, 'dist', 'manifest.json'), 'utf-8');
|
|
117
|
+
const buildManifest = JSON.parse(manifestFile);
|
|
118
|
+
lastBuild = buildManifest.timestamp;
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
try {
|
|
122
|
+
await access(join(opts.projectRoot, 'dist', 'server', 'entry.js'));
|
|
123
|
+
hasServerEntry = true;
|
|
124
|
+
}
|
|
125
|
+
catch { }
|
|
126
|
+
try {
|
|
127
|
+
const funcs = await readdir(join(opts.projectRoot, 'dist', 'functions'));
|
|
128
|
+
functionCount = funcs.length;
|
|
129
|
+
hasFunctions = functionCount > 0;
|
|
130
|
+
taskEntryCount = funcs.filter(f => f.startsWith('task_')).length;
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
try {
|
|
134
|
+
const statics = await readdir(join(opts.projectRoot, 'dist', 'static'), { recursive: true });
|
|
135
|
+
staticPageCount = statics.filter(f => String(f).endsWith('.html')).length;
|
|
136
|
+
hasStatic = staticPageCount > 0;
|
|
137
|
+
}
|
|
138
|
+
catch { }
|
|
139
|
+
// Check adapter output
|
|
140
|
+
let adapterName = config.adapter?.name ?? null;
|
|
141
|
+
let adapterOutput = {};
|
|
142
|
+
for (const dir of ['cloudflare', 'lambda']) {
|
|
143
|
+
try {
|
|
144
|
+
await access(join(opts.projectRoot, 'dist', dir));
|
|
145
|
+
adapterOutput[dir] = true;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
adapterOutput[dir] = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
res.writeHead(200, apiHeaders);
|
|
152
|
+
res.end(JSON.stringify({
|
|
153
|
+
name: pkgJson.name ?? projectName,
|
|
154
|
+
version: pkgJson.version ?? '0.0.0',
|
|
155
|
+
root: opts.projectRoot,
|
|
156
|
+
adapter: adapterName,
|
|
157
|
+
adapterOutput,
|
|
158
|
+
lastBuild,
|
|
159
|
+
hasServerEntry,
|
|
160
|
+
hasFunctions,
|
|
161
|
+
hasStatic,
|
|
162
|
+
functionCount,
|
|
163
|
+
staticPageCount,
|
|
164
|
+
taskEntryCount,
|
|
165
|
+
nodeVersion: process.version,
|
|
166
|
+
platform: process.platform,
|
|
167
|
+
}));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (url.pathname === '/__admin/api/env' && method === 'GET') {
|
|
171
|
+
let envContent = '';
|
|
172
|
+
try {
|
|
173
|
+
envContent = await readFile(join(opts.projectRoot, '.env'), 'utf-8');
|
|
174
|
+
}
|
|
175
|
+
catch { }
|
|
176
|
+
let envLocalContent = '';
|
|
177
|
+
try {
|
|
178
|
+
envLocalContent = await readFile(join(opts.projectRoot, '.env.local'), 'utf-8');
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
const parseEnv = (content) => {
|
|
182
|
+
const vars = [];
|
|
183
|
+
for (const line of content.split('\n')) {
|
|
184
|
+
const trimmed = line.trim();
|
|
185
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
186
|
+
continue;
|
|
187
|
+
const eqIdx = trimmed.indexOf('=');
|
|
188
|
+
if (eqIdx > 0) {
|
|
189
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
190
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
191
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
192
|
+
value = value.slice(1, -1);
|
|
193
|
+
}
|
|
194
|
+
vars.push({ key, value });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return vars;
|
|
198
|
+
};
|
|
199
|
+
res.writeHead(200, apiHeaders);
|
|
200
|
+
res.end(JSON.stringify({
|
|
201
|
+
env: parseEnv(envContent),
|
|
202
|
+
envLocal: parseEnv(envLocalContent),
|
|
203
|
+
rawEnv: envContent,
|
|
204
|
+
rawEnvLocal: envLocalContent,
|
|
205
|
+
}));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (url.pathname === '/__admin/api/env' && method === 'POST') {
|
|
209
|
+
try {
|
|
210
|
+
const chunks = [];
|
|
211
|
+
let totalBytes = 0;
|
|
212
|
+
for await (const chunk of req) {
|
|
213
|
+
const buffer = chunk;
|
|
214
|
+
totalBytes += buffer.byteLength;
|
|
215
|
+
if (totalBytes > ADMIN_ENV_MAX_BODY_BYTES) {
|
|
216
|
+
res.writeHead(413, apiHeaders);
|
|
217
|
+
res.end(JSON.stringify({ error: 'Env save payload too large' }));
|
|
218
|
+
req.destroy();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
chunks.push(buffer);
|
|
222
|
+
}
|
|
223
|
+
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
224
|
+
if (body.file !== '.env' && body.file !== '.env.local') {
|
|
225
|
+
res.writeHead(400, apiHeaders);
|
|
226
|
+
res.end(JSON.stringify({ error: 'Invalid file' }));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (typeof body.content !== 'string') {
|
|
230
|
+
res.writeHead(400, apiHeaders);
|
|
231
|
+
res.end(JSON.stringify({ error: 'Invalid env content' }));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await writeFile(join(opts.projectRoot, body.file), body.content, 'utf-8');
|
|
235
|
+
res.writeHead(200, apiHeaders);
|
|
236
|
+
res.end(JSON.stringify({ ok: true }));
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
res.writeHead(400, apiHeaders);
|
|
240
|
+
res.end(JSON.stringify({ error: err instanceof SyntaxError ? 'Invalid JSON body' : 'Failed to save env file' }));
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (url.pathname === '/__admin/api/deployments' && method === 'GET') {
|
|
245
|
+
const deployments = [];
|
|
246
|
+
// Hot server
|
|
247
|
+
if (manifest.api.some(r => r.kind === 'hot') || manifest.pages.some(p => p.mode === 'server')) {
|
|
248
|
+
deployments.push({
|
|
249
|
+
type: 'hot-server',
|
|
250
|
+
label: 'Hot Server',
|
|
251
|
+
description: 'Long-running Node.js server for hot routes & SSR pages',
|
|
252
|
+
entry: 'dist/server/entry.js',
|
|
253
|
+
url: `http://localhost:${process.env.PORT || 3000}`,
|
|
254
|
+
routes: [
|
|
255
|
+
...manifest.api.filter(r => r.kind === 'hot').map(r => ({ pattern: r.urlPattern, methods: r.methods })),
|
|
256
|
+
...manifest.pages.filter(p => p.mode === 'server' || p.mode === 'hybrid').map(p => ({ pattern: p.urlPattern, methods: ['GET'] })),
|
|
257
|
+
],
|
|
258
|
+
status: 'ready',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Static / CDN
|
|
262
|
+
if (manifest.pages.some(p => p.mode === 'static')) {
|
|
263
|
+
deployments.push({
|
|
264
|
+
type: 'static',
|
|
265
|
+
label: 'Static Pages',
|
|
266
|
+
description: 'Pre-rendered HTML pages for CDN deployment',
|
|
267
|
+
directory: 'dist/static/',
|
|
268
|
+
pages: manifest.pages.filter(p => p.mode === 'static').map(p => p.urlPattern),
|
|
269
|
+
status: 'ready',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// Serverless functions
|
|
273
|
+
const serverlessFns = manifest.api.filter(r => r.kind === 'serverless');
|
|
274
|
+
if (serverlessFns.length > 0) {
|
|
275
|
+
deployments.push({
|
|
276
|
+
type: 'serverless',
|
|
277
|
+
label: 'Serverless Functions',
|
|
278
|
+
description: 'Individual function bundles for Lambda/Workers',
|
|
279
|
+
directory: 'dist/functions/',
|
|
280
|
+
functions: serverlessFns.map(r => ({ pattern: r.urlPattern, methods: r.methods })),
|
|
281
|
+
status: 'ready',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
// Task routes
|
|
285
|
+
const taskFns = manifest.api.filter(r => r.kind === 'task');
|
|
286
|
+
if (taskFns.length > 0) {
|
|
287
|
+
deployments.push({
|
|
288
|
+
type: 'tasks',
|
|
289
|
+
label: 'Background Tasks',
|
|
290
|
+
description: 'Scheduled & on-demand task workers',
|
|
291
|
+
directory: 'dist/functions/',
|
|
292
|
+
tasks: taskFns.map(r => ({
|
|
293
|
+
pattern: r.urlPattern,
|
|
294
|
+
schedule: r.config.schedule ?? null,
|
|
295
|
+
retries: r.config.retries ?? 0,
|
|
296
|
+
timeout: r.config.timeout ?? 30000,
|
|
297
|
+
})),
|
|
298
|
+
status: 'ready',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
res.writeHead(200, apiHeaders);
|
|
302
|
+
res.end(JSON.stringify(deployments));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// ─── Serve Dashboard UI ───
|
|
306
|
+
if (url.pathname === '/' || url.pathname === '/admin' || url.pathname.startsWith('/admin')) {
|
|
307
|
+
res.writeHead(200, adminApiHeaders('text/html; charset=utf-8'));
|
|
308
|
+
res.end(renderDashboardHtml(adminToken));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
res.writeHead(404, apiHeaders);
|
|
312
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
313
|
+
});
|
|
314
|
+
server.listen(opts.port, opts.host, () => {
|
|
315
|
+
const displayHost = opts.host === '127.0.0.1' ? 'localhost' : opts.host;
|
|
316
|
+
const displayUrl = displayHost.includes(':')
|
|
317
|
+
? `http://[${displayHost}]:${opts.port}`
|
|
318
|
+
: `http://${displayHost}:${opts.port}`;
|
|
319
|
+
console.log(`
|
|
320
|
+
┌─────────────────────────────────────────┐
|
|
321
|
+
│ │
|
|
322
|
+
│ vura admin │
|
|
323
|
+
│ │
|
|
324
|
+
│ Dashboard: ${displayUrl.padEnd(22)} │
|
|
325
|
+
│ Token: ${adminToken.slice(0, 8).padEnd(25)} │
|
|
326
|
+
│ Project: ${projectName.slice(0, 25).padEnd(25)} │
|
|
327
|
+
│ │
|
|
328
|
+
│ ${manifest.api.length} API routes · ${manifest.pages.length} pages │
|
|
329
|
+
│ │
|
|
330
|
+
└─────────────────────────────────────────┘
|
|
331
|
+
`);
|
|
332
|
+
});
|
|
333
|
+
await new Promise(() => { });
|
|
334
|
+
}
|
|
335
|
+
// ─── Dashboard HTML ───
|
|
336
|
+
export function renderDashboardHtml(adminToken) {
|
|
337
|
+
return `<!DOCTYPE html>
|
|
338
|
+
<html lang="en">
|
|
339
|
+
<head>
|
|
340
|
+
<meta charset="UTF-8">
|
|
341
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
342
|
+
<title>vura · admin</title>
|
|
343
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
344
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
345
|
+
<style>
|
|
346
|
+
:root {
|
|
347
|
+
--bg-root: #0a0a0b;
|
|
348
|
+
--bg-surface: #111113;
|
|
349
|
+
--bg-card: #161618;
|
|
350
|
+
--bg-elevated: #1c1c1f;
|
|
351
|
+
--bg-input: #1a1a1d;
|
|
352
|
+
--border: #27272a;
|
|
353
|
+
--border-subtle: #1f1f22;
|
|
354
|
+
--border-focus: #3b82f6;
|
|
355
|
+
--text-primary: #fafafa;
|
|
356
|
+
--text-secondary: #a1a1aa;
|
|
357
|
+
--text-tertiary: #71717a;
|
|
358
|
+
--accent: #22c55e;
|
|
359
|
+
--accent-dim: rgba(34, 197, 94, 0.12);
|
|
360
|
+
--accent-blue: #3b82f6;
|
|
361
|
+
--accent-blue-dim: rgba(59, 130, 246, 0.12);
|
|
362
|
+
--accent-amber: #f59e0b;
|
|
363
|
+
--accent-amber-dim: rgba(245, 158, 11, 0.12);
|
|
364
|
+
--accent-red: #ef4444;
|
|
365
|
+
--accent-red-dim: rgba(239, 68, 68, 0.12);
|
|
366
|
+
--accent-purple: #a78bfa;
|
|
367
|
+
--accent-purple-dim: rgba(167, 139, 250, 0.12);
|
|
368
|
+
--accent-cyan: #06b6d4;
|
|
369
|
+
--accent-cyan-dim: rgba(6, 182, 212, 0.12);
|
|
370
|
+
--radius: 8px;
|
|
371
|
+
--radius-lg: 12px;
|
|
372
|
+
--font-sans: 'Outfit', -apple-system, sans-serif;
|
|
373
|
+
--font-mono: 'JetBrains Mono', monospace;
|
|
374
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border);
|
|
375
|
+
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
379
|
+
|
|
380
|
+
body {
|
|
381
|
+
font-family: var(--font-sans);
|
|
382
|
+
background: var(--bg-root);
|
|
383
|
+
color: var(--text-primary);
|
|
384
|
+
min-height: 100vh;
|
|
385
|
+
-webkit-font-smoothing: antialiased;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ─── Noise overlay ─── */
|
|
389
|
+
body::before {
|
|
390
|
+
content: '';
|
|
391
|
+
position: fixed;
|
|
392
|
+
inset: 0;
|
|
393
|
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
|
|
394
|
+
pointer-events: none;
|
|
395
|
+
z-index: 9999;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ─── Layout ─── */
|
|
399
|
+
.layout {
|
|
400
|
+
display: grid;
|
|
401
|
+
grid-template-columns: 220px 1fr;
|
|
402
|
+
min-height: 100vh;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* ─── Sidebar ─── */
|
|
406
|
+
.sidebar {
|
|
407
|
+
background: var(--bg-surface);
|
|
408
|
+
border-right: 1px solid var(--border);
|
|
409
|
+
padding: 20px 0;
|
|
410
|
+
display: flex;
|
|
411
|
+
flex-direction: column;
|
|
412
|
+
position: sticky;
|
|
413
|
+
top: 0;
|
|
414
|
+
height: 100vh;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.sidebar-logo {
|
|
418
|
+
padding: 0 20px 20px;
|
|
419
|
+
border-bottom: 1px solid var(--border);
|
|
420
|
+
margin-bottom: 8px;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.sidebar-logo h1 {
|
|
424
|
+
font-size: 15px;
|
|
425
|
+
font-weight: 600;
|
|
426
|
+
letter-spacing: -0.02em;
|
|
427
|
+
display: flex;
|
|
428
|
+
align-items: center;
|
|
429
|
+
gap: 8px;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.sidebar-logo h1 .logo-mark {
|
|
433
|
+
width: 22px;
|
|
434
|
+
height: 22px;
|
|
435
|
+
background: var(--accent);
|
|
436
|
+
border-radius: 6px;
|
|
437
|
+
display: flex;
|
|
438
|
+
align-items: center;
|
|
439
|
+
justify-content: center;
|
|
440
|
+
font-size: 11px;
|
|
441
|
+
font-weight: 700;
|
|
442
|
+
color: #000;
|
|
443
|
+
font-family: var(--font-mono);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.sidebar-logo .project-name {
|
|
447
|
+
font-size: 12px;
|
|
448
|
+
color: var(--text-tertiary);
|
|
449
|
+
margin-top: 4px;
|
|
450
|
+
font-family: var(--font-mono);
|
|
451
|
+
font-weight: 400;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.nav-section {
|
|
455
|
+
padding: 8px 12px;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.nav-section-label {
|
|
459
|
+
font-size: 10px;
|
|
460
|
+
text-transform: uppercase;
|
|
461
|
+
letter-spacing: 0.08em;
|
|
462
|
+
color: var(--text-tertiary);
|
|
463
|
+
padding: 8px 8px 4px;
|
|
464
|
+
font-weight: 500;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.nav-item {
|
|
468
|
+
display: flex;
|
|
469
|
+
align-items: center;
|
|
470
|
+
gap: 10px;
|
|
471
|
+
padding: 8px 12px;
|
|
472
|
+
border-radius: var(--radius);
|
|
473
|
+
cursor: pointer;
|
|
474
|
+
font-size: 13px;
|
|
475
|
+
font-weight: 400;
|
|
476
|
+
color: var(--text-secondary);
|
|
477
|
+
transition: all var(--transition);
|
|
478
|
+
user-select: none;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.nav-item:hover {
|
|
482
|
+
background: var(--bg-elevated);
|
|
483
|
+
color: var(--text-primary);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.nav-item.active {
|
|
487
|
+
background: var(--bg-elevated);
|
|
488
|
+
color: var(--text-primary);
|
|
489
|
+
font-weight: 500;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.nav-item .nav-icon {
|
|
493
|
+
font-size: 15px;
|
|
494
|
+
width: 20px;
|
|
495
|
+
text-align: center;
|
|
496
|
+
opacity: 0.7;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.nav-item.active .nav-icon { opacity: 1; }
|
|
500
|
+
|
|
501
|
+
.sidebar-footer {
|
|
502
|
+
margin-top: auto;
|
|
503
|
+
padding: 12px 20px;
|
|
504
|
+
border-top: 1px solid var(--border);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.sidebar-footer .system-info {
|
|
508
|
+
font-size: 11px;
|
|
509
|
+
color: var(--text-tertiary);
|
|
510
|
+
font-family: var(--font-mono);
|
|
511
|
+
line-height: 1.6;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* ─── Main ─── */
|
|
515
|
+
.main {
|
|
516
|
+
padding: 32px 40px;
|
|
517
|
+
max-width: 1100px;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.page-header {
|
|
521
|
+
margin-bottom: 28px;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.page-header h2 {
|
|
525
|
+
font-size: 22px;
|
|
526
|
+
font-weight: 600;
|
|
527
|
+
letter-spacing: -0.03em;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.page-header p {
|
|
531
|
+
font-size: 13px;
|
|
532
|
+
color: var(--text-tertiary);
|
|
533
|
+
margin-top: 4px;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* ─── Cards ─── */
|
|
537
|
+
.card {
|
|
538
|
+
background: var(--bg-card);
|
|
539
|
+
border: 1px solid var(--border);
|
|
540
|
+
border-radius: var(--radius-lg);
|
|
541
|
+
overflow: hidden;
|
|
542
|
+
margin-bottom: 16px;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.card-header {
|
|
546
|
+
padding: 16px 20px;
|
|
547
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
548
|
+
display: flex;
|
|
549
|
+
align-items: center;
|
|
550
|
+
justify-content: space-between;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.card-header h3 {
|
|
554
|
+
font-size: 13px;
|
|
555
|
+
font-weight: 600;
|
|
556
|
+
display: flex;
|
|
557
|
+
align-items: center;
|
|
558
|
+
gap: 8px;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.card-body {
|
|
562
|
+
padding: 16px 20px;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/* ─── Stats Grid ─── */
|
|
566
|
+
.stats-grid {
|
|
567
|
+
display: grid;
|
|
568
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
569
|
+
gap: 12px;
|
|
570
|
+
margin-bottom: 24px;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.stat-card {
|
|
574
|
+
background: var(--bg-card);
|
|
575
|
+
border: 1px solid var(--border);
|
|
576
|
+
border-radius: var(--radius-lg);
|
|
577
|
+
padding: 20px;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.stat-card .stat-label {
|
|
581
|
+
font-size: 11px;
|
|
582
|
+
text-transform: uppercase;
|
|
583
|
+
letter-spacing: 0.06em;
|
|
584
|
+
color: var(--text-tertiary);
|
|
585
|
+
font-weight: 500;
|
|
586
|
+
margin-bottom: 8px;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
.stat-card .stat-value {
|
|
590
|
+
font-size: 28px;
|
|
591
|
+
font-weight: 700;
|
|
592
|
+
letter-spacing: -0.04em;
|
|
593
|
+
font-family: var(--font-mono);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.stat-card .stat-detail {
|
|
597
|
+
font-size: 11px;
|
|
598
|
+
color: var(--text-tertiary);
|
|
599
|
+
margin-top: 4px;
|
|
600
|
+
font-family: var(--font-mono);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ─── Badges ─── */
|
|
604
|
+
.badge {
|
|
605
|
+
display: inline-flex;
|
|
606
|
+
align-items: center;
|
|
607
|
+
gap: 4px;
|
|
608
|
+
padding: 3px 8px;
|
|
609
|
+
border-radius: 999px;
|
|
610
|
+
font-size: 11px;
|
|
611
|
+
font-weight: 500;
|
|
612
|
+
font-family: var(--font-mono);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.badge-green { background: var(--accent-dim); color: var(--accent); }
|
|
616
|
+
.badge-blue { background: var(--accent-blue-dim); color: var(--accent-blue); }
|
|
617
|
+
.badge-amber { background: var(--accent-amber-dim); color: var(--accent-amber); }
|
|
618
|
+
.badge-red { background: var(--accent-red-dim); color: var(--accent-red); }
|
|
619
|
+
.badge-purple { background: var(--accent-purple-dim); color: var(--accent-purple); }
|
|
620
|
+
.badge-cyan { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
|
621
|
+
|
|
622
|
+
.badge .dot {
|
|
623
|
+
width: 6px;
|
|
624
|
+
height: 6px;
|
|
625
|
+
border-radius: 50%;
|
|
626
|
+
background: currentColor;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/* ─── Table ─── */
|
|
630
|
+
.data-table {
|
|
631
|
+
width: 100%;
|
|
632
|
+
border-collapse: collapse;
|
|
633
|
+
font-size: 13px;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.data-table th {
|
|
637
|
+
text-align: left;
|
|
638
|
+
padding: 10px 16px;
|
|
639
|
+
font-size: 11px;
|
|
640
|
+
text-transform: uppercase;
|
|
641
|
+
letter-spacing: 0.06em;
|
|
642
|
+
color: var(--text-tertiary);
|
|
643
|
+
font-weight: 500;
|
|
644
|
+
border-bottom: 1px solid var(--border);
|
|
645
|
+
background: var(--bg-surface);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.data-table td {
|
|
649
|
+
padding: 12px 16px;
|
|
650
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
651
|
+
vertical-align: middle;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.data-table tr:last-child td { border-bottom: none; }
|
|
655
|
+
|
|
656
|
+
.data-table tr:hover td {
|
|
657
|
+
background: var(--bg-elevated);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.data-table .mono {
|
|
661
|
+
font-family: var(--font-mono);
|
|
662
|
+
font-size: 12px;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/* ─── Deployment Cards ─── */
|
|
666
|
+
.deploy-card {
|
|
667
|
+
background: var(--bg-card);
|
|
668
|
+
border: 1px solid var(--border);
|
|
669
|
+
border-radius: var(--radius-lg);
|
|
670
|
+
padding: 20px;
|
|
671
|
+
margin-bottom: 12px;
|
|
672
|
+
display: flex;
|
|
673
|
+
align-items: flex-start;
|
|
674
|
+
gap: 16px;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.deploy-card .deploy-icon {
|
|
678
|
+
width: 40px;
|
|
679
|
+
height: 40px;
|
|
680
|
+
border-radius: var(--radius);
|
|
681
|
+
display: flex;
|
|
682
|
+
align-items: center;
|
|
683
|
+
justify-content: center;
|
|
684
|
+
font-size: 18px;
|
|
685
|
+
flex-shrink: 0;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.deploy-card .deploy-info { flex: 1; min-width: 0; }
|
|
689
|
+
|
|
690
|
+
.deploy-card .deploy-info h4 {
|
|
691
|
+
font-size: 14px;
|
|
692
|
+
font-weight: 600;
|
|
693
|
+
margin-bottom: 2px;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.deploy-card .deploy-info p {
|
|
697
|
+
font-size: 12px;
|
|
698
|
+
color: var(--text-tertiary);
|
|
699
|
+
margin-bottom: 10px;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.deploy-card .deploy-url {
|
|
703
|
+
font-family: var(--font-mono);
|
|
704
|
+
font-size: 12px;
|
|
705
|
+
color: var(--accent-blue);
|
|
706
|
+
background: var(--accent-blue-dim);
|
|
707
|
+
padding: 6px 10px;
|
|
708
|
+
border-radius: 6px;
|
|
709
|
+
display: inline-block;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.deploy-card .deploy-routes {
|
|
713
|
+
display: flex;
|
|
714
|
+
flex-wrap: wrap;
|
|
715
|
+
gap: 4px;
|
|
716
|
+
margin-top: 8px;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.deploy-card .route-chip {
|
|
720
|
+
font-family: var(--font-mono);
|
|
721
|
+
font-size: 11px;
|
|
722
|
+
padding: 2px 8px;
|
|
723
|
+
border-radius: 4px;
|
|
724
|
+
background: var(--bg-elevated);
|
|
725
|
+
border: 1px solid var(--border);
|
|
726
|
+
color: var(--text-secondary);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/* ─── Env Editor ─── */
|
|
730
|
+
.env-editor {
|
|
731
|
+
margin-bottom: 16px;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.env-row {
|
|
735
|
+
display: grid;
|
|
736
|
+
grid-template-columns: 200px 1fr 36px;
|
|
737
|
+
gap: 8px;
|
|
738
|
+
margin-bottom: 6px;
|
|
739
|
+
align-items: center;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.env-row input {
|
|
743
|
+
font-family: var(--font-mono);
|
|
744
|
+
font-size: 12px;
|
|
745
|
+
padding: 8px 12px;
|
|
746
|
+
background: var(--bg-input);
|
|
747
|
+
border: 1px solid var(--border);
|
|
748
|
+
border-radius: 6px;
|
|
749
|
+
color: var(--text-primary);
|
|
750
|
+
outline: none;
|
|
751
|
+
transition: border-color var(--transition);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.env-row input:focus {
|
|
755
|
+
border-color: var(--border-focus);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
.env-row input.env-key {
|
|
759
|
+
font-weight: 500;
|
|
760
|
+
text-transform: uppercase;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.env-row input.env-value {
|
|
764
|
+
color: var(--accent);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.env-btn {
|
|
768
|
+
width: 32px;
|
|
769
|
+
height: 32px;
|
|
770
|
+
display: flex;
|
|
771
|
+
align-items: center;
|
|
772
|
+
justify-content: center;
|
|
773
|
+
border-radius: 6px;
|
|
774
|
+
border: 1px solid var(--border);
|
|
775
|
+
background: var(--bg-input);
|
|
776
|
+
color: var(--text-tertiary);
|
|
777
|
+
cursor: pointer;
|
|
778
|
+
font-size: 14px;
|
|
779
|
+
transition: all var(--transition);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.env-btn:hover { background: var(--accent-red-dim); color: var(--accent-red); border-color: transparent; }
|
|
783
|
+
|
|
784
|
+
.btn {
|
|
785
|
+
padding: 8px 16px;
|
|
786
|
+
border-radius: 6px;
|
|
787
|
+
border: 1px solid var(--border);
|
|
788
|
+
background: var(--bg-elevated);
|
|
789
|
+
color: var(--text-primary);
|
|
790
|
+
font-family: var(--font-sans);
|
|
791
|
+
font-size: 12px;
|
|
792
|
+
font-weight: 500;
|
|
793
|
+
cursor: pointer;
|
|
794
|
+
transition: all var(--transition);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.btn:hover { background: var(--bg-card); }
|
|
798
|
+
|
|
799
|
+
.btn-primary {
|
|
800
|
+
background: var(--text-primary);
|
|
801
|
+
color: var(--bg-root);
|
|
802
|
+
border-color: transparent;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.btn-primary:hover { opacity: 0.9; background: var(--text-primary); }
|
|
806
|
+
|
|
807
|
+
.btn-group {
|
|
808
|
+
display: flex;
|
|
809
|
+
gap: 8px;
|
|
810
|
+
margin-top: 12px;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/* ─── Domain Section ─── */
|
|
814
|
+
.domain-card {
|
|
815
|
+
display: flex;
|
|
816
|
+
align-items: center;
|
|
817
|
+
justify-content: space-between;
|
|
818
|
+
padding: 14px 20px;
|
|
819
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
.domain-card:last-child { border-bottom: none; }
|
|
823
|
+
|
|
824
|
+
.domain-info {
|
|
825
|
+
display: flex;
|
|
826
|
+
align-items: center;
|
|
827
|
+
gap: 12px;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.domain-info .domain-type {
|
|
831
|
+
font-size: 11px;
|
|
832
|
+
color: var(--text-tertiary);
|
|
833
|
+
text-transform: uppercase;
|
|
834
|
+
letter-spacing: 0.06em;
|
|
835
|
+
font-weight: 500;
|
|
836
|
+
width: 80px;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.domain-info .domain-url {
|
|
840
|
+
font-family: var(--font-mono);
|
|
841
|
+
font-size: 13px;
|
|
842
|
+
color: var(--text-primary);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
.domain-status {
|
|
846
|
+
display: flex;
|
|
847
|
+
align-items: center;
|
|
848
|
+
gap: 6px;
|
|
849
|
+
font-size: 12px;
|
|
850
|
+
color: var(--text-tertiary);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/* ─── Animations ─── */
|
|
854
|
+
@keyframes fadeIn {
|
|
855
|
+
from { opacity: 0; transform: translateY(6px); }
|
|
856
|
+
to { opacity: 1; transform: translateY(0); }
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
.fade-in {
|
|
860
|
+
animation: fadeIn 0.3s ease-out forwards;
|
|
861
|
+
opacity: 0;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
.fade-in:nth-child(1) { animation-delay: 0ms; }
|
|
865
|
+
.fade-in:nth-child(2) { animation-delay: 50ms; }
|
|
866
|
+
.fade-in:nth-child(3) { animation-delay: 100ms; }
|
|
867
|
+
.fade-in:nth-child(4) { animation-delay: 150ms; }
|
|
868
|
+
.fade-in:nth-child(5) { animation-delay: 200ms; }
|
|
869
|
+
.fade-in:nth-child(6) { animation-delay: 250ms; }
|
|
870
|
+
|
|
871
|
+
@keyframes pulse {
|
|
872
|
+
0%, 100% { opacity: 1; }
|
|
873
|
+
50% { opacity: 0.5; }
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
.pulse { animation: pulse 2s infinite; }
|
|
877
|
+
|
|
878
|
+
/* ─── Toast ─── */
|
|
879
|
+
.toast {
|
|
880
|
+
position: fixed;
|
|
881
|
+
bottom: 24px;
|
|
882
|
+
right: 24px;
|
|
883
|
+
background: var(--bg-elevated);
|
|
884
|
+
border: 1px solid var(--border);
|
|
885
|
+
border-radius: var(--radius);
|
|
886
|
+
padding: 12px 20px;
|
|
887
|
+
font-size: 13px;
|
|
888
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
889
|
+
z-index: 10000;
|
|
890
|
+
transform: translateY(80px);
|
|
891
|
+
opacity: 0;
|
|
892
|
+
transition: all 0.3s ease;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
.toast.show {
|
|
896
|
+
transform: translateY(0);
|
|
897
|
+
opacity: 1;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
.hidden { display: none !important; }
|
|
901
|
+
.empty-state {
|
|
902
|
+
text-align: center;
|
|
903
|
+
padding: 40px 20px;
|
|
904
|
+
color: var(--text-tertiary);
|
|
905
|
+
font-size: 13px;
|
|
906
|
+
}
|
|
907
|
+
</style>
|
|
908
|
+
</head>
|
|
909
|
+
<body>
|
|
910
|
+
|
|
911
|
+
<div class="layout">
|
|
912
|
+
<!-- Sidebar -->
|
|
913
|
+
<nav class="sidebar">
|
|
914
|
+
<div class="sidebar-logo">
|
|
915
|
+
<h1><span class="logo-mark">v</span> vura</h1>
|
|
916
|
+
<div class="project-name" id="projectName">loading...</div>
|
|
917
|
+
</div>
|
|
918
|
+
|
|
919
|
+
<div class="nav-section">
|
|
920
|
+
<div class="nav-section-label">Overview</div>
|
|
921
|
+
<div class="nav-item active" data-page="overview">
|
|
922
|
+
<span class="nav-icon">◫</span> Overview
|
|
923
|
+
</div>
|
|
924
|
+
<div class="nav-item" data-page="deployments">
|
|
925
|
+
<span class="nav-icon">▲</span> Deployments
|
|
926
|
+
</div>
|
|
927
|
+
<div class="nav-item" data-page="routes">
|
|
928
|
+
<span class="nav-icon">⑂</span> Routes
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
|
|
932
|
+
<div class="nav-section">
|
|
933
|
+
<div class="nav-section-label">Settings</div>
|
|
934
|
+
<div class="nav-item" data-page="env">
|
|
935
|
+
<span class="nav-icon">⎋</span> Environment
|
|
936
|
+
</div>
|
|
937
|
+
<div class="nav-item" data-page="domains">
|
|
938
|
+
<span class="nav-icon">◎</span> Domains
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
|
|
942
|
+
<div class="sidebar-footer">
|
|
943
|
+
<div class="system-info" id="systemInfo">loading...</div>
|
|
944
|
+
</div>
|
|
945
|
+
</nav>
|
|
946
|
+
|
|
947
|
+
<!-- Main Content -->
|
|
948
|
+
<main class="main" id="mainContent">
|
|
949
|
+
<!-- Pages rendered by JS -->
|
|
950
|
+
</main>
|
|
951
|
+
</div>
|
|
952
|
+
|
|
953
|
+
<div class="toast" id="toast"></div>
|
|
954
|
+
|
|
955
|
+
<script>
|
|
956
|
+
const API = '/__admin/api';
|
|
957
|
+
const ADMIN_TOKEN = '__THEN_ADMIN_TOKEN__';
|
|
958
|
+
let state = { project: null, manifest: null, deployments: null, env: null };
|
|
959
|
+
let currentPage = 'overview';
|
|
960
|
+
const HTML_ESCAPE = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
961
|
+
function h(value) { return String(value ?? '').replace(/[&<>"']/g, ch => HTML_ESCAPE[ch]); }
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
// ─── Data Fetching ───
|
|
965
|
+
function adminFetch(path, options = {}) {
|
|
966
|
+
return fetch(API + path, {
|
|
967
|
+
...options,
|
|
968
|
+
headers: {
|
|
969
|
+
...(options.headers || {}),
|
|
970
|
+
'X-Then-Admin-Token': ADMIN_TOKEN,
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function fetchAll() {
|
|
976
|
+
const [project, manifest, deployments, env] = await Promise.all([
|
|
977
|
+
adminFetch('/project').then(r => r.json()),
|
|
978
|
+
adminFetch('/manifest').then(r => r.json()),
|
|
979
|
+
adminFetch('/deployments').then(r => r.json()),
|
|
980
|
+
adminFetch('/env').then(r => r.json()),
|
|
981
|
+
]);
|
|
982
|
+
state = { project, manifest, deployments, env };
|
|
983
|
+
document.getElementById('projectName').textContent = project.name;
|
|
984
|
+
document.getElementById('systemInfo').textContent =
|
|
985
|
+
'Node ' + project.nodeVersion + ' · ' + project.platform;
|
|
986
|
+
renderPage(currentPage);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// ─── Navigation ───
|
|
990
|
+
document.querySelectorAll('.nav-item').forEach(item => {
|
|
991
|
+
item.addEventListener('click', () => {
|
|
992
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
993
|
+
item.classList.add('active');
|
|
994
|
+
currentPage = item.dataset.page;
|
|
995
|
+
renderPage(currentPage);
|
|
996
|
+
});
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// ─── Toast ───
|
|
1000
|
+
function showToast(msg) {
|
|
1001
|
+
const t = document.getElementById('toast');
|
|
1002
|
+
t.textContent = msg;
|
|
1003
|
+
t.classList.add('show');
|
|
1004
|
+
setTimeout(() => t.classList.remove('show'), 2500);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ─── Page Renderer ───
|
|
1008
|
+
function renderPage(page) {
|
|
1009
|
+
const main = document.getElementById('mainContent');
|
|
1010
|
+
const renderers = { overview: renderOverview, deployments: renderDeployments, routes: renderRoutes, env: renderEnv, domains: renderDomains };
|
|
1011
|
+
main.innerHTML = (renderers[page] || renderOverview)();
|
|
1012
|
+
bindPageEvents(page);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ─── Overview ───
|
|
1016
|
+
function renderOverview() {
|
|
1017
|
+
const p = state.project;
|
|
1018
|
+
const m = state.manifest;
|
|
1019
|
+
if (!p || !m) return '<div class="empty-state">Loading...</div>';
|
|
1020
|
+
|
|
1021
|
+
const apiCount = m.api.length;
|
|
1022
|
+
const pageCount = m.pages.length;
|
|
1023
|
+
const serverless = m.api.filter(r => r.kind === 'serverless').length;
|
|
1024
|
+
const hot = m.api.filter(r => r.kind === 'hot').length;
|
|
1025
|
+
const tasks = m.api.filter(r => r.kind === 'task').length;
|
|
1026
|
+
|
|
1027
|
+
return \`
|
|
1028
|
+
<div class="page-header">
|
|
1029
|
+
<h2>\${h(p.name)}</h2>
|
|
1030
|
+
<p>\${h(p.root)}</p>
|
|
1031
|
+
</div>
|
|
1032
|
+
|
|
1033
|
+
<div class="stats-grid">
|
|
1034
|
+
<div class="stat-card fade-in">
|
|
1035
|
+
<div class="stat-label">API Routes</div>
|
|
1036
|
+
<div class="stat-value">\${apiCount}</div>
|
|
1037
|
+
<div class="stat-detail">\${serverless} serverless · \${hot} hot · \${tasks} task</div>
|
|
1038
|
+
</div>
|
|
1039
|
+
<div class="stat-card fade-in">
|
|
1040
|
+
<div class="stat-label">Pages</div>
|
|
1041
|
+
<div class="stat-value">\${pageCount}</div>
|
|
1042
|
+
<div class="stat-detail">\${m.pages.filter(p=>p.mode==='static').length} static · \${m.pages.filter(p=>p.mode==='server').length} server</div>
|
|
1043
|
+
</div>
|
|
1044
|
+
<div class="stat-card fade-in">
|
|
1045
|
+
<div class="stat-label">Last Build</div>
|
|
1046
|
+
<div class="stat-value" style="font-size:16px">\${p.lastBuild ? new Date(p.lastBuild).toLocaleString() : '—'}</div>
|
|
1047
|
+
<div class="stat-detail">\${p.hasServerEntry ? 'server entry ready' : 'not built'}</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
<div class="stat-card fade-in">
|
|
1050
|
+
<div class="stat-label">Adapter</div>
|
|
1051
|
+
<div class="stat-value" style="font-size:16px">\${h(p.adapter || 'None')}</div>
|
|
1052
|
+
<div class="stat-detail">\${h(Object.entries(p.adapterOutput).filter(([,v])=>v).map(([k])=>k).join(', ') || 'no adapter output')}</div>
|
|
1053
|
+
</div>
|
|
1054
|
+
</div>
|
|
1055
|
+
|
|
1056
|
+
<div class="card fade-in">
|
|
1057
|
+
<div class="card-header">
|
|
1058
|
+
<h3>Build Output</h3>
|
|
1059
|
+
<span class="badge \${p.hasServerEntry ? 'badge-green' : 'badge-red'}"><span class="dot"></span> \${p.hasServerEntry ? 'Ready' : 'Not Built'}</span>
|
|
1060
|
+
</div>
|
|
1061
|
+
<div class="card-body">
|
|
1062
|
+
<table class="data-table">
|
|
1063
|
+
<tr><td class="mono">dist/server/entry.js</td><td>\${p.hasServerEntry ? '<span class="badge badge-green">exists</span>' : '<span class="badge badge-red">missing</span>'}</td></tr>
|
|
1064
|
+
<tr><td class="mono">dist/functions/</td><td>\${p.hasFunctions ? '<span class="badge badge-green">' + p.functionCount + ' bundles</span>' : '<span class="badge badge-amber">empty</span>'}</td></tr>
|
|
1065
|
+
<tr><td class="mono">dist/static/</td><td>\${p.hasStatic ? '<span class="badge badge-green">' + p.staticPageCount + ' pages</span>' : '<span class="badge badge-amber">none</span>'}</td></tr>
|
|
1066
|
+
\${p.taskEntryCount > 0 ? '<tr><td class="mono">dist/functions/task_*</td><td><span class="badge badge-purple">' + p.taskEntryCount + ' task entries</span></td></tr>' : ''}
|
|
1067
|
+
</table>
|
|
1068
|
+
</div>
|
|
1069
|
+
</div>
|
|
1070
|
+
|
|
1071
|
+
<div class="card fade-in">
|
|
1072
|
+
<div class="card-header">
|
|
1073
|
+
<h3>Quick Actions</h3>
|
|
1074
|
+
</div>
|
|
1075
|
+
<div class="card-body">
|
|
1076
|
+
<div class="btn-group">
|
|
1077
|
+
<button class="btn" onclick="navigator.clipboard.writeText('vura build').then(()=>showToast('Copied: vura build'))">Copy Build Command</button>
|
|
1078
|
+
<button class="btn" onclick="navigator.clipboard.writeText('vura dev').then(()=>showToast('Copied: vura dev'))">Copy Dev Command</button>
|
|
1079
|
+
<button class="btn" onclick="fetchAll().then(()=>showToast('Refreshed'))">Refresh Data</button>
|
|
1080
|
+
</div>
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
\`;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ─── Deployments ───
|
|
1087
|
+
function renderDeployments() {
|
|
1088
|
+
const deps = state.deployments || [];
|
|
1089
|
+
if (deps.length === 0) return '<div class="page-header"><h2>Deployments</h2><p>No deployment targets found. Run <code>vura build</code> first.</p></div>';
|
|
1090
|
+
|
|
1091
|
+
const iconMap = { 'hot-server': '●', 'static': '◆', 'serverless': 'λ', 'tasks': '⏳' };
|
|
1092
|
+
const colorMap = { 'hot-server': 'var(--accent)', 'static': 'var(--accent-blue)', 'serverless': 'var(--accent-amber)', 'tasks': 'var(--accent-purple)' };
|
|
1093
|
+
const bgMap = { 'hot-server': 'var(--accent-dim)', 'static': 'var(--accent-blue-dim)', 'serverless': 'var(--accent-amber-dim)', 'tasks': 'var(--accent-purple-dim)' };
|
|
1094
|
+
|
|
1095
|
+
return \`
|
|
1096
|
+
<div class="page-header">
|
|
1097
|
+
<h2>Deployments</h2>
|
|
1098
|
+
<p>Your application deployment targets and their status</p>
|
|
1099
|
+
</div>
|
|
1100
|
+
\${deps.map((d, i) => \`
|
|
1101
|
+
<div class="deploy-card fade-in" style="animation-delay: \${i * 60}ms">
|
|
1102
|
+
<div class="deploy-icon" style="background: \${bgMap[d.type]}; color: \${colorMap[d.type]}">\${iconMap[d.type] || '?'}</div>
|
|
1103
|
+
<div class="deploy-info">
|
|
1104
|
+
<h4>\${h(d.label)}</h4>
|
|
1105
|
+
<p>\${h(d.description)}</p>
|
|
1106
|
+
\${d.url ? '<div class="deploy-url">' + h(d.url) + '</div>' : ''}
|
|
1107
|
+
\${d.directory ? '<div class="deploy-url" style="background: var(--bg-elevated); color: var(--text-secondary)">' + h(d.directory) + '</div>' : ''}
|
|
1108
|
+
\${d.routes ? '<div class="deploy-routes">' + d.routes.map(r => '<span class="route-chip">' + r.methods.map(h).join(',') + ' ' + h(r.pattern) + '</span>').join('') + '</div>' : ''}
|
|
1109
|
+
\${d.pages ? '<div class="deploy-routes">' + d.pages.map(p => '<span class="route-chip">' + h(p) + '</span>').join('') + '</div>' : ''}
|
|
1110
|
+
\${d.functions ? '<div class="deploy-routes">' + d.functions.map(f => '<span class="route-chip">' + f.methods.map(h).join(',') + ' ' + h(f.pattern) + '</span>').join('') + '</div>' : ''}
|
|
1111
|
+
\${d.tasks ? '<div class="deploy-routes">' + d.tasks.map(t => '<span class="route-chip">' + h(t.pattern) + (t.schedule ? ' (' + h(t.schedule) + ')' : '') + '</span>').join('') + '</div>' : ''}
|
|
1112
|
+
</div>
|
|
1113
|
+
<span class="badge badge-green"><span class="dot"></span> \${h(d.status)}</span>
|
|
1114
|
+
</div>
|
|
1115
|
+
\`).join('')}
|
|
1116
|
+
\`;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ─── Routes ───
|
|
1120
|
+
function renderRoutes() {
|
|
1121
|
+
const m = state.manifest;
|
|
1122
|
+
if (!m) return '';
|
|
1123
|
+
|
|
1124
|
+
const kindBadge = (k) => ({ serverless: 'badge-amber', hot: 'badge-green', task: 'badge-purple' }[k] || 'badge-blue');
|
|
1125
|
+
const modeBadge = (mode) => ({ static: 'badge-blue', server: 'badge-green', client: 'badge-amber', hybrid: 'badge-cyan' }[mode] || 'badge-blue');
|
|
1126
|
+
|
|
1127
|
+
return \`
|
|
1128
|
+
<div class="page-header">
|
|
1129
|
+
<h2>Routes</h2>
|
|
1130
|
+
<p>All registered API routes and pages in your project</p>
|
|
1131
|
+
</div>
|
|
1132
|
+
|
|
1133
|
+
<div class="card fade-in">
|
|
1134
|
+
<div class="card-header">
|
|
1135
|
+
<h3>API Routes</h3>
|
|
1136
|
+
<span class="badge badge-blue">\${m.api.length} routes</span>
|
|
1137
|
+
</div>
|
|
1138
|
+
<table class="data-table">
|
|
1139
|
+
<thead><tr><th>Pattern</th><th>Methods</th><th>Kind</th><th>File</th></tr></thead>
|
|
1140
|
+
<tbody>
|
|
1141
|
+
\${m.api.map(r => \`<tr>
|
|
1142
|
+
<td class="mono">\${h(r.urlPattern)}</td>
|
|
1143
|
+
<td>\${r.methods.map(m => '<span class="badge badge-blue">' + h(m) + '</span> ').join('')}</td>
|
|
1144
|
+
<td><span class="badge \${kindBadge(r.kind)}">\${h(r.kind)}</span></td>
|
|
1145
|
+
<td class="mono" style="color: var(--text-tertiary); font-size: 11px">\${h(r.filePath)}</td>
|
|
1146
|
+
</tr>\`).join('')}
|
|
1147
|
+
</tbody>
|
|
1148
|
+
</table>
|
|
1149
|
+
</div>
|
|
1150
|
+
|
|
1151
|
+
<div class="card fade-in">
|
|
1152
|
+
<div class="card-header">
|
|
1153
|
+
<h3>Pages</h3>
|
|
1154
|
+
<span class="badge badge-blue">\${m.pages.length} pages</span>
|
|
1155
|
+
</div>
|
|
1156
|
+
<table class="data-table">
|
|
1157
|
+
<thead><tr><th>Pattern</th><th>Mode</th><th>SSR Data</th><th>File</th></tr></thead>
|
|
1158
|
+
<tbody>
|
|
1159
|
+
\${m.pages.map(p => \`<tr>
|
|
1160
|
+
<td class="mono">\${h(p.urlPattern)}</td>
|
|
1161
|
+
<td><span class="badge \${modeBadge(p.mode)}">\${h(p.mode)}</span></td>
|
|
1162
|
+
<td>\${p.hasGetServerData ? '<span class="badge badge-green">getServerData</span>' : '<span style="color: var(--text-tertiary)">—</span>'}</td>
|
|
1163
|
+
<td class="mono" style="color: var(--text-tertiary); font-size: 11px">\${h(p.filePath)}</td>
|
|
1164
|
+
</tr>\`).join('')}
|
|
1165
|
+
</tbody>
|
|
1166
|
+
</table>
|
|
1167
|
+
</div>
|
|
1168
|
+
\`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// ─── Environment ───
|
|
1172
|
+
function renderEnv() {
|
|
1173
|
+
const e = state.env || { env: [], envLocal: [], rawEnv: '', rawEnvLocal: '' };
|
|
1174
|
+
|
|
1175
|
+
const renderVars = (vars, file) => vars.map((v, i) => \`
|
|
1176
|
+
<div class="env-row">
|
|
1177
|
+
<input class="env-key" value="\${h(v.key)}" data-file="\${file}" data-index="\${i}" data-field="key" spellcheck="false">
|
|
1178
|
+
<input class="env-value" value="\${h(v.value)}" data-file="\${file}" data-index="\${i}" data-field="value" spellcheck="false" type="\${v.key.includes('SECRET') || v.key.includes('KEY') || v.key.includes('TOKEN') ? 'password' : 'text'}">
|
|
1179
|
+
<button class="env-btn" data-remove="\${file}:\${i}" title="Remove">✕</button>
|
|
1180
|
+
</div>
|
|
1181
|
+
\`).join('');
|
|
1182
|
+
|
|
1183
|
+
return \`
|
|
1184
|
+
<div class="page-header">
|
|
1185
|
+
<h2>Environment Variables</h2>
|
|
1186
|
+
<p>Manage environment variables for your project</p>
|
|
1187
|
+
</div>
|
|
1188
|
+
|
|
1189
|
+
<div class="card fade-in">
|
|
1190
|
+
<div class="card-header">
|
|
1191
|
+
<h3>.env</h3>
|
|
1192
|
+
<span class="badge badge-blue">\${e.env.length} variables</span>
|
|
1193
|
+
</div>
|
|
1194
|
+
<div class="card-body">
|
|
1195
|
+
<div class="env-editor" id="envEditor">
|
|
1196
|
+
\${renderVars(e.env, '.env')}
|
|
1197
|
+
</div>
|
|
1198
|
+
<div class="btn-group">
|
|
1199
|
+
<button class="btn" id="addEnvBtn">+ Add Variable</button>
|
|
1200
|
+
<button class="btn btn-primary" id="saveEnvBtn">Save .env</button>
|
|
1201
|
+
</div>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
|
|
1205
|
+
<div class="card fade-in">
|
|
1206
|
+
<div class="card-header">
|
|
1207
|
+
<h3>.env.local</h3>
|
|
1208
|
+
<span class="badge badge-amber">\${e.envLocal.length} variables</span>
|
|
1209
|
+
</div>
|
|
1210
|
+
<div class="card-body">
|
|
1211
|
+
<div class="env-editor" id="envLocalEditor">
|
|
1212
|
+
\${renderVars(e.envLocal, '.env.local')}
|
|
1213
|
+
</div>
|
|
1214
|
+
<div class="btn-group">
|
|
1215
|
+
<button class="btn" id="addEnvLocalBtn">+ Add Variable</button>
|
|
1216
|
+
<button class="btn btn-primary" id="saveEnvLocalBtn">Save .env.local</button>
|
|
1217
|
+
</div>
|
|
1218
|
+
</div>
|
|
1219
|
+
</div>
|
|
1220
|
+
\`;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function quoteEnvValue(value) {
|
|
1224
|
+
if (!/[\s#"'\\n\\r]/.test(value)) return value;
|
|
1225
|
+
return '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') + '"';
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function collectEnvVars(containerId) {
|
|
1229
|
+
const rows = document.querySelectorAll('#' + containerId + ' .env-row');
|
|
1230
|
+
const lines = [];
|
|
1231
|
+
rows.forEach(row => {
|
|
1232
|
+
const key = row.querySelector('.env-key').value.trim();
|
|
1233
|
+
const value = row.querySelector('.env-value').value;
|
|
1234
|
+
if (key) lines.push(key + '=' + quoteEnvValue(value));
|
|
1235
|
+
});
|
|
1236
|
+
return lines.join('\\n') + '\\n';
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function saveEnvFile(file, containerId) {
|
|
1240
|
+
const content = collectEnvVars(containerId);
|
|
1241
|
+
const response = await adminFetch('/env', {
|
|
1242
|
+
method: 'POST',
|
|
1243
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1244
|
+
body: JSON.stringify({ file, content }),
|
|
1245
|
+
});
|
|
1246
|
+
if (!response.ok) {
|
|
1247
|
+
const error = await response.json().catch(() => ({ error: 'Save failed' }));
|
|
1248
|
+
showToast(error.error || 'Save failed');
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
showToast('Saved ' + file);
|
|
1252
|
+
const envData = await adminFetch('/env').then(r => r.json());
|
|
1253
|
+
state.env = envData;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ─── Domains ───
|
|
1257
|
+
function renderDomains() {
|
|
1258
|
+
const p = state.project;
|
|
1259
|
+
const m = state.manifest;
|
|
1260
|
+
if (!p || !m) return '';
|
|
1261
|
+
|
|
1262
|
+
const hasHot = m.api.some(r => r.kind === 'hot') || m.pages.some(p => p.mode === 'server');
|
|
1263
|
+
const hasStatic = m.pages.some(p => p.mode === 'static');
|
|
1264
|
+
const hasServerless = m.api.some(r => r.kind === 'serverless');
|
|
1265
|
+
|
|
1266
|
+
return \`
|
|
1267
|
+
<div class="page-header">
|
|
1268
|
+
<h2>Domains</h2>
|
|
1269
|
+
<p>URLs and endpoints for your deployment targets</p>
|
|
1270
|
+
</div>
|
|
1271
|
+
|
|
1272
|
+
<div class="card fade-in">
|
|
1273
|
+
<div class="card-header">
|
|
1274
|
+
<h3>Endpoints</h3>
|
|
1275
|
+
</div>
|
|
1276
|
+
\${hasHot ? \`
|
|
1277
|
+
<div class="domain-card">
|
|
1278
|
+
<div class="domain-info">
|
|
1279
|
+
<span class="domain-type" style="color: var(--accent)">Backend</span>
|
|
1280
|
+
<span class="domain-url">http://localhost:3000</span>
|
|
1281
|
+
</div>
|
|
1282
|
+
<div class="domain-status"><span class="badge badge-green"><span class="dot pulse"></span> Hot Server</span></div>
|
|
1283
|
+
</div>
|
|
1284
|
+
\` : ''}
|
|
1285
|
+
\${hasStatic ? \`
|
|
1286
|
+
<div class="domain-card">
|
|
1287
|
+
<div class="domain-info">
|
|
1288
|
+
<span class="domain-type" style="color: var(--accent-blue)">Frontend</span>
|
|
1289
|
+
<span class="domain-url">dist/static/ → CDN</span>
|
|
1290
|
+
</div>
|
|
1291
|
+
<div class="domain-status"><span class="badge badge-blue"><span class="dot"></span> Static</span></div>
|
|
1292
|
+
</div>
|
|
1293
|
+
\` : ''}
|
|
1294
|
+
\${hasServerless ? \`
|
|
1295
|
+
<div class="domain-card">
|
|
1296
|
+
<div class="domain-info">
|
|
1297
|
+
<span class="domain-type" style="color: var(--accent-amber)">Functions</span>
|
|
1298
|
+
<span class="domain-url">\${p.adapter === 'cloudflare' ? '*.workers.dev' : p.adapter === 'adapter-lambda' ? '*.execute-api.*.amazonaws.com' : 'dist/functions/'}</span>
|
|
1299
|
+
</div>
|
|
1300
|
+
<div class="domain-status"><span class="badge badge-amber"><span class="dot"></span> Serverless</span></div>
|
|
1301
|
+
</div>
|
|
1302
|
+
\` : ''}
|
|
1303
|
+
\${m.api.filter(r => r.kind === 'task').length > 0 ? \`
|
|
1304
|
+
<div class="domain-card">
|
|
1305
|
+
<div class="domain-info">
|
|
1306
|
+
<span class="domain-type" style="color: var(--accent-purple)">Tasks</span>
|
|
1307
|
+
<span class="domain-url">/__tasks/* (internal)</span>
|
|
1308
|
+
</div>
|
|
1309
|
+
<div class="domain-status"><span class="badge badge-purple"><span class="dot"></span> Protected</span></div>
|
|
1310
|
+
</div>
|
|
1311
|
+
\` : ''}
|
|
1312
|
+
</div>
|
|
1313
|
+
|
|
1314
|
+
<div class="card fade-in">
|
|
1315
|
+
<div class="card-header">
|
|
1316
|
+
<h3>Server Endpoints</h3>
|
|
1317
|
+
</div>
|
|
1318
|
+
<table class="data-table">
|
|
1319
|
+
<thead><tr><th>Endpoint</th><th>Description</th><th>Status</th></tr></thead>
|
|
1320
|
+
<tbody>
|
|
1321
|
+
<tr>
|
|
1322
|
+
<td class="mono">/__health</td>
|
|
1323
|
+
<td style="color: var(--text-secondary)">Health check endpoint</td>
|
|
1324
|
+
<td><span class="badge badge-green">active</span></td>
|
|
1325
|
+
</tr>
|
|
1326
|
+
<tr>
|
|
1327
|
+
<td class="mono">/__tasks</td>
|
|
1328
|
+
<td style="color: var(--text-secondary)">Task management (auth required)</td>
|
|
1329
|
+
<td><span class="badge badge-purple">protected</span></td>
|
|
1330
|
+
</tr>
|
|
1331
|
+
\${m.pages.filter(p => p.mode === 'server' || p.mode === 'hybrid').map(p => \`
|
|
1332
|
+
<tr>
|
|
1333
|
+
<td class="mono">\${h(p.urlPattern)}</td>
|
|
1334
|
+
<td style="color: var(--text-secondary)">SSR page\${p.hasGetServerData ? ' (getServerData)' : ''}</td>
|
|
1335
|
+
<td><span class="badge badge-green">SSR</span></td>
|
|
1336
|
+
</tr>
|
|
1337
|
+
\`).join('')}
|
|
1338
|
+
</tbody>
|
|
1339
|
+
</table>
|
|
1340
|
+
</div>
|
|
1341
|
+
\`;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ─── Event Binding ───
|
|
1345
|
+
function bindPageEvents(page) {
|
|
1346
|
+
if (page === 'env') {
|
|
1347
|
+
document.getElementById('addEnvBtn')?.addEventListener('click', () => {
|
|
1348
|
+
const editor = document.getElementById('envEditor');
|
|
1349
|
+
editor.insertAdjacentHTML('beforeend', \`
|
|
1350
|
+
<div class="env-row">
|
|
1351
|
+
<input class="env-key" placeholder="KEY" spellcheck="false">
|
|
1352
|
+
<input class="env-value" placeholder="value" spellcheck="false">
|
|
1353
|
+
<button class="env-btn" onclick="this.parentElement.remove()" title="Remove">✕</button>
|
|
1354
|
+
</div>
|
|
1355
|
+
\`);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
document.getElementById('addEnvLocalBtn')?.addEventListener('click', () => {
|
|
1359
|
+
const editor = document.getElementById('envLocalEditor');
|
|
1360
|
+
editor.insertAdjacentHTML('beforeend', \`
|
|
1361
|
+
<div class="env-row">
|
|
1362
|
+
<input class="env-key" placeholder="KEY" spellcheck="false">
|
|
1363
|
+
<input class="env-value" placeholder="value" spellcheck="false">
|
|
1364
|
+
<button class="env-btn" onclick="this.parentElement.remove()" title="Remove">✕</button>
|
|
1365
|
+
</div>
|
|
1366
|
+
\`);
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
document.getElementById('saveEnvBtn')?.addEventListener('click', () => saveEnvFile('.env', 'envEditor'));
|
|
1370
|
+
document.getElementById('saveEnvLocalBtn')?.addEventListener('click', () => saveEnvFile('.env.local', 'envLocalEditor'));
|
|
1371
|
+
|
|
1372
|
+
document.querySelectorAll('.env-btn[data-remove]').forEach(btn => {
|
|
1373
|
+
btn.addEventListener('click', () => btn.parentElement.remove());
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ─── Init ───
|
|
1379
|
+
fetchAll();
|
|
1380
|
+
setInterval(fetchAll, 30000);
|
|
1381
|
+
</script>
|
|
1382
|
+
</body>
|
|
1383
|
+
</html>`.replace('__THEN_ADMIN_TOKEN__', adminToken);
|
|
1384
|
+
}
|
|
1385
|
+
//# sourceMappingURL=admin.js.map
|