initial commit

This commit is contained in:
Daniel Noulet 2026-10-04 16:20:07 +02:00
commit ebd5bb09fd
30 changed files with 1920 additions and 0 deletions

245
web/src/App.jsx Normal file
View file

@ -0,0 +1,245 @@
import React, { useState, useEffect } from 'react';
import { escapeHTML } from './App.utils';
const state = {
tenants: [],
applications: [],
databases: [],
selectedTenant: '',
isSystemAdministrator: false,
};
const $ = selector => document.querySelector(selector);
const $$ = selector => [...document.querySelectorAll(selector)];
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'': '''
}[c]));
const roleOptions = roles => roles
.map(item => `<option value="${escapeHTML(item.value)}">${escapeHTML(item.label)}</option>`)
.join('');
const $$Q = '[data-action]';
const notice = (message, error = false) => {
const el = $('#notice');
el.textContent = message;
el.hidden = false;
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
setTimeout(() => el.hidden = true, 5000);
};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
if (response.status === 401) {
location.href = '/login';
throw new Error('Session expired');
}
const body = response.status === 204 ? null : await response.json();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function load() {
const data = await api('/api/v1/dashboard');
state.tenants = data.tenants || [];
state.applications = data.applications || [];
state.databases = data.databases || [];
state.isSystemAdministrator = data.is_system_administrator || false;
state.selectedTenant = data.selected_tenant || state.tenants[0]?.id || '';
render();
updateTenantVisibility();
}
function updateTenantVisibility() {
$$Q('[data-tenant-action]').forEach(el => {
const action = el.dataset.tenantAction;
if (action === 'system-only' && !state.isSystemAdministrator) {
el.hidden = true;
} else {
el.hidden = false;
}
});
}
function render() {
const tenantID = state.selectedTenant;
const selectedTenant = state.tenants.find(t => t.id === tenantID);
const canManageDatabases = selectedTenant ? selectedTenant.permissions && selectedTenant.permissions.includes('databases.manage') : false;
const tenantApps = state.applications.filter(a => a.tenant_id === tenantID);
const tenantDBs = state.databases.filter(d => d.tenant_id === tenantID);
$('#tenant-count').textContent = state.tenants.length;
$('#app-count').textContent = tenantApps.length;
$('#db-count').textContent = tenantDBs.length;
$('#tenant-context').innerHTML = state.tenants.map(item => `
<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>
${escapeHTML(item.name)}
</option>
`).join('') || '<option>No tenants yet</option>';
$('#tenant-list').innerHTML = state.tenants.map(item => `
<article class="card">
<span class="badge">${escapeHTML(item.role)}</span>
<h3>${escapeHTML(item.name)}</h3>
<p>${escapeHTML(item.slug)}</p>
<div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div>
</article>
`).join('') || empty('No tenants yet');
$('#app-list').innerHTML = tenantApps.map(item => `
<article class="card">
<span class="badge">${escapeHTML(item.tenant_slug)}</span>
<h3>${escapeHTML(item.name)}</h3>
<p>${escapeHTML(item.description || 'No description')}</p>
<div class="meta">
<span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span>
<code>${escapeHTML(item.client_id)}</code>
</div>
${canManageDatabases ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Manage databases</button>` : ''}
</article>
`).join('') || empty('No applications yet');
$('#db-list').innerHTML = tenantDBs.map(item => `
<article class="card">
<h3>${escapeHTML(item.name)}</h3>
<p>${escapeHTML(item.connection_string ? 'Connected' : 'Not connected')}</p>
<div class="meta">
<span>Tenant ID: ${escapeHTML(item.tenant_id)}</span>
${item.migration_status ? `<span>Migrations: ${escapeHTML(item.migration_status)}</span>` : ''}
</div>
${canManageDatabases ? `<button class="card-action" data-db="${escapeHTML(item.id)}">Run migrations</button>` : ''}
</article>
`).join('') || empty('No databases yet');
}
const empty = text => `<article class="card"><p>${text}</p></article>`;
function formJSON(form) {
const data = Object.fromEntries(new FormData(form));
return data;
}
$$Q('[data-dialog]').forEach(button => button.addEventListener('click', () => $('#' + button.dataset.dialog).showModal()));
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
$$('.tab').forEach(button => button.addEventListener('click', () => {
$$('.tab,.panel').forEach(el => el.classList.remove('active'));
button.classList.add('active');
$('#' + button.dataset.panel).classList.add('active')
}));
$('#tenant-form').addEventListener('submit', async event => {
event.preventDefault();
try {
await api('/api/v1/tenants', { method: 'POST', body: JSON.stringify(formJSON(event.target)) });
event.target.closest('dialog').close();
event.target.reset();
notice('Tenant created');
await load();
} catch (error) {
notice(error.message, true);
}
});
$('#app-form').addEventListener('submit', async event => {
event.preventDefault();
try {
await api('/api/v1/applications', { method: 'POST', body: JSON.stringify(formJSON(event.target)) });
event.target.closest('dialog').close();
event.target.reset();
notice('Application registered');
await load();
} catch (error) {
notice(error.message, true);
}
});
$('#db-form').addEventListener('submit', async event => {
event.preventDefault();
try {
await api('/api/v1/databases', { method: 'POST', body: JSON.stringify(formJSON(event.target)) });
event.target.closest('dialog').close();
event.target.reset();
notice('Database connection added');
await load();
} catch (error) {
notice(error.message, true);
}
});
$$('[data-tenant-application-action]').forEach(element => element.hidden = !state.isSystemAdministrator);
$$('[data-tenant-db-action]').forEach(element => element.hidden = !state.isSystemAdministrator);
$$('.tenant-select').forEach(select => {
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
select.value = tenantID;
});
$$('.app-select').forEach(select => select.innerHTML = state.applications.filter(a => a.tenant_id === tenantID).map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('') || '<option>No applications</option>');
$$('[data-db-action]').forEach(element => {
const action = element.dataset.dbAction;
if (action === 'run-migration' && !state.isSystemAdministrator) {
element.hidden = true;
}
});
document.addEventListener('click', async event => {
const dbID = event.target.dataset.db;
if (dbID) {
if (confirm('Run migrations for this database?')) {
try {
await api(`/api/v1/databases/${dbID}/migrations`, { method: 'POST' });
notice('Migrations executed');
await load();
} catch (error) {
notice(error.message, true);
}
}
return;
}
const clientID = event.target.dataset.client;
if (clientID) {
const app = state.applications.find(item => item.id === clientID), form = $('#client-form');
form.application_id.value = app.id;
form.client_id.value = app.client_id;
form.redirect_uris.value = app.redirect_uris.join('\n');
$('#client-dialog').showModal();
return;
}
});
$('#copy-secret').addEventListener('click', () => {
const secret = $('#new-secret').textContent;
navigator.clipboard.writeText(secret).then(() => notice('Secret copied'));
});
const theme = $('#theme'), mode = $('#mode');
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
document.documentElement.dataset.theme = theme.value;
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
theme.addEventListener('change', () => {
document.documentElement.dataset.theme = theme.value;
localStorage.setItem('gl-theme', theme.value);
});
mode.addEventListener('click', () => {
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.mode = next;
localStorage.setItem('gl-mode', next);
});
$('#tenant-context').addEventListener('change', event => {
state.selectedTenant = event.target.value;
render();
updateTenantVisibility();
});
load().catch(error => notice(error.message, true));

1
web/src/App.utils.js Normal file
View file

@ -0,0 +1 @@
export { escapeHTML };

585
web/src/index.css Normal file
View file

@ -0,0 +1,585 @@
:root{
--bg:#0d0d17;
--surface:#1a1a2e;
--surface2:#16162e;
--text:#e0e0e0;
--muted:#8888a0;
--accent:#bb86fc;
--accent2:#03dac6;
--good:#4caf50;
--danger:#f44336;
--border:#3d3d5d;
--shadow:rgba(0,0,0,.4);
}
[data-mode=light]{--bg:#f5f5f5;--surface:#fff;--surface2:#f0f0f5;--text:#1a1a2e;--muted:#424266;--accent:#0d47a1;--accent2:#1565c0;--good:#2e7d32;--danger:#c62828;--border:#bdbdbd;--shadow:rgba(0,0,0,.15)}
[data-mode=dark]{--bg:#0d0d17;--surface:#1a1a2e;--surface2:#16162e;--text:#e0e0e0;--muted:#8888a0;--accent:#bb86fc;--accent2:#03dac6;--good:#4caf50;--danger:#f44336;--border:#3d3d5d;--shadow:rgba(0,0,0,.4)}
*{
box-sizing:border-box;
margin:0;
padding:0;
}
html{
background:var(--bg);
color:var(--text);
font-family:Inter,ui-sans-serif,system-ui,sans-serif;
}
body{
min-height:100vh;
background:
radial-gradient(circle at 70% -10%, color-mix(in srgb,var(--accent2) 12%,transparent) 25%, transparent 40%),
var(--bg);
}
a{
color:var(--accent);
text-decoration:none;
}
button,
input,
textarea,
select{
font:inherit;
}
.noise{
position:fixed;
inset:0;
pointer-events:none;
opacity:.03;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
header{
height:68px;
border-bottom:1px solid var(--border);
display:flex;
align-items:center;
justify-content:space-between;
padding:0 24px;
background:color-mix(in srgb,var(--bg) 88%,transparent);
backdrop-filter:blur(14px);
position:sticky;
top:0;
z-index:20;
}
.brand{
display:flex;
align-items:center;
gap:12px;
color:var(--text);
font-weight:750;
text-decoration:none;
letter-spacing:-.02em;
}
.brand-mark{
display:grid;
place-items:center;
width:32px;
height:32px;
border:1px solid var(--accent);
color:var(--accent);
font:700 11px ui-monospace,monospace;
clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%);
}
nav{
display:flex;
align-items:center;
gap:16px;
}
nav a{
color:var(--muted);
text-decoration:none;
font-size:14px;
}
nav form{
margin:0;
}
.compact{
display:flex;
align-items:center;
gap:6px;
font-size:11px;
color:var(--muted);
}
select,
input,
textarea{
color:var(--text);
background:var(--surface2);
border:1px solid var(--border);
border-radius:6px;
padding:8px 12px;
outline:none;
}
select:focus,
input:focus,
textarea:focus{
border-color:var(--accent);
box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent);
}
select:disabled,
input:disabled{
background:var(--border);
color:var(--muted);
}
.icon-button,
.ghost{
padding:6px 10px;
background:transparent;
border:1px solid var(--border);
color:var(--text);
border-radius:6px;
}
.identity{
font-size:12px;
color:var(--accent);
display:inline-flex;
align-items-center;
gap:6px;
}
main{
width:min(1400px,92vw);
margin:80px auto 60px;
}
.hero{
min-height:280px;
display:flex;
align-items:center;
justify-content:space-between;
gap:40px;
border-bottom:1px solid var(--border);
}
.eyebrow{
font:700 10px ui-monospace,monospace;
letter-spacing:.14em;
color:var(--accent);
margin:0 0 12px;
}
.hero h1{
font-size:clamp(36px,4vw,56px);
line-height:.96;
letter-spacing:-.04em;
margin:0;
}
.hero h1 span{
color:var(--accent2);
}
.lede{
max-width:560px;
color:color-mix(in srgb,var(--text) 65%,transparent);
font-size:15px;
line-height:1.6;
}
.status-card{
min-width:280px;
background:linear-gradient(145deg,var(--surface),var(--surface2));
border:1px solid var(--border);
border-radius:12px;
padding:18px;
box-shadow:0 16px 48px var(--shadow);
display:grid;
grid-template-columns:auto 1fr;
gap:10px;
align-items:center;
}
.status-card span{
display:block;
color:var(--good);
font-size:11px;
margin-top:3px;
}
.status-card code{
grid-column:1/-1;
background:var(--bg);
padding:10px;
border-radius:6px;
color:var(--muted);
}
.pulse{
width:8px;
height:8px;
border-radius:50%;
background:var(--good);
box-shadow:0 0 0 5px color-mix(in srgb,var(--good) 12%,transparent);
}
.stats{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(140px,1fr));
gap:2px;
background:var(--border);
border:1px solid var(--border);
margin:24px 0;
overflow:hidden;
border-radius:10px;
}
.stats article{
background:var(--surface);
padding:16px 20px;
}
.stats span{
display:block;
color:var(--muted);
font-size:11px;
text-transform:uppercase;
letter-spacing:.08em;
}
.stats strong{
display:block;
font-size:24px;
margin-top:4px;
}
.workspace{
display:grid;
grid-template-columns:180px 1fr;
gap:24px;
}
.workspace aside{
display:flex;
flex-direction:column;
gap:4px;
}
.tab{
border:0;
border-left:2px solid transparent;
background:transparent;
color:var(--muted);
padding:10px;
text-align:left;
font:600 11px ui-monospace,monospace;
}
.tab span{
font-family:Inter,system-ui;
margin-left:6px;
}
.tab.active{
color:var(--accent);
border-color:var(--accent);
background:color-mix(in srgb,var(--accent) 7%,transparent);
}
.panel{
display:none;
}
.panel.active{
display:block;
}
.panel-head{
display:flex;
align-items:flex-end;
justify-content:space-between;
margin-bottom:16px;
}
.panel h2{
font-size:28px;
letter-spacing:-.03em;
margin:0;
}
.panel-head button,
.card-action{
background:var(--accent);
color:var(--bg);
font-weight:700;
border:0;
padding:9px 14px;
border-radius:6px;
cursor:pointer;
}
.card-grid{
display:grid;
grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
gap:12px;
}
.card{
background:var(--surface);
border:1px solid var(--border);
border-radius:8px;
padding:16px;
}
.card h3{
margin:0 0 4px;
}
.card p{
color:color-mix(in srgb,var(--text) 55%,transparent);
min-height:20px;
}
.card-action{
width:100%;
margin-top:12px;
background:color-mix(in srgb,var(--accent) 12%,var(--surface));
color:var(--accent);
border:1px solid color-mix(in srgb,var(--accent) 40%,var(--border));
}
.badge{
font:700 9px ui-monospace,monospace;
text-transform:uppercase;
color:var(--accent);
background:color-mix(in srgb,var(--accent) 8%,transparent);
padding:4px 6px;
border-radius:4px;
}
.meta{
display:flex;
justify-content:space-between;
margin-top:12px;
color:var(--muted);
font-size:10px;
}
.table-wr{
width:100%;
border-collapse:collapse;
}
.table-wr th,
.table-wr td{
padding:8px 4px;
border-bottom:1px solid var(--border);
font-size:11px;
}
.table-wr th{
color:var(--muted);
text-align:left;
text-transform:uppercase;
letter-spacing:.06em;
}
.empty{
padding:20px;
color:var(--muted);
}
.dialog{
color:var(--text);
background:var(--surface);
border:1px solid var(--border);
border-radius:10px;
box-shadow:0 24px 80px rgba(0,0,0,.55);
width:min(480px,92vw);
padding:20px;
}
dialog::backdrop{
background:rgba(8,8,15,.7);
backdrop-filter:blur(3px);
}
dialog form{
display:grid;
gap:12px;
}
dialog form .dialog-head{
display:flex;
align-items:center;
justify-content:space-between;
}
dialog form .dialog-head h3{
margin:0;
font-size:20px;
}
.close{
border:0;
background:transparent;
color:var(--muted);
font-size:24px;
cursor:pointer;
}
dialog label{
display:grid;
gap:5px;
font-size:12px;
color:var(--muted);
}
textarea{
resize:vertical;
}
.client-terminal{
width:min(920px,92vw);
margin:24px 0;
position:relative;
background:color-mix(in srgb,var(--surface) 93%,black);
border:1px solid var(--border);
border-radius:8px;
box-shadow:0 32px 100px rgba(0,0,0,.55);
overflow:hidden;
font-family:ui-monospace,SFMono-Regular,monospace;
}
.client-terminal .terminal-bar{
height:36px;
background:var(--surface2);
display:flex;
align-items:center;
gap:6px;
padding:0 12px;
}
.client-terminal .terminal-bar span{
width:9px;
height:9px;
border-radius:50%;
background:var(--danger);
}
.client-terminal .terminal-bar span:nth-child(2){
background:#e0af68;
}
.client-terminal .terminal-bar span:nth-child(3){
background:var(--good);
}
.client-terminal .terminal-bar strong{
position:absolute;
left:50%;
transform:translateX(-50%);
color:var(--muted);
font-size:11px;
}
.client-terminal .terminal-body{
padding:24px;
}
.client-terminal .ascii{
color:var(--accent);
font-size:clamp(5px,.8vw,10px);
line-height:1.2;
overflow:hidden;
}
.client-terminal .prompt{
color:var(--good);
}
.client-terminal .muted{
color:var(--muted);
max-width:550px;
line-height:1.4;
}
.login-page{
display:grid;
place-items:center;
min-height:100vh;
overflow:hidden;
}
.login-glow{
position:fixed;
width:500px;
height:500px;
border-radius:50%;
background:var(--accent2);
filter:blur(160px);
opacity:.12;
}
.terminal{
width:min(920px,92vw);
margin:24px;
position:relative;
background:color-mix(in srgb,var(--surface) 93%,black);
border:1px solid var(--border);
border-radius:8px;
box-shadow:0 32px 100px rgba(0,0,0,.55);
overflow:hidden;
font-family:ui-monospace,SFMono-Regular,monospace;
}
.terminal .terminal-bar{
height:36px;
background:var(--surface2);
display:flex;
align-items:center;
gap:6px;
padding:0 12px;
}
.terminal .terminal-bar span{
width:9px;
height:9px;
border-radius:50%;
background:var(--danger);
}
.terminal .terminal-bar span:nth-child(2){
background:#e0af68;
}
.terminal .terminal-bar span:nth-child(3){
background:var(--good);
}
.terminal .terminal-bar strong{
position:absolute;
left:50%;
transform:translateX(-50%);
color:var(--muted);
font-size:11px;
}
.terminal .terminal-body{
padding:24px;
}
.terminal .ascii{
color:var(--accent);
font-size:clamp(5px, .8vw, 10px);
line-height:1.2;
overflow:hidden;
}
.terminal .prompt{
color:var(--good);
}
.terminal .muted{
color:var(--muted);
max-width:600px;
line-height:1.4;
}
.password-login{
display:grid;
grid-template-columns:1fr 1fr auto;
gap:8px;
align-items:end;
margin:20px 0;
}
.password-login label{
display:grid;
gap:4px;
color:var(--muted);
font-size:11px;
}
.password-login button{
height:36px;
border:0;
border-radius:6px;
background:var(--accent);
color:var(--bg);
font-weight:700;
padding:0 16px;
}
.login-divider{
color:var(--muted);
font-size:10px;
text-transform:uppercase;
letter-spacing:.1em;
}
.installer-form{
display:grid;
gap:12px;
margin-top:20px;
}
.installer-form label{
display:grid;
gap:4px;
color:var(--muted);
font-size:11px;
}
.installer-form button{
height:40px;
border:0;
border-radius:6px;
background:var(--accent);
color:var(--bg);
font-weight:700;
}
.installer-form .seed-option{
display:flex;
gap:6px;
align-items:center;
}
.installer h1{
font-size:clamp(32px,5vw,52px);
line-height:1;
margin:8px 0;
letter-spacing:-.04em;
}
.installer h1 span{
color:var(--accent2);
}
@media(max-width:800px){
header{padding:0 16px}
nav .compact,.identity{display:none}
.hero{padding:44px 0;display:block}
.status-card{margin-top:30px;min-width:0}
.stats{grid-template-columns:1fr 1fr}
.workspace{grid-template-columns:1fr}
.workspace aside{flex-direction:row;overflow:auto}
.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}
.card-grid{grid-template-columns:1fr}
.ascii{font-size:5px}
.terminal-body{padding:18px}
.password-login{grid-template-columns:1fr}
.password-login button{width:100%}
}

13
web/src/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>go-mad - Tenant Database Manager</title>
<link rel="stylesheet" href="/static/index.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/static/index.jsx"></script>
</body>
</html>

9
web/src/main.jsx Normal file
View file

@ -0,0 +1,9 @@
import './index.css';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);