triflix commited on
Commit
cfcea40
Β·
verified Β·
1 Parent(s): 5d1a8d2

Upload 21 files

Browse files
Dockerfile CHANGED
@@ -1,7 +1,7 @@
1
  FROM python:3.11-slim
2
 
3
  # ── Install Litestream ──
4
- ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.13/litestream-v0.3.13-linux-amd64.tar.gz /tmp/litestream.tar.gz
5
  RUN tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz && rm /tmp/litestream.tar.gz
6
 
7
  # ── Install SQLite3 CLI (for integrity checks + manual backup) ──
 
1
  FROM python:3.11-slim
2
 
3
  # ── Install Litestream ──
4
+ ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.13/litestream-v0.3.13-linux-amd64.tar.gz /tmp/litestream.tar.gz
5
  RUN tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz && rm /tmp/litestream.tar.gz
6
 
7
  # ── Install SQLite3 CLI (for integrity checks + manual backup) ──
app/__init__.py CHANGED
@@ -1 +1 @@
1
- # SQLite DBaaS - App Package
 
1
+ # SQLite DBaaS Application Package
app/admin/router.py CHANGED
@@ -13,8 +13,8 @@ Features:
13
  - Integrity checks
14
 
15
  Source inspiration:
16
- - sqlite-web: https://github.com/coleifer/sqlite-web
17
- - Flask-Admin: https://github.com/flask-admin/flask-admin
18
  """
19
 
20
  from fastapi import APIRouter, Request, Depends, HTTPException, Form
 
13
  - Integrity checks
14
 
15
  Source inspiration:
16
+ - sqlite-web: https://github.com/coleifer/sqlite-web
17
+ - Flask-Admin: https://github.com/flask-admin/flask-admin
18
  """
19
 
20
  from fastapi import APIRouter, Request, Depends, HTTPException, Form
app/admin/templates/backups.html CHANGED
@@ -1,82 +1,60 @@
1
  {% extends "base.html" %}
2
-
3
  {% block title %}Backups - SQLite Admin{% endblock %}
4
-
5
  {% block content %}
6
- <div class="page-header">
7
- <h1><i class="fas fa-hdd"></i> Backup Management</h1>
8
- </div>
9
-
10
- <!-- Health Status -->
11
- <div class="card">
12
- <div class="card-header">
13
- <h3><i class="fas fa-heartbeat"></i> Database Health</h3>
14
  </div>
15
- <div class="card-body">
 
 
 
16
  <div class="health-grid">
17
  <div class="health-item">
18
- <span class="health-label">Integrity</span>
19
- <span class="health-value {% if health.integrity == 'ok' %}status-success{% else %}status-error{% endif %}">
20
- <i class="fas fa-{% if health.integrity == 'ok' %}check-circle{% else %}times-circle{% endif %}"></i>
21
  {{ health.integrity }}
22
  </span>
23
  </div>
24
  <div class="health-item">
25
- <span class="health-label">Size</span>
26
- <span class="health-value">{{ health.db_size_mb }} MB</span>
27
- </div>
28
- <div class="health-item">
29
- <span class="health-label">Pages</span>
30
- <span class="health-value">{{ health.page_count }}</span>
31
  </div>
32
  </div>
33
  </div>
34
- </div>
35
-
36
- <!-- Backup Actions -->
37
- <div class="card">
38
- <div class="card-header">
39
- <h3><i class="fas fa-cogs"></i> Create Backup</h3>
40
- </div>
41
- <div class="card-body">
42
  <div class="backup-actions">
43
- <form method="post" action="/admin/backups/create" class="inline-form">
44
  <input type="hidden" name="backup_type" value="snapshot">
45
- <button type="submit" class="btn btn-primary">
46
- <i class="fas fa-camera"></i> Snapshot
47
- </button>
48
  </form>
49
-
50
- <form method="post" action="/admin/backups/create" class="inline-form">
51
  <input type="hidden" name="backup_type" value="compressed">
52
- <button type="submit" class="btn btn-primary">
53
- <i class="fas fa-compress"></i> Compressed
54
- </button>
55
  </form>
56
-
57
- <form method="post" action="/admin/backups/create" class="inline-form">
58
  <input type="hidden" name="backup_type" value="vacuum">
59
- <button type="submit" class="btn btn-primary">
60
- <i class="fas fa-broom"></i> VACUUM
61
- </button>
62
  </form>
63
-
64
- <form method="post" action="/admin/backups/create" class="inline-form">
65
  <input type="hidden" name="backup_type" value="hf_upload">
66
- <button type="submit" class="btn btn-success">
67
- <i class="fas fa-cloud-upload-alt"></i> Upload to HF
68
- </button>
69
  </form>
70
  </div>
71
  </div>
72
- </div>
73
-
74
- <!-- Local Snapshots -->
75
- <div class="card">
76
- <div class="card-header">
77
- <h3><i class="fas fa-folder-open"></i> Local Snapshots</h3>
78
- </div>
79
- <div class="card-body">
80
  {% if backups.local_snapshots %}
81
  <table class="data-table">
82
  <thead>
@@ -90,42 +68,36 @@
90
  <tbody>
91
  {% for snapshot in backups.local_snapshots %}
92
  <tr>
93
- <td><i class="fas fa-file-archive text-primary"></i> {{ snapshot.name }}</td>
94
  <td>{{ snapshot.size_mb }} MB</td>
95
  <td>{{ snapshot.modified }}</td>
96
  <td>
97
- <a href="/admin/backups/download/{{ snapshot.name }}" class="btn btn-sm btn-primary">
98
- <i class="fas fa-download"></i> Download
99
- </a>
100
  </td>
101
  </tr>
102
  {% endfor %}
103
  </tbody>
104
  </table>
105
  {% else %}
106
- <p class="text-muted">No local snapshots yet. Create one using the buttons above.</p>
107
  {% endif %}
108
  </div>
109
- </div>
110
-
111
- <!-- Backup Info -->
112
- <div class="card">
113
- <div class="card-header">
114
- <h3><i class="fas fa-info-circle"></i> Backup Strategy</h3>
115
- </div>
116
- <div class="card-body">
117
- <div class="backup-strategy">
118
  <div class="strategy-layer">
119
- <h4><span class="layer-badge">1</span> Litestream (Automatic)</h4>
120
- <p>Replicates every write to local file replica. Max 1 second data loss. Local only.</p>
121
  </div>
122
  <div class="strategy-layer">
123
- <h4><span class="layer-badge">2</span> Python Snapshots (30 min)</h4>
124
- <p>Atomic backups using sqlite3.backup(). Compressed with gzip (60-80% smaller).</p>
125
  </div>
126
  <div class="strategy-layer">
127
- <h4><span class="layer-badge">3</span> HF Bucket (30 min)</h4>
128
- <p>Uploads to HuggingFace Dataset repo. Survives restarts. 100GB free storage.</p>
129
  </div>
130
  </div>
131
  </div>
 
1
  {% extends "base.html" %}
 
2
  {% block title %}Backups - SQLite Admin{% endblock %}
 
3
  {% block content %}
4
+ <div class="backups-page">
5
+ <div class="page-header">
6
+ <h1>πŸ’Ύ Backup Management</h1>
7
+ <a href="/admin/dashboard" class="btn-secondary">← Back to Dashboard</a>
 
 
 
 
8
  </div>
9
+
10
+ <!-- Health Status -->
11
+ <div class="card">
12
+ <h2>Database Status</h2>
13
  <div class="health-grid">
14
  <div class="health-item">
15
+ <span class="label">Integrity</span>
16
+ <span class="value {% if health.integrity == 'ok' %}success{% else %}error{% endif %}">
 
17
  {{ health.integrity }}
18
  </span>
19
  </div>
20
  <div class="health-item">
21
+ <span class="label">Size</span>
22
+ <span class="value">{{ health.db_size_mb }} MB</span>
 
 
 
 
23
  </div>
24
  </div>
25
  </div>
26
+
27
+ <!-- Create Backup -->
28
+ <div class="card">
29
+ <h2>Create Backup</h2>
30
+ <p class="info-text">
31
+ Create a backup of your database. Backups are stored locally and can be uploaded to HF Bucket for persistence.
32
+ </p>
 
33
  <div class="backup-actions">
34
+ <form method="POST" action="/admin/backups/create" style="display:inline;">
35
  <input type="hidden" name="backup_type" value="snapshot">
36
+ <button type="submit" class="btn-primary">πŸ“Έ Create Snapshot</button>
 
 
37
  </form>
38
+ <form method="POST" action="/admin/backups/create" style="display:inline;">
 
39
  <input type="hidden" name="backup_type" value="compressed">
40
+ <button type="submit" class="btn-secondary">πŸ—œοΈ Compressed Snapshot</button>
 
 
41
  </form>
42
+ <form method="POST" action="/admin/backups/create" style="display:inline;">
 
43
  <input type="hidden" name="backup_type" value="vacuum">
44
+ <button type="submit" class="btn-secondary">🧹 VACUUM Snapshot</button>
 
 
45
  </form>
46
+ <form method="POST" action="/admin/backups/create" style="display:inline;">
 
47
  <input type="hidden" name="backup_type" value="hf_upload">
48
+ <button type="submit" class="btn-primary">☁️ Upload to HF Bucket</button>
 
 
49
  </form>
50
  </div>
51
  </div>
52
+
53
+ <!-- Local Snapshots -->
54
+ <div class="card">
55
+ <h2>πŸ“ Local Snapshots</h2>
56
+ <p class="info-text">Directory: <code>{{ backups.snapshot_dir }}</code></p>
57
+
 
 
58
  {% if backups.local_snapshots %}
59
  <table class="data-table">
60
  <thead>
 
68
  <tbody>
69
  {% for snapshot in backups.local_snapshots %}
70
  <tr>
71
+ <td class="code">{{ snapshot.name }}</td>
72
  <td>{{ snapshot.size_mb }} MB</td>
73
  <td>{{ snapshot.modified }}</td>
74
  <td>
75
+ <a href="/admin/backups/download/{{ snapshot.name }}" class="btn-small">⬇️ Download</a>
 
 
76
  </td>
77
  </tr>
78
  {% endfor %}
79
  </tbody>
80
  </table>
81
  {% else %}
82
+ <p class="empty-state">No local snapshots yet. Create one above!</p>
83
  {% endif %}
84
  </div>
85
+
86
+ <!-- Backup Strategy Info -->
87
+ <div class="card">
88
+ <h2>πŸ“š 3-Layer Backup Strategy</h2>
89
+ <div class="strategy-info">
 
 
 
 
90
  <div class="strategy-layer">
91
+ <h3>Layer 1: Litestream (Automatic)</h3>
92
+ <p>Real-time replication to local file. Catches every write with max 1 second data loss.</p>
93
  </div>
94
  <div class="strategy-layer">
95
+ <h3>Layer 2: Python sqlite3.backup()</h3>
96
+ <p>On-demand atomic snapshots. Consistent backups while DB is in use. Compressed with gzip.</p>
97
  </div>
98
  <div class="strategy-layer">
99
+ <h3>Layer 3: HF Bucket Upload</h3>
100
+ <p>Persistent storage on HuggingFace. 100GB free private storage. Survives container restarts.</p>
101
  </div>
102
  </div>
103
  </div>
app/admin/templates/base.html CHANGED
@@ -5,33 +5,25 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>{% block title %}SQLite Admin{% endblock %}</title>
7
  <link rel="stylesheet" href="/static/admin.css">
8
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
  </head>
10
  <body>
11
- <div class="admin-container">
12
- {% if request.cookies.get('admin_token') %}
13
- <nav class="sidebar">
14
- <div class="sidebar-header">
15
- <i class="fas fa-database"></i>
16
- <span>SQLite Admin</span>
17
- </div>
18
- <ul class="nav-menu">
19
- <li><a href="/admin/dashboard" class="{% if request.url.path == '/admin/dashboard' %}active{% endif %}">
20
- <i class="fas fa-tachometer-alt"></i> Dashboard
21
- </a></li>
22
- <li><a href="/admin/query" class="{% if request.url.path == '/admin/query' %}active{% endif %}">
23
- <i class="fas fa-code"></i> SQL Query
24
- </a></li>
25
- <li><a href="/admin/backups" class="{% if request.url.path == '/admin/backups' %}active{% endif %}">
26
- <i class="fas fa-hdd"></i> Backups
27
- </a></li>
28
- </ul>
29
- </nav>
30
- {% endif %}
31
-
32
- <main class="main-content">
33
- {% block content %}{% endblock %}
34
- </main>
35
- </div>
36
  </body>
37
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>{% block title %}SQLite Admin{% endblock %}</title>
7
  <link rel="stylesheet" href="/static/admin.css">
 
8
  </head>
9
  <body>
10
+ <nav class="navbar">
11
+ <div class="nav-brand">
12
+ <a href="/admin/dashboard">πŸ—„οΈ SQLite DBaaS</a>
13
+ </div>
14
+ <div class="nav-links">
15
+ <a href="/admin/dashboard">Dashboard</a>
16
+ <a href="/admin/query">Query</a>
17
+ <a href="/admin/backups">Backups</a>
18
+ </div>
19
+ </nav>
20
+
21
+ <main class="container">
22
+ {% block content %}{% endblock %}
23
+ </main>
24
+
25
+ <footer class="footer">
26
+ <p>SQLite DBaaS β€” Powered by FastAPI + Litestream + HF Bucket</p>
27
+ </footer>
 
 
 
 
 
 
 
28
  </body>
29
  </html>
app/admin/templates/dashboard.html CHANGED
@@ -1,56 +1,46 @@
1
  {% extends "base.html" %}
2
-
3
  {% block title %}Dashboard - SQLite Admin{% endblock %}
4
-
5
  {% block content %}
6
- <div class="page-header">
7
- <h1><i class="fas fa-tachometer-alt"></i> Dashboard</h1>
8
- </div>
9
-
10
- <!-- Health Status -->
11
- <div class="card">
12
- <div class="card-header">
13
- <h3><i class="fas fa-heartbeat"></i> Database Health</h3>
14
- </div>
15
- <div class="card-body">
16
  <div class="health-grid">
17
  <div class="health-item">
18
- <span class="health-label">Integrity</span>
19
- <span class="health-value {% if health.integrity == 'ok' %}status-success{% else %}status-error{% endif %}">
20
- <i class="fas fa-{% if health.integrity == 'ok' %}check-circle{% else %}times-circle{% endif %}"></i>
21
  {{ health.integrity }}
22
  </span>
23
  </div>
24
  <div class="health-item">
25
- <span class="health-label">Size</span>
26
- <span class="health-value">{{ health.db_size_mb }} MB</span>
27
  </div>
28
  <div class="health-item">
29
- <span class="health-label">Pages</span>
30
- <span class="health-value">{{ health.page_count }}</span>
31
  </div>
32
  <div class="health-item">
33
- <span class="health-label">Journal Mode</span>
34
- <span class="health-value">{{ health.journal_mode }}</span>
35
  </div>
36
  <div class="health-item">
37
- <span class="health-label">Page Size</span>
38
- <span class="health-value">{{ health.page_size }} bytes</span>
39
  </div>
40
  <div class="health-item">
41
- <span class="health-label">WAL Pages</span>
42
- <span class="health-value">{{ health.wal_pages }}</span>
43
  </div>
44
  </div>
45
  </div>
46
- </div>
47
-
48
- <!-- Tables -->
49
- <div class="card">
50
- <div class="card-header">
51
- <h3><i class="fas fa-table"></i> Tables</h3>
52
- </div>
53
- <div class="card-body">
54
  <table class="data-table">
55
  <thead>
56
  <tr>
@@ -62,26 +52,23 @@
62
  <tbody>
63
  {% for table in tables %}
64
  <tr>
65
- <td><i class="fas fa-table text-primary"></i> {{ table.name }}</td>
66
  <td>{{ table.row_count }}</td>
67
  <td>
68
- <a href="/admin/table/{{ table.name }}" class="btn btn-sm btn-primary">
69
- <i class="fas fa-eye"></i> View
70
- </a>
71
  </td>
72
  </tr>
73
  {% endfor %}
74
  </tbody>
75
  </table>
 
 
 
76
  </div>
77
- </div>
78
-
79
- <!-- Recent Backups -->
80
- <div class="card">
81
- <div class="card-header">
82
- <h3><i class="fas fa-hdd"></i> Recent Backups</h3>
83
- </div>
84
- <div class="card-body">
85
  {% if backups %}
86
  <table class="data-table">
87
  <thead>
@@ -97,21 +84,20 @@
97
  {% for backup in backups %}
98
  <tr>
99
  <td>{{ backup.backup_type }}</td>
100
- <td class="truncate">{{ backup.file_path }}</td>
101
- <td>{{ (backup.file_size / 1024 / 1024) | round(2) }} MB</td>
102
- <td>
103
- <span class="badge {% if backup.status == 'success' %}badge-success{% else %}badge-error{% endif %}">
104
- {{ backup.status }}
105
- </span>
106
- </td>
107
  <td>{{ backup.created_at }}</td>
108
  </tr>
109
  {% endfor %}
110
  </tbody>
111
  </table>
112
  {% else %}
113
- <p class="text-muted">No backups yet. <a href="/admin/backups">Create one</a>.</p>
114
  {% endif %}
 
 
 
115
  </div>
116
  </div>
117
  {% endblock %}
 
1
  {% extends "base.html" %}
 
2
  {% block title %}Dashboard - SQLite Admin{% endblock %}
 
3
  {% block content %}
4
+ <div class="dashboard">
5
+ <h1>πŸ“Š Dashboard</h1>
6
+
7
+ <!-- Health Status -->
8
+ <div class="card">
9
+ <h2>Database Health</h2>
 
 
 
 
10
  <div class="health-grid">
11
  <div class="health-item">
12
+ <span class="label">Integrity</span>
13
+ <span class="value {% if health.integrity == 'ok' %}success{% else %}error{% endif %}">
 
14
  {{ health.integrity }}
15
  </span>
16
  </div>
17
  <div class="health-item">
18
+ <span class="label">Journal Mode</span>
19
+ <span class="value">{{ health.journal_mode }}</span>
20
  </div>
21
  <div class="health-item">
22
+ <span class="label">Size</span>
23
+ <span class="value">{{ health.db_size_mb }} MB</span>
24
  </div>
25
  <div class="health-item">
26
+ <span class="label">Pages</span>
27
+ <span class="value">{{ health.page_count }}</span>
28
  </div>
29
  <div class="health-item">
30
+ <span class="label">WAL Pages</span>
31
+ <span class="value">{{ health.wal_pages }}</span>
32
  </div>
33
  <div class="health-item">
34
+ <span class="label">DB Path</span>
35
+ <span class="value code">{{ db_path }}</span>
36
  </div>
37
  </div>
38
  </div>
39
+
40
+ <!-- Tables -->
41
+ <div class="card">
42
+ <h2>πŸ“‹ Tables</h2>
43
+ {% if tables %}
 
 
 
44
  <table class="data-table">
45
  <thead>
46
  <tr>
 
52
  <tbody>
53
  {% for table in tables %}
54
  <tr>
55
+ <td class="code">{{ table.name }}</td>
56
  <td>{{ table.row_count }}</td>
57
  <td>
58
+ <a href="/admin/table/{{ table.name }}" class="btn-small">View</a>
 
 
59
  </td>
60
  </tr>
61
  {% endfor %}
62
  </tbody>
63
  </table>
64
+ {% else %}
65
+ <p class="empty-state">No tables found</p>
66
+ {% endif %}
67
  </div>
68
+
69
+ <!-- Recent Backups -->
70
+ <div class="card">
71
+ <h2>πŸ’Ύ Recent Backups</h2>
 
 
 
 
72
  {% if backups %}
73
  <table class="data-table">
74
  <thead>
 
84
  {% for backup in backups %}
85
  <tr>
86
  <td>{{ backup.backup_type }}</td>
87
+ <td class="code">{{ backup.file_path|truncate(40) if backup.file_path else '-' }}</td>
88
+ <td>{{ backup.file_size|default(0) }} bytes</td>
89
+ <td class="status-{{ backup.status }}">{{ backup.status }}</td>
 
 
 
 
90
  <td>{{ backup.created_at }}</td>
91
  </tr>
92
  {% endfor %}
93
  </tbody>
94
  </table>
95
  {% else %}
96
+ <p class="empty-state">No backups yet</p>
97
  {% endif %}
98
+ <div class="card-actions">
99
+ <a href="/admin/backups" class="btn-secondary">Manage Backups</a>
100
+ </div>
101
  </div>
102
  </div>
103
  {% endblock %}
app/admin/templates/login.html CHANGED
@@ -1,29 +1,24 @@
1
  {% extends "base.html" %}
2
-
3
  {% block title %}Login - SQLite Admin{% endblock %}
4
-
5
  {% block content %}
6
  <div class="login-container">
7
- <div class="login-box">
8
- <div class="login-header">
9
- <i class="fas fa-lock"></i>
10
- <h2>Admin Login</h2>
11
- </div>
12
 
13
  {% if error %}
14
- <div class="alert alert-error">
15
- <i class="fas fa-exclamation-circle"></i> {{ error }}
16
  </div>
17
  {% endif %}
18
 
19
- <form method="post" action="/admin/login">
20
  <div class="form-group">
21
  <label for="password">Password</label>
22
- <input type="password" id="password" name="password" required autofocus>
 
23
  </div>
24
- <button type="submit" class="btn btn-primary btn-block">
25
- <i class="fas fa-sign-in-alt"></i> Login
26
- </button>
27
  </form>
28
  </div>
29
  </div>
 
1
  {% extends "base.html" %}
 
2
  {% block title %}Login - SQLite Admin{% endblock %}
 
3
  {% block content %}
4
  <div class="login-container">
5
+ <div class="login-card">
6
+ <h1>πŸ” Admin Login</h1>
7
+ <p class="login-subtitle">SQLite DBaaS Admin Console</p>
 
 
8
 
9
  {% if error %}
10
+ <div class="error-message">
11
+ {{ error }}
12
  </div>
13
  {% endif %}
14
 
15
+ <form method="POST" action="/admin/login">
16
  <div class="form-group">
17
  <label for="password">Password</label>
18
+ <input type="password" id="password" name="password"
19
+ placeholder="Enter admin password" required autofocus>
20
  </div>
21
+ <button type="submit" class="btn-primary">Login</button>
 
 
22
  </form>
23
  </div>
24
  </div>
app/admin/templates/query.html CHANGED
@@ -1,41 +1,35 @@
1
  {% extends "base.html" %}
2
-
3
- {% block title %}SQL Query - SQLite Admin{% endblock %}
4
-
5
  {% block content %}
6
- <div class="page-header">
7
- <h1><i class="fas fa-code"></i> SQL Query</h1>
8
- </div>
9
-
10
- <div class="card">
11
- <div class="card-header">
12
- <h3><i class="fas fa-terminal"></i> Query Editor</h3>
13
  </div>
14
- <div class="card-body">
15
- <form method="post" action="/admin/query">
 
16
  <div class="form-group">
17
- <textarea name="sql" class="sql-editor" rows="8" placeholder="Enter SQL query...">{{ sql }}</textarea>
 
 
18
  </div>
19
- <button type="submit" class="btn btn-primary">
20
- <i class="fas fa-play"></i> Run Query
21
- </button>
22
  </form>
23
  </div>
24
- </div>
25
-
26
- {% if error %}
27
- <div class="alert alert-error">
28
- <i class="fas fa-exclamation-circle"></i> {{ error }}
29
- </div>
30
- {% endif %}
31
-
32
- {% if results %}
33
- <div class="card">
34
- <div class="card-header">
35
- <h3><i class="fas fa-list"></i> Results ({{ results | length }} rows)</h3>
36
  </div>
37
- <div class="card-body">
38
- <div class="table-responsive">
 
 
 
 
 
39
  <table class="data-table">
40
  <thead>
41
  <tr>
@@ -48,21 +42,40 @@
48
  {% for row in results %}
49
  <tr>
50
  {% for col in columns %}
51
- <td>
52
- {% set value = row[col] %}
53
- {% if value is none %}
54
- <span class="text-muted">NULL</span>
55
- {% else %}
56
- {{ value }}
57
- {% endif %}
58
- </td>
59
  {% endfor %}
60
  </tr>
61
  {% endfor %}
62
  </tbody>
63
  </table>
64
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  </div>
66
  </div>
67
- {% endif %}
68
  {% endblock %}
 
1
  {% extends "base.html" %}
2
+ {% block title %}Query - SQLite Admin{% endblock %}
 
 
3
  {% block content %}
4
+ <div class="query-page">
5
+ <div class="page-header">
6
+ <h1>πŸ” SQL Query</h1>
7
+ <a href="/admin/dashboard" class="btn-secondary">← Back to Dashboard</a>
 
 
 
8
  </div>
9
+
10
+ <div class="card">
11
+ <form method="POST" action="/admin/query">
12
  <div class="form-group">
13
+ <label for="sql">SQL Query</label>
14
+ <textarea id="sql" name="sql" rows="6"
15
+ placeholder="SELECT * FROM users LIMIT 10;">{{ sql }}</textarea>
16
  </div>
17
+ <button type="submit" class="btn-primary">Run Query</button>
 
 
18
  </form>
19
  </div>
20
+
21
+ {% if error %}
22
+ <div class="card error-card">
23
+ <h2>❌ Error</h2>
24
+ <pre class="error-text">{{ error }}</pre>
 
 
 
 
 
 
 
25
  </div>
26
+ {% endif %}
27
+
28
+ {% if results %}
29
+ <div class="card">
30
+ <h2>βœ… Results ({{ results|length }} rows)</h2>
31
+ {% if results|length > 0 %}
32
+ <div class="table-scroll">
33
  <table class="data-table">
34
  <thead>
35
  <tr>
 
42
  {% for row in results %}
43
  <tr>
44
  {% for col in columns %}
45
+ <td class="code">{{ row[col]|truncate(100) if row[col] != None else '<span class="null">NULL</span>' }}</td>
 
 
 
 
 
 
 
46
  {% endfor %}
47
  </tr>
48
  {% endfor %}
49
  </tbody>
50
  </table>
51
  </div>
52
+ {% else %}
53
+ <p class="empty-state">No results</p>
54
+ {% endif %}
55
+ </div>
56
+ {% endif %}
57
+
58
+ <!-- Quick Queries -->
59
+ <div class="card">
60
+ <h2>⚑ Quick Queries</h2>
61
+ <div class="quick-queries">
62
+ <form method="POST" action="/admin/query" style="display:inline;">
63
+ <input type="hidden" name="sql" value="SELECT name FROM sqlite_master WHERE type='table';">
64
+ <button type="submit" class="btn-small">List Tables</button>
65
+ </form>
66
+ <form method="POST" action="/admin/query" style="display:inline;">
67
+ <input type="hidden" name="sql" value="PRAGMA integrity_check;">
68
+ <button type="submit" class="btn-small">Integrity Check</button>
69
+ </form>
70
+ <form method="POST" action="/admin/query" style="display:inline;">
71
+ <input type="hidden" name="sql" value="SELECT * FROM backup_log ORDER BY created_at DESC LIMIT 10;">
72
+ <button type="submit" class="btn-small">Recent Backups</button>
73
+ </form>
74
+ <form method="POST" action="/admin/query" style="display:inline;">
75
+ <input type="hidden" name="sql" value="PRAGMA journal_mode;">
76
+ <button type="submit" class="btn-small">Journal Mode</button>
77
+ </form>
78
+ </div>
79
  </div>
80
  </div>
 
81
  {% endblock %}
app/admin/templates/table_view.html CHANGED
@@ -1,21 +1,15 @@
1
  {% extends "base.html" %}
2
-
3
  {% block title %}{{ table_name }} - SQLite Admin{% endblock %}
4
-
5
  {% block content %}
6
- <div class="page-header">
7
- <h1><i class="fas fa-table"></i> {{ table_name }}</h1>
8
- <a href="/admin/dashboard" class="btn btn-secondary">
9
- <i class="fas fa-arrow-left"></i> Back
10
- </a>
11
- </div>
12
-
13
- <!-- Table Structure -->
14
- <div class="card">
15
- <div class="card-header">
16
- <h3><i class="fas fa-columns"></i> Structure</h3>
17
  </div>
18
- <div class="card-body">
 
 
 
19
  <table class="data-table">
20
  <thead>
21
  <tr>
@@ -31,57 +25,49 @@
31
  {% for col in columns %}
32
  <tr>
33
  <td>{{ col.cid }}</td>
34
- <td><code>{{ col.name }}</code></td>
35
  <td>{{ col.type }}</td>
36
- <td>{% if col.notnull %}<i class="fas fa-check text-success"></i>{% endif %}</td>
37
- <td>{{ col.dflt_value or '-' }}</td>
38
- <td>{% if col.pk %}<i class="fas fa-key text-primary"></i>{% endif %}</td>
39
  </tr>
40
  {% endfor %}
41
  </tbody>
42
  </table>
43
  </div>
44
- </div>
45
-
46
- <!-- Indexes -->
47
- {% if indexes %}
48
- <div class="card">
49
- <div class="card-header">
50
- <h3><i class="fas fa-list-ol"></i> Indexes</h3>
51
- </div>
52
- <div class="card-body">
53
  <table class="data-table">
54
  <thead>
55
  <tr>
 
56
  <th>Name</th>
57
  <th>Unique</th>
58
  <th>Origin</th>
59
- <th>Partial</th>
60
  </tr>
61
  </thead>
62
  <tbody>
63
  {% for idx in indexes %}
64
  <tr>
65
- <td><code>{{ idx.name }}</code></td>
66
- <td>{% if idx.unique %}<i class="fas fa-check text-success"></i>{% endif %}</td>
 
67
  <td>{{ idx.origin }}</td>
68
- <td>{% if idx.partial %}<i class="fas fa-check text-success"></i>{% endif %}</td>
69
  </tr>
70
  {% endfor %}
71
  </tbody>
72
  </table>
73
  </div>
74
- </div>
75
- {% endif %}
76
-
77
- <!-- Data -->
78
- <div class="card">
79
- <div class="card-header">
80
- <h3><i class="fas fa-database"></i> Data ({{ total }} rows)</h3>
81
- </div>
82
- <div class="card-body">
83
  {% if rows %}
84
- <div class="table-responsive">
85
  <table class="data-table">
86
  <thead>
87
  <tr>
@@ -94,14 +80,7 @@
94
  {% for row in rows %}
95
  <tr>
96
  {% for col in columns %}
97
- <td>
98
- {% set value = row[col.name] %}
99
- {% if value is none %}
100
- <span class="text-muted">NULL</span>
101
- {% else %}
102
- <span class="truncate" title="{{ value }}">{{ value }}</span>
103
- {% endif %}
104
- </td>
105
  {% endfor %}
106
  </tr>
107
  {% endfor %}
@@ -112,23 +91,17 @@
112
  <!-- Pagination -->
113
  {% if total_pages > 1 %}
114
  <div class="pagination">
 
115
  {% if page > 1 %}
116
- <a href="/admin/table/{{ table_name }}?page={{ page - 1 }}" class="btn btn-sm btn-secondary">
117
- <i class="fas fa-chevron-left"></i> Previous
118
- </a>
119
  {% endif %}
120
-
121
- <span class="page-info">Page {{ page }} of {{ total_pages }}</span>
122
-
123
  {% if page < total_pages %}
124
- <a href="/admin/table/{{ table_name }}?page={{ page + 1 }}" class="btn btn-sm btn-secondary">
125
- Next <i class="fas fa-chevron-right"></i>
126
- </a>
127
  {% endif %}
128
  </div>
129
  {% endif %}
130
  {% else %}
131
- <p class="text-muted">No data in this table.</p>
132
  {% endif %}
133
  </div>
134
  </div>
 
1
  {% extends "base.html" %}
 
2
  {% block title %}{{ table_name }} - SQLite Admin{% endblock %}
 
3
  {% block content %}
4
+ <div class="table-view">
5
+ <div class="page-header">
6
+ <h1>πŸ“‹ Table: {{ table_name }}</h1>
7
+ <a href="/admin/dashboard" class="btn-secondary">← Back to Dashboard</a>
 
 
 
 
 
 
 
8
  </div>
9
+
10
+ <!-- Columns Info -->
11
+ <div class="card">
12
+ <h2>Columns</h2>
13
  <table class="data-table">
14
  <thead>
15
  <tr>
 
25
  {% for col in columns %}
26
  <tr>
27
  <td>{{ col.cid }}</td>
28
+ <td class="code">{{ col.name }}</td>
29
  <td>{{ col.type }}</td>
30
+ <td>{{ col.notnull }}</td>
31
+ <td class="code">{{ col.dflt_value or '-' }}</td>
32
+ <td>{{ col.pk }}</td>
33
  </tr>
34
  {% endfor %}
35
  </tbody>
36
  </table>
37
  </div>
38
+
39
+ <!-- Indexes -->
40
+ {% if indexes %}
41
+ <div class="card">
42
+ <h2>Indexes</h2>
 
 
 
 
43
  <table class="data-table">
44
  <thead>
45
  <tr>
46
+ <th>Sequence</th>
47
  <th>Name</th>
48
  <th>Unique</th>
49
  <th>Origin</th>
 
50
  </tr>
51
  </thead>
52
  <tbody>
53
  {% for idx in indexes %}
54
  <tr>
55
+ <td>{{ idx.seq }}</td>
56
+ <td class="code">{{ idx.name }}</td>
57
+ <td>{{ idx.unique }}</td>
58
  <td>{{ idx.origin }}</td>
 
59
  </tr>
60
  {% endfor %}
61
  </tbody>
62
  </table>
63
  </div>
64
+ {% endif %}
65
+
66
+ <!-- Data -->
67
+ <div class="card">
68
+ <h2>Data ({{ total }} rows)</h2>
 
 
 
 
69
  {% if rows %}
70
+ <div class="table-scroll">
71
  <table class="data-table">
72
  <thead>
73
  <tr>
 
80
  {% for row in rows %}
81
  <tr>
82
  {% for col in columns %}
83
+ <td class="code">{{ row[col.name]|truncate(100) if row[col.name] else '<span class="null">NULL</span>' }}</td>
 
 
 
 
 
 
 
84
  {% endfor %}
85
  </tr>
86
  {% endfor %}
 
91
  <!-- Pagination -->
92
  {% if total_pages > 1 %}
93
  <div class="pagination">
94
+ <span>Page {{ page }} of {{ total_pages }}</span>
95
  {% if page > 1 %}
96
+ <a href="?page={{ page - 1 }}" class="btn-small">← Prev</a>
 
 
97
  {% endif %}
 
 
 
98
  {% if page < total_pages %}
99
+ <a href="?page={{ page + 1 }}" class="btn-small">Next β†’</a>
 
 
100
  {% endif %}
101
  </div>
102
  {% endif %}
103
  {% else %}
104
+ <p class="empty-state">No data in this table</p>
105
  {% endif %}
106
  </div>
107
  </div>
app/api/routes.py CHANGED
@@ -1,36 +1,25 @@
1
  """
2
  API Routes β€” Your Application Endpoints
3
  ────────────────────────────────────────
4
- These are the actual API endpoints for your application.
5
  The admin panel is separate at /admin/*
6
  """
7
 
8
  from fastapi import APIRouter, HTTPException
9
  from pydantic import BaseModel
10
- from typing import List, Optional
11
  import uuid
12
- from datetime import datetime
13
-
14
  from app.database import get_db
15
 
16
  router = APIRouter(prefix="/api", tags=["api"])
17
 
18
 
19
- # ═══════════════════════════════════════════════════════
20
- # Pydantic Models
21
- # ═══════════════════════════════════════════════════════
22
-
23
  class UserCreate(BaseModel):
24
  email: str
25
  name: Optional[str] = None
26
  avatar_url: Optional[str] = None
27
 
28
- class UserResponse(BaseModel):
29
- id: str
30
- email: str
31
- name: Optional[str]
32
- avatar_url: Optional[str]
33
- created_at: str
34
 
35
  class ProjectCreate(BaseModel):
36
  user_id: str
@@ -38,13 +27,6 @@ class ProjectCreate(BaseModel):
38
  description: Optional[str] = None
39
  design_tokens: Optional[dict] = None
40
 
41
- class ProjectResponse(BaseModel):
42
- id: str
43
- user_id: str
44
- name: str
45
- description: Optional[str]
46
- status: str
47
- created_at: str
48
 
49
  class ColorPaletteCreate(BaseModel):
50
  project_id: str
@@ -53,41 +35,31 @@ class ColorPaletteCreate(BaseModel):
53
  mood: Optional[str] = None
54
 
55
 
56
- # ═══════════════════════════════════════════════════════
57
- # User Endpoints
58
- # ═══════════════════════════════════════════════════════
59
-
60
- @router.post("/users", response_model=UserResponse)
61
  async def create_user(user: UserCreate):
62
- """Create a new user."""
63
  conn = get_db()
64
- user_id = str(uuid.uuid4())[:8]
65
-
66
  try:
67
  conn.execute(
68
- """INSERT INTO users (id, email, name, avatar_url)
69
- VALUES (?, ?, ?, ?)""",
70
  (user_id, user.email, user.name, user.avatar_url)
71
  )
72
  conn.commit()
73
-
74
- row = conn.execute(
75
- "SELECT * FROM users WHERE id = ?", (user_id,)
76
- ).fetchone()
77
- return dict(row)
78
  except Exception as e:
79
  raise HTTPException(status_code=400, detail=str(e))
80
 
81
- @router.get("/users", response_model=List[UserResponse])
 
82
  async def list_users():
83
- """List all users."""
84
  conn = get_db()
85
  rows = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
86
  return [dict(r) for r in rows]
87
 
88
- @router.get("/users/{user_id}", response_model=UserResponse)
 
89
  async def get_user(user_id: str):
90
- """Get a specific user."""
91
  conn = get_db()
92
  row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
93
  if not row:
@@ -95,108 +67,69 @@ async def get_user(user_id: str):
95
  return dict(row)
96
 
97
 
98
- # ═══════════════════════════════════════════════════════
99
- # Project Endpoints
100
- # ═══════════════════════════════════════════════════════
101
-
102
- @router.post("/projects", response_model=ProjectResponse)
103
  async def create_project(project: ProjectCreate):
104
- """Create a new project."""
105
  conn = get_db()
106
- project_id = str(uuid.uuid4())[:8]
107
-
108
- import json
109
- design_tokens_json = json.dumps(project.design_tokens) if project.design_tokens else None
110
-
111
- conn.execute(
112
- """INSERT INTO projects (id, user_id, name, description, design_tokens)
113
- VALUES (?, ?, ?, ?, ?)""",
114
- (project_id, project.user_id, project.name, project.description, design_tokens_json)
115
- )
116
- conn.commit()
117
-
118
- row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
119
- return dict(row)
120
 
121
  @router.get("/projects")
122
  async def list_projects(user_id: Optional[str] = None):
123
- """List projects, optionally filtered by user_id."""
124
  conn = get_db()
125
  if user_id:
126
- rows = conn.execute(
127
- "SELECT * FROM projects WHERE user_id = ? ORDER BY created_at DESC",
128
- (user_id,)
129
- ).fetchall()
130
  else:
131
  rows = conn.execute("SELECT * FROM projects ORDER BY created_at DESC").fetchall()
132
  return [dict(r) for r in rows]
133
 
 
134
  @router.get("/projects/{project_id}")
135
  async def get_project(project_id: str):
136
- """Get a specific project with its palettes and assets."""
137
  conn = get_db()
138
-
139
- project = conn.execute(
140
- "SELECT * FROM projects WHERE id = ?", (project_id,)
141
- ).fetchone()
142
- if not project:
143
  raise HTTPException(status_code=404, detail="Project not found")
144
-
145
- palettes = conn.execute(
146
- "SELECT * FROM color_palettes WHERE project_id = ?", (project_id,)
147
- ).fetchall()
148
-
149
- assets = conn.execute(
150
- "SELECT * FROM design_assets WHERE project_id = ?", (project_id,)
151
- ).fetchall()
152
-
153
- return {
154
- "project": dict(project),
155
- "palettes": [dict(p) for p in palettes],
156
- "assets": [dict(a) for a in assets]
157
- }
158
-
159
-
160
- # ═══════════════════════════════════════════════════════
161
- # Color Palette Endpoints
162
- # ═══════════════════════════════════════════════════════
163
 
 
164
  @router.post("/palettes")
165
  async def create_palette(palette: ColorPaletteCreate):
166
- """Create a new color palette."""
167
  conn = get_db()
168
- palette_id = str(uuid.uuid4())[:8]
169
-
170
- import json
171
- colors_json = json.dumps(palette.colors)
172
-
173
- conn.execute(
174
- """INSERT INTO color_palettes (id, project_id, name, colors, mood)
175
- VALUES (?, ?, ?, ?, ?)""",
176
- (palette_id, palette.project_id, palette.name, colors_json, palette.mood)
177
- )
178
- conn.commit()
179
-
180
- row = conn.execute("SELECT * FROM color_palettes WHERE id = ?", (palette_id,)).fetchone()
181
- return dict(row)
182
 
183
- @router.get("/palettes/{project_id}")
184
- async def list_palettes(project_id: str):
185
- """List all palettes for a project."""
186
  conn = get_db()
187
- rows = conn.execute(
188
- "SELECT * FROM color_palettes WHERE project_id = ? ORDER BY created_at DESC",
189
- (project_id,)
190
- ).fetchall()
191
  return [dict(r) for r in rows]
192
 
193
 
194
- # ═══════════════════════════════════════════════════════
195
- # Health & Status
196
- # ═══════════════════════════════════════════════════════
197
-
198
  @router.get("/health")
199
  async def health_check():
200
- """API health check."""
201
  from app.database import integrity_check
202
  return integrity_check()
 
1
  """
2
  API Routes β€” Your Application Endpoints
3
  ────────────────────────────────────────
4
+ This is where your actual API endpoints go.
5
  The admin panel is separate at /admin/*
6
  """
7
 
8
  from fastapi import APIRouter, HTTPException
9
  from pydantic import BaseModel
10
+ from typing import Optional
11
  import uuid
 
 
12
  from app.database import get_db
13
 
14
  router = APIRouter(prefix="/api", tags=["api"])
15
 
16
 
17
+ # ── Models ──
 
 
 
18
  class UserCreate(BaseModel):
19
  email: str
20
  name: Optional[str] = None
21
  avatar_url: Optional[str] = None
22
 
 
 
 
 
 
 
23
 
24
  class ProjectCreate(BaseModel):
25
  user_id: str
 
27
  description: Optional[str] = None
28
  design_tokens: Optional[dict] = None
29
 
 
 
 
 
 
 
 
30
 
31
  class ColorPaletteCreate(BaseModel):
32
  project_id: str
 
35
  mood: Optional[str] = None
36
 
37
 
38
+ # ── Users ──
39
+ @router.post("/users")
 
 
 
40
  async def create_user(user: UserCreate):
 
41
  conn = get_db()
42
+ user_id = str(uuid.uuid4())
 
43
  try:
44
  conn.execute(
45
+ "INSERT INTO users (id, email, name, avatar_url) VALUES (?, ?, ?, ?)",
 
46
  (user_id, user.email, user.name, user.avatar_url)
47
  )
48
  conn.commit()
49
+ return {"id": user_id, "email": user.email}
 
 
 
 
50
  except Exception as e:
51
  raise HTTPException(status_code=400, detail=str(e))
52
 
53
+
54
+ @router.get("/users")
55
  async def list_users():
 
56
  conn = get_db()
57
  rows = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
58
  return [dict(r) for r in rows]
59
 
60
+
61
+ @router.get("/users/{user_id}")
62
  async def get_user(user_id: str):
 
63
  conn = get_db()
64
  row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
65
  if not row:
 
67
  return dict(row)
68
 
69
 
70
+ # ── Projects ──
71
+ @router.post("/projects")
 
 
 
72
  async def create_project(project: ProjectCreate):
 
73
  conn = get_db()
74
+ project_id = str(uuid.uuid4())
75
+ try:
76
+ conn.execute(
77
+ "INSERT INTO projects (id, user_id, name, description, design_tokens) VALUES (?, ?, ?, ?, ?)",
78
+ (project_id, project.user_id, project.name, project.description, str(project.design_tokens))
79
+ )
80
+ conn.commit()
81
+ return {"id": project_id, "name": project.name}
82
+ except Exception as e:
83
+ raise HTTPException(status_code=400, detail=str(e))
84
+
 
 
 
85
 
86
  @router.get("/projects")
87
  async def list_projects(user_id: Optional[str] = None):
 
88
  conn = get_db()
89
  if user_id:
90
+ rows = conn.execute("SELECT * FROM projects WHERE user_id = ? ORDER BY created_at DESC", (user_id,)).fetchall()
 
 
 
91
  else:
92
  rows = conn.execute("SELECT * FROM projects ORDER BY created_at DESC").fetchall()
93
  return [dict(r) for r in rows]
94
 
95
+
96
  @router.get("/projects/{project_id}")
97
  async def get_project(project_id: str):
 
98
  conn = get_db()
99
+ row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
100
+ if not row:
 
 
 
101
  raise HTTPException(status_code=404, detail="Project not found")
102
+ return dict(row)
103
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
+ # ── Color Palettes ──
106
  @router.post("/palettes")
107
  async def create_palette(palette: ColorPaletteCreate):
 
108
  conn = get_db()
109
+ palette_id = str(uuid.uuid4())
110
+ try:
111
+ conn.execute(
112
+ "INSERT INTO color_palettes (id, project_id, name, colors, mood) VALUES (?, ?, ?, ?, ?)",
113
+ (palette_id, palette.project_id, palette.name, str(palette.colors), palette.mood)
114
+ )
115
+ conn.commit()
116
+ return {"id": palette_id, "name": palette.name}
117
+ except Exception as e:
118
+ raise HTTPException(status_code=400, detail=str(e))
119
+
 
 
 
120
 
121
+ @router.get("/palettes")
122
+ async def list_palettes(project_id: Optional[str] = None):
 
123
  conn = get_db()
124
+ if project_id:
125
+ rows = conn.execute("SELECT * FROM color_palettes WHERE project_id = ? ORDER BY created_at DESC", (project_id,)).fetchall()
126
+ else:
127
+ rows = conn.execute("SELECT * FROM color_palettes ORDER BY created_at DESC").fetchall()
128
  return [dict(r) for r in rows]
129
 
130
 
131
+ # ── Health Check ──
 
 
 
132
  @router.get("/health")
133
  async def health_check():
 
134
  from app.database import integrity_check
135
  return integrity_check()
app/backup.py CHANGED
@@ -6,11 +6,11 @@ Layer 2: Python sqlite3.backup() β†’ snapshot to /tmp (on-demand + scheduled)
6
  Layer 3: Upload snapshot to HF Bucket (persistent, survives restarts)
7
 
8
  Sources:
9
- - Python backup API: https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup
10
- - SQLite Backup API: https://sqlite.org/backup.html
11
- - Litestream: https://litestream.io/how-it-works/
12
- - HF Hub API: https://huggingface.co/docs/huggingface_hub
13
- - SQLite VACUUM INTO: https://www.sqlite.org/lang_vacuum.html
14
  """
15
 
16
  import sqlite3
@@ -47,7 +47,7 @@ def create_snapshot(label: str = "manual") -> dict:
47
  This uses SQLite's Online Backup API under the hood β€” it's safe to call
48
  while the database is being written to. The backup is atomic and consistent.
49
 
50
- Source: https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup
51
  """
52
  if not os.path.exists(DB_PATH):
53
  return {"status": "error", "message": "Database not found"}
@@ -92,7 +92,7 @@ def create_compressed_snapshot() -> dict:
92
  Create a gzip-compressed snapshot.
93
  SQLite databases compress very well (often 60-80% reduction).
94
 
95
- Source: https://litestream.io/alternatives/cron/
96
  """
97
  result = create_snapshot(label="compressed")
98
  if result["status"] != "success":
@@ -126,7 +126,7 @@ def create_vacuum_snapshot() -> dict:
126
  This creates a snapshot from a single transaction β€” best for high-write scenarios.
127
  Also removes any free pages, giving you the smallest possible backup.
128
 
129
- Source: https://sqlite.org/lang_vacuum.html
130
  """
131
  if not os.path.exists(DB_PATH):
132
  return {"status": "error", "message": "Database not found"}
@@ -164,7 +164,7 @@ def upload_to_hf_bucket(local_path: str = None) -> dict:
164
 
165
  If no local_path given, creates a fresh compressed snapshot first.
166
 
167
- Source: https://huggingface.co/docs/huggingface_hub/guides/upload
168
  """
169
  if not HF_TOKEN or not HF_BUCKET_REPO:
170
  return {"status": "error", "message": "HF_TOKEN or HF_BUCKET_REPO not set"}
@@ -215,7 +215,7 @@ def restore_from_hf_bucket() -> dict:
215
  Restore database from HF Bucket.
216
  Called on startup by entrypoint.sh if local DB doesn't exist.
217
 
218
- Source: https://huggingface.co/docs/huggingface_hub/guides/download
219
  """
220
  if not HF_TOKEN or not HF_BUCKET_REPO:
221
  logger.warning("⚠️ HF credentials not set, skipping restore")
 
6
  Layer 3: Upload snapshot to HF Bucket (persistent, survives restarts)
7
 
8
  Sources:
9
+ - Python backup API: https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup
10
+ - SQLite Backup API: https://sqlite.org/backup.html
11
+ - Litestream: https://litestream.io/how-it-works/
12
+ - HF Hub API: https://huggingface.co/docs/huggingface_hub
13
+ - SQLite VACUUM INTO: https://www.sqlite.org/lang_vacuum.html
14
  """
15
 
16
  import sqlite3
 
47
  This uses SQLite's Online Backup API under the hood β€” it's safe to call
48
  while the database is being written to. The backup is atomic and consistent.
49
 
50
+ Source: https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup
51
  """
52
  if not os.path.exists(DB_PATH):
53
  return {"status": "error", "message": "Database not found"}
 
92
  Create a gzip-compressed snapshot.
93
  SQLite databases compress very well (often 60-80% reduction).
94
 
95
+ Source: https://litestream.io/alternatives/cron/
96
  """
97
  result = create_snapshot(label="compressed")
98
  if result["status"] != "success":
 
126
  This creates a snapshot from a single transaction β€” best for high-write scenarios.
127
  Also removes any free pages, giving you the smallest possible backup.
128
 
129
+ Source: https://sqlite.org/lang_vacuum.html
130
  """
131
  if not os.path.exists(DB_PATH):
132
  return {"status": "error", "message": "Database not found"}
 
164
 
165
  If no local_path given, creates a fresh compressed snapshot first.
166
 
167
+ Source: https://huggingface.co/docs/huggingface_hub/guides/upload
168
  """
169
  if not HF_TOKEN or not HF_BUCKET_REPO:
170
  return {"status": "error", "message": "HF_TOKEN or HF_BUCKET_REPO not set"}
 
215
  Restore database from HF Bucket.
216
  Called on startup by entrypoint.sh if local DB doesn't exist.
217
 
218
+ Source: https://huggingface.co/docs/huggingface_hub/guides/download
219
  """
220
  if not HF_TOKEN or not HF_BUCKET_REPO:
221
  logger.warning("⚠️ HF credentials not set, skipping restore")
app/database.py CHANGED
@@ -4,10 +4,10 @@ SQLite Database Module
4
  Handles connection, initialization, and health checks.
5
 
6
  Sources:
7
- - Python sqlite3 docs: https://docs.python.org/3/library/sqlite3.html
8
- - Litestream tips: https://litestream.io/tips/
9
- - SQLite WAL mode: https://www.sqlite.org/wal.html
10
- - SQLite PRAGMA: https://www.sqlite.org/pragma.html
11
  """
12
 
13
  import sqlite3
@@ -153,7 +153,7 @@ def integrity_check() -> dict:
153
  Run SQLite integrity check.
154
  Returns health status of the database.
155
 
156
- Source: https://www.sqlite.org/pragma.html#pragma_integrity_check
157
  """
158
  conn = get_db()
159
  result = conn.execute("PRAGMA integrity_check").fetchone()
 
4
  Handles connection, initialization, and health checks.
5
 
6
  Sources:
7
+ - Python sqlite3 docs: https://docs.python.org/3/library/sqlite3.html
8
+ - Litestream tips: https://litestream.io/tips/
9
+ - SQLite WAL mode: https://www.sqlite.org/wal.html
10
+ - SQLite PRAGMA: https://www.sqlite.org/pragma.html
11
  """
12
 
13
  import sqlite3
 
153
  Run SQLite integrity check.
154
  Returns health status of the database.
155
 
156
+ Source: https://www.sqlite.org/pragma.html#pragma_integrity_check
157
  """
158
  conn = get_db()
159
  result = conn.execute("PRAGMA integrity_check").fetchone()
app/main.py CHANGED
@@ -4,8 +4,7 @@ Main FastAPI Application
4
  Ties everything together: API + Admin + MCP + Backup scheduler
5
 
6
  Sources:
7
- - FastAPI: https://fastapi.tiangolo.com/
8
- - FastAPI-MCP: https://github.com/tadata-org/fastapi_mcp
9
  """
10
 
11
  from fastapi import FastAPI
@@ -46,8 +45,8 @@ async def lifespan(app: FastAPI):
46
 
47
 
48
  app = FastAPI(
49
- title="SQLite DBaaS",
50
- description="SQLite + Litestream + HF Bucket powered Database-as-a-Service",
51
  version="1.0.0",
52
  lifespan=lifespan,
53
  )
@@ -59,16 +58,12 @@ app.include_router(api_router)
59
  # ── Mount static files ──
60
  app.mount("/static", StaticFiles(directory="static"), name="static")
61
 
62
- # ── MCP Layer ──
63
- # Uncomment when ready:
64
- # from fastapi_mcp import FastApiMCP
65
- # mcp = FastApiMCP(app)
66
- # mcp.mount()
67
 
68
  @app.get("/")
69
  async def root():
70
  return {
71
  "status": "running",
 
72
  "admin": "/admin/login",
73
  "docs": "/docs",
74
  "health": "/admin/api/health",
 
4
  Ties everything together: API + Admin + MCP + Backup scheduler
5
 
6
  Sources:
7
+ - FastAPI: https://fastapi.tiangolo.com/
 
8
  """
9
 
10
  from fastapi import FastAPI
 
45
 
46
 
47
  app = FastAPI(
48
+ title="SQLite DBaaS - Admin Console",
49
+ description="SQLite + Litestream + HF Bucket powered database service",
50
  version="1.0.0",
51
  lifespan=lifespan,
52
  )
 
58
  # ── Mount static files ──
59
  app.mount("/static", StaticFiles(directory="static"), name="static")
60
 
 
 
 
 
 
61
 
62
  @app.get("/")
63
  async def root():
64
  return {
65
  "status": "running",
66
+ "service": "SQLite DBaaS",
67
  "admin": "/admin/login",
68
  "docs": "/docs",
69
  "health": "/admin/api/health",
app/mcp/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # MCP Tools Package
app/mcp/tools.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MCP Tools β€” AI Integration Layer
3
+ ─────────────────────────────────
4
+ Tools for AI assistants to interact with the database.
5
+ """
6
+
7
+ from app.database import get_db
8
+
9
+ def get_all_tables() -> list:
10
+ """Get all table names in the database."""
11
+ conn = get_db()
12
+ tables = conn.execute(
13
+ "SELECT name FROM sqlite_master WHERE type='table'"
14
+ ).fetchall()
15
+ return [t[0] for t in tables]
16
+
17
+
18
+ def get_table_schema(table_name: str) -> list:
19
+ """Get the schema for a specific table."""
20
+ conn = get_db()
21
+ columns = conn.execute(f"PRAGMA table_info([{table_name}])").fetchall()
22
+ return [dict(c) for c in columns]
23
+
24
+
25
+ def execute_safe_query(sql: str) -> list:
26
+ """
27
+ Execute a SELECT query safely.
28
+ Only allows SELECT and PRAGMA statements.
29
+ """
30
+ sql_upper = sql.strip().upper()
31
+ if not (sql_upper.startswith("SELECT") or sql_upper.startswith("PRAGMA")):
32
+ raise ValueError("Only SELECT and PRAGMA queries are allowed")
33
+
34
+ conn = get_db()
35
+ rows = conn.execute(sql).fetchall()
36
+ return [dict(r) for r in rows]
requirements.txt CHANGED
@@ -3,5 +3,4 @@ uvicorn==0.30.0
3
  jinja2==3.1.4
4
  python-multipart==0.0.9
5
  huggingface-hub==0.25.0
6
- fastapi-mcp==0.1.0
7
  aiofiles==24.1.0
 
3
  jinja2==3.1.4
4
  python-multipart==0.0.9
5
  huggingface-hub==0.25.0
 
6
  aiofiles==24.1.0
static/admin.css CHANGED
@@ -1,19 +1,21 @@
1
- /* SQLite Admin Panel Styles */
 
 
2
 
3
  :root {
4
- --primary: #6366f1;
5
- --primary-dark: #4f46e5;
6
- --secondary: #64748b;
7
- --success: #22c55e;
 
 
 
 
8
  --error: #ef4444;
 
9
  --warning: #f59e0b;
10
- --bg: #f8fafc;
11
- --card-bg: #ffffff;
12
- --text: #1e293b;
13
- --text-muted: #64748b;
14
- --border: #e2e8f0;
15
- --shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
16
- --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1);
17
  }
18
 
19
  * {
@@ -24,156 +26,188 @@
24
 
25
  body {
26
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
27
- background: var(--bg);
28
  color: var(--text);
29
- line-height: 1.6;
 
 
30
  }
31
 
32
- /* Layout */
33
- .admin-container {
 
 
 
 
 
34
  display: flex;
35
- min-height: 100vh;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
 
38
- .sidebar {
39
- width: 260px;
40
- background: var(--card-bg);
41
- border-right: 1px solid var(--border);
42
- position: fixed;
43
- height: 100vh;
44
- overflow-y: auto;
 
 
 
 
 
 
45
  }
46
 
47
- .sidebar-header {
 
 
 
 
 
 
48
  padding: 1.5rem;
 
 
 
 
 
 
 
 
 
49
  border-bottom: 1px solid var(--border);
50
- display: flex;
51
- align-items: center;
52
- gap: 0.75rem;
53
- font-size: 1.25rem;
54
- font-weight: 600;
55
- color: var(--primary);
56
  }
57
 
58
- .nav-menu {
59
- list-style: none;
60
- padding: 1rem 0;
 
61
  }
62
 
63
- .nav-menu a {
 
 
 
 
64
  display: flex;
 
65
  align-items: center;
66
- gap: 0.75rem;
67
- padding: 0.875rem 1.5rem;
68
- color: var(--text);
69
- text-decoration: none;
70
- transition: all 0.2s;
71
  }
72
 
73
- .nav-menu a:hover,
74
- .nav-menu a.active {
75
- background: rgba(99, 102, 241, 0.1);
76
- color: var(--primary);
77
  }
78
 
79
- .main-content {
80
- flex: 1;
81
- margin-left: 260px;
82
- padding: 2rem;
83
- max-width: calc(100% - 260px);
 
 
 
84
  }
85
 
86
- /* Login */
87
- .login-container {
88
  display: flex;
89
- justify-content: center;
90
- align-items: center;
91
- min-height: 100vh;
92
- background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
93
  }
94
 
95
- .login-box {
96
- background: var(--card-bg);
97
- padding: 2.5rem;
98
- border-radius: 12px;
99
- box-shadow: var(--shadow-lg);
100
- width: 100%;
101
- max-width: 400px;
102
  }
103
 
104
- .login-header {
105
- text-align: center;
106
- margin-bottom: 2rem;
107
  }
108
 
109
- .login-header i {
110
- font-size: 3rem;
111
- color: var(--primary);
112
- margin-bottom: 1rem;
113
  }
114
 
115
- .login-header h2 {
116
- font-size: 1.5rem;
117
- color: var(--text);
118
  }
119
 
120
- /* Cards */
121
- .card {
122
- background: var(--card-bg);
123
- border-radius: 8px;
124
- box-shadow: var(--shadow);
125
- margin-bottom: 1.5rem;
126
- overflow: hidden;
127
  }
128
 
129
- .card-header {
130
- padding: 1.25rem 1.5rem;
 
 
 
 
 
 
 
131
  border-bottom: 1px solid var(--border);
132
- display: flex;
133
- align-items: center;
134
- gap: 0.5rem;
135
  }
136
 
137
- .card-header h3 {
138
- font-size: 1.125rem;
139
  font-weight: 600;
 
 
 
 
140
  }
141
 
142
- .card-body {
143
- padding: 1.5rem;
144
  }
145
 
146
- /* Page Header */
147
- .page-header {
148
- display: flex;
149
- justify-content: space-between;
150
- align-items: center;
151
- margin-bottom: 2rem;
152
  }
153
 
154
- .page-header h1 {
155
- font-size: 1.875rem;
156
- font-weight: 700;
157
  }
158
 
159
- /* Buttons */
160
- .btn {
161
- display: inline-flex;
162
- align-items: center;
163
- gap: 0.5rem;
164
- padding: 0.625rem 1.25rem;
165
- border-radius: 6px;
166
- font-size: 0.875rem;
167
- font-weight: 500;
168
- text-decoration: none;
169
- border: none;
170
- cursor: pointer;
171
- transition: all 0.2s;
172
- }
173
 
174
  .btn-primary {
175
  background: var(--primary);
176
  color: white;
 
 
 
 
 
 
177
  }
178
 
179
  .btn-primary:hover {
@@ -181,125 +215,127 @@ body {
181
  }
182
 
183
  .btn-secondary {
184
- background: var(--secondary);
185
- color: white;
 
 
 
 
 
 
 
 
186
  }
187
 
188
  .btn-secondary:hover {
189
- background: #475569;
190
  }
191
 
192
- .btn-success {
193
- background: var(--success);
194
- color: white;
195
- }
196
-
197
- .btn-success:hover {
198
- background: #16a34a;
199
- }
200
-
201
- .btn-sm {
202
- padding: 0.375rem 0.75rem;
203
- font-size: 0.75rem;
204
  }
205
 
206
- .btn-block {
207
- width: 100%;
208
- justify-content: center;
209
- }
210
 
211
- /* Forms */
212
  .form-group {
213
- margin-bottom: 1.25rem;
214
  }
215
 
216
  .form-group label {
217
  display: block;
218
  margin-bottom: 0.5rem;
219
  font-weight: 500;
220
- font-size: 0.875rem;
221
  }
222
 
223
  .form-group input,
224
- .form-group textarea,
225
- .sql-editor {
226
  width: 100%;
227
  padding: 0.75rem 1rem;
 
228
  border: 1px solid var(--border);
229
- border-radius: 6px;
230
- font-size: 0.875rem;
 
231
  font-family: inherit;
232
- transition: border-color 0.2s;
233
  }
234
 
235
  .form-group input:focus,
236
- .form-group textarea:focus,
237
- .sql-editor:focus {
238
  outline: none;
239
  border-color: var(--primary);
240
  }
241
 
242
- .sql-editor {
243
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
244
- resize: vertical;
245
- min-height: 120px;
246
  }
247
 
248
- /* Tables */
249
- .data-table {
250
- width: 100%;
251
- border-collapse: collapse;
252
- font-size: 0.875rem;
253
- }
254
 
255
- .data-table th,
256
- .data-table td {
257
- padding: 0.875rem 1rem;
258
- text-align: left;
259
- border-bottom: 1px solid var(--border);
260
  }
261
 
262
- .data-table th {
263
- font-weight: 600;
264
- background: var(--bg);
265
- color: var(--text-muted);
266
- font-size: 0.75rem;
267
- text-transform: uppercase;
268
- letter-spacing: 0.05em;
269
  }
270
 
271
- .data-table tr:hover {
272
- background: var(--bg);
 
273
  }
274
 
275
- .table-responsive {
276
- overflow-x: auto;
 
 
277
  }
278
 
279
- /* Health Grid */
280
- .health-grid {
281
- display: grid;
282
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
283
- gap: 1.5rem;
 
 
284
  }
285
 
286
- .health-item {
287
- display: flex;
288
- flex-direction: column;
289
- gap: 0.25rem;
290
- }
291
 
292
- .health-label {
293
- font-size: 0.75rem;
294
- color: var(--text-muted);
295
- text-transform: uppercase;
296
- letter-spacing: 0.05em;
 
 
297
  }
298
 
299
- .health-value {
300
- font-size: 1.125rem;
301
- font-weight: 600;
302
- }
303
 
304
  .status-success {
305
  color: var(--success);
@@ -309,151 +345,133 @@ body {
309
  color: var(--error);
310
  }
311
 
312
- /* Alerts */
313
- .alert {
314
- padding: 1rem 1.25rem;
315
- border-radius: 6px;
316
- margin-bottom: 1.5rem;
317
- display: flex;
318
- align-items: center;
319
- gap: 0.5rem;
320
  }
321
 
322
- .alert-error {
323
- background: rgba(239, 68, 68, 0.1);
 
 
 
 
 
324
  color: var(--error);
325
- border: 1px solid rgba(239, 68, 68, 0.2);
326
  }
327
 
328
- /* Badges */
329
- .badge {
330
- display: inline-flex;
331
- align-items: center;
332
- padding: 0.25rem 0.625rem;
333
- border-radius: 9999px;
334
- font-size: 0.75rem;
335
- font-weight: 500;
336
- }
337
 
338
- .badge-success {
339
- background: rgba(34, 197, 94, 0.1);
340
- color: var(--success);
 
341
  }
342
 
343
- .badge-error {
344
- background: rgba(239, 68, 68, 0.1);
345
- color: var(--error);
346
- }
347
 
348
- /* Pagination */
349
- .pagination {
350
  display: flex;
351
- justify-content: center;
352
- align-items: center;
353
- gap: 1rem;
354
- margin-top: 1.5rem;
355
  }
356
 
357
- .page-info {
358
- color: var(--text-muted);
359
- font-size: 0.875rem;
360
- }
361
 
362
- /* Backup Actions */
363
  .backup-actions {
364
  display: flex;
365
- gap: 0.75rem;
366
  flex-wrap: wrap;
 
367
  }
368
 
369
- .inline-form {
370
- display: inline;
 
 
 
 
 
371
  }
372
 
373
- /* Backup Strategy */
374
- .backup-strategy {
375
- display: flex;
376
- flex-direction: column;
377
- gap: 1.5rem;
 
 
 
 
 
 
 
 
 
378
  }
379
 
380
  .strategy-layer {
381
- padding: 1.25rem;
382
- background: var(--bg);
383
  border-radius: 8px;
384
- border-left: 4px solid var(--primary);
385
  }
386
 
387
- .strategy-layer h4 {
388
- display: flex;
389
- align-items: center;
390
- gap: 0.75rem;
391
  margin-bottom: 0.5rem;
392
- font-size: 1rem;
393
- }
394
-
395
- .layer-badge {
396
- display: inline-flex;
397
- align-items: center;
398
- justify-content: center;
399
- width: 24px;
400
- height: 24px;
401
- background: var(--primary);
402
- color: white;
403
- border-radius: 50%;
404
- font-size: 0.75rem;
405
- font-weight: 600;
406
  }
407
 
408
  .strategy-layer p {
 
409
  color: var(--text-muted);
410
- font-size: 0.875rem;
411
- margin-left: 2rem;
412
- }
413
-
414
- /* Utilities */
415
- .text-primary {
416
- color: var(--primary);
417
  }
418
 
419
- .text-success {
420
- color: var(--success);
421
- }
422
 
423
- .text-muted {
 
 
424
  color: var(--text-muted);
 
 
425
  }
426
 
427
- .truncate {
428
- max-width: 200px;
429
- overflow: hidden;
430
- text-overflow: ellipsis;
431
- white-space: nowrap;
432
- }
433
-
434
- code {
435
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
436
- background: var(--bg);
437
- padding: 0.125rem 0.375rem;
438
- border-radius: 4px;
439
- font-size: 0.875rem;
440
- }
441
 
442
- /* Responsive */
443
  @media (max-width: 768px) {
444
- .sidebar {
445
- width: 100%;
446
- position: relative;
447
- height: auto;
448
  }
449
 
450
- .main-content {
451
  margin-left: 0;
452
- max-width: 100%;
 
 
 
 
453
  }
454
 
455
- .admin-container {
456
  flex-direction: column;
 
 
457
  }
458
 
459
  .health-grid {
 
1
+ /* ═══════════════════════════════════════════
2
+ SQLite DBaaS Admin Panel Styles
3
+ ═══════════════════════════════════════════ */
4
 
5
  :root {
6
+ --primary: #10b981;
7
+ --primary-dark: #059669;
8
+ --secondary: #6b7280;
9
+ --background: #0f172a;
10
+ --surface: #1e293b;
11
+ --surface-light: #334155;
12
+ --text: #f8fafc;
13
+ --text-muted: #94a3b8;
14
  --error: #ef4444;
15
+ --success: #22c55e;
16
  --warning: #f59e0b;
17
+ --border: #475569;
18
+ --code-bg: #0f172a;
 
 
 
 
 
19
  }
20
 
21
  * {
 
26
 
27
  body {
28
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
29
+ background: var(--background);
30
  color: var(--text);
31
+ min-height: 100vh;
32
+ display: flex;
33
+ flex-direction: column;
34
  }
35
 
36
+ /* ═══════════════════════════════════════════
37
+ Navigation
38
+ ═══════════════════════════════════════════ */
39
+
40
+ .navbar {
41
+ background: var(--surface);
42
+ padding: 1rem 2rem;
43
  display: flex;
44
+ justify-content: space-between;
45
+ align-items: center;
46
+ border-bottom: 1px solid var(--border);
47
+ }
48
+
49
+ .nav-brand a {
50
+ color: var(--primary);
51
+ text-decoration: none;
52
+ font-size: 1.25rem;
53
+ font-weight: 700;
54
+ }
55
+
56
+ .nav-links a {
57
+ color: var(--text-muted);
58
+ text-decoration: none;
59
+ margin-left: 1.5rem;
60
+ transition: color 0.2s;
61
  }
62
 
63
+ .nav-links a:hover {
64
+ color: var(--primary);
65
+ }
66
+
67
+ /* ═══════════════════════════════════════════
68
+ Container
69
+ ═══════════════════════════════════════════ */
70
+
71
+ .container {
72
+ max-width: 1400px;
73
+ margin: 0 auto;
74
+ padding: 2rem;
75
+ flex: 1;
76
  }
77
 
78
+ /* ═══════════════════════════════════════════
79
+ Cards
80
+ ═══════════════════════════════════════════ */
81
+
82
+ .card {
83
+ background: var(--surface);
84
+ border-radius: 12px;
85
  padding: 1.5rem;
86
+ margin-bottom: 1.5rem;
87
+ border: 1px solid var(--border);
88
+ }
89
+
90
+ .card h2 {
91
+ font-size: 1.1rem;
92
+ color: var(--text);
93
+ margin-bottom: 1rem;
94
+ padding-bottom: 0.5rem;
95
  border-bottom: 1px solid var(--border);
 
 
 
 
 
 
96
  }
97
 
98
+ .card-actions {
99
+ margin-top: 1rem;
100
+ padding-top: 1rem;
101
+ border-top: 1px solid var(--border);
102
  }
103
 
104
+ /* ═══════════════════════════════════════════
105
+ Page Header
106
+ ═══════════════════════════════════════════ */
107
+
108
+ .page-header {
109
  display: flex;
110
+ justify-content: space-between;
111
  align-items: center;
112
+ margin-bottom: 1.5rem;
 
 
 
 
113
  }
114
 
115
+ .page-header h1 {
116
+ font-size: 1.5rem;
 
 
117
  }
118
 
119
+ /* ═══════════════════════════════════════════
120
+ Health Grid
121
+ ═══════════════════════════════════════════ */
122
+
123
+ .health-grid {
124
+ display: grid;
125
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
126
+ gap: 1rem;
127
  }
128
 
129
+ .health-item {
 
130
  display: flex;
131
+ flex-direction: column;
132
+ gap: 0.25rem;
 
 
133
  }
134
 
135
+ .health-item .label {
136
+ font-size: 0.75rem;
137
+ color: var(--text-muted);
138
+ text-transform: uppercase;
139
+ letter-spacing: 0.05em;
 
 
140
  }
141
 
142
+ .health-item .value {
143
+ font-size: 1rem;
144
+ font-weight: 600;
145
  }
146
 
147
+ .health-item .value.success {
148
+ color: var(--success);
 
 
149
  }
150
 
151
+ .health-item .value.error {
152
+ color: var(--error);
 
153
  }
154
 
155
+ /* ═══════════════════════════════════════════
156
+ Tables
157
+ ═══════════════════════════════════════════ */
158
+
159
+ .table-scroll {
160
+ overflow-x: auto;
 
161
  }
162
 
163
+ .data-table {
164
+ width: 100%;
165
+ border-collapse: collapse;
166
+ }
167
+
168
+ .data-table th,
169
+ .data-table td {
170
+ padding: 0.75rem 1rem;
171
+ text-align: left;
172
  border-bottom: 1px solid var(--border);
 
 
 
173
  }
174
 
175
+ .data-table th {
176
+ background: var(--surface-light);
177
  font-weight: 600;
178
+ font-size: 0.75rem;
179
+ text-transform: uppercase;
180
+ letter-spacing: 0.05em;
181
+ color: var(--text-muted);
182
  }
183
 
184
+ .data-table tr:hover {
185
+ background: var(--surface-light);
186
  }
187
 
188
+ .data-table .code {
189
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
190
+ font-size: 0.85rem;
 
 
 
191
  }
192
 
193
+ .null {
194
+ color: var(--text-muted);
195
+ font-style: italic;
196
  }
197
 
198
+ /* ═══════════════════════════════════════════
199
+ Buttons
200
+ ═══════════════════════════════════════════ */
 
 
 
 
 
 
 
 
 
 
 
201
 
202
  .btn-primary {
203
  background: var(--primary);
204
  color: white;
205
+ border: none;
206
+ padding: 0.75rem 1.5rem;
207
+ border-radius: 8px;
208
+ cursor: pointer;
209
+ font-weight: 600;
210
+ transition: background 0.2s;
211
  }
212
 
213
  .btn-primary:hover {
 
215
  }
216
 
217
  .btn-secondary {
218
+ background: var(--surface-light);
219
+ color: var(--text);
220
+ border: 1px solid var(--border);
221
+ padding: 0.75rem 1.5rem;
222
+ border-radius: 8px;
223
+ cursor: pointer;
224
+ font-weight: 500;
225
+ text-decoration: none;
226
+ display: inline-block;
227
+ transition: background 0.2s;
228
  }
229
 
230
  .btn-secondary:hover {
231
+ background: var(--surface);
232
  }
233
 
234
+ .btn-small {
235
+ background: var(--surface-light);
236
+ color: var(--text);
237
+ border: 1px solid var(--border);
238
+ padding: 0.4rem 0.75rem;
239
+ border-radius: 6px;
240
+ cursor: pointer;
241
+ font-size: 0.85rem;
242
+ text-decoration: none;
243
+ display: inline-block;
 
 
244
  }
245
 
246
+ /* ═══════════════════════════════════════════
247
+ Forms
248
+ ═══════════════════════════════════════════ */
 
249
 
 
250
  .form-group {
251
+ margin-bottom: 1rem;
252
  }
253
 
254
  .form-group label {
255
  display: block;
256
  margin-bottom: 0.5rem;
257
  font-weight: 500;
258
+ color: var(--text-muted);
259
  }
260
 
261
  .form-group input,
262
+ .form-group textarea {
 
263
  width: 100%;
264
  padding: 0.75rem 1rem;
265
+ background: var(--background);
266
  border: 1px solid var(--border);
267
+ border-radius: 8px;
268
+ color: var(--text);
269
+ font-size: 1rem;
270
  font-family: inherit;
 
271
  }
272
 
273
  .form-group input:focus,
274
+ .form-group textarea:focus {
 
275
  outline: none;
276
  border-color: var(--primary);
277
  }
278
 
279
+ .form-group textarea {
280
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
 
 
281
  }
282
 
283
+ /* ═══════════════════════════════════════════
284
+ Login Page
285
+ ═══════════════════════════════════════════ */
 
 
 
286
 
287
+ .login-container {
288
+ display: flex;
289
+ justify-content: center;
290
+ align-items: center;
291
+ min-height: 80vh;
292
  }
293
 
294
+ .login-card {
295
+ background: var(--surface);
296
+ padding: 2.5rem;
297
+ border-radius: 16px;
298
+ width: 100%;
299
+ max-width: 400px;
300
+ border: 1px solid var(--border);
301
  }
302
 
303
+ .login-card h1 {
304
+ text-align: center;
305
+ margin-bottom: 0.5rem;
306
  }
307
 
308
+ .login-subtitle {
309
+ text-align: center;
310
+ color: var(--text-muted);
311
+ margin-bottom: 2rem;
312
  }
313
 
314
+ .error-message {
315
+ background: rgba(239, 68, 68, 0.1);
316
+ border: 1px solid var(--error);
317
+ color: var(--error);
318
+ padding: 0.75rem 1rem;
319
+ border-radius: 8px;
320
+ margin-bottom: 1rem;
321
  }
322
 
323
+ /* ═══════════════════════════════════════════
324
+ Pagination
325
+ ═══════════════════════════════════════════ */
 
 
326
 
327
+ .pagination {
328
+ display: flex;
329
+ align-items: center;
330
+ gap: 1rem;
331
+ margin-top: 1rem;
332
+ padding-top: 1rem;
333
+ border-top: 1px solid var(--border);
334
  }
335
 
336
+ /* ═══════════════════════════════════════════
337
+ Status Colors
338
+ ═══════════════════════════════════════════ */
 
339
 
340
  .status-success {
341
  color: var(--success);
 
345
  color: var(--error);
346
  }
347
 
348
+ /* ═══════════════════════════════════════════
349
+ Error Card
350
+ ═══════════════════════════════════════════ */
351
+
352
+ .error-card {
353
+ border-color: var(--error);
 
 
354
  }
355
 
356
+ .error-text {
357
+ background: var(--background);
358
+ padding: 1rem;
359
+ border-radius: 8px;
360
+ overflow-x: auto;
361
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
362
+ font-size: 0.85rem;
363
  color: var(--error);
 
364
  }
365
 
366
+ /* ═══════════════════════════════════════════
367
+ Empty State
368
+ ═══════════════════════════════════════════ */
 
 
 
 
 
 
369
 
370
+ .empty-state {
371
+ text-align: center;
372
+ color: var(--text-muted);
373
+ padding: 2rem;
374
  }
375
 
376
+ /* ═══════════════════════════════════════════
377
+ Quick Queries
378
+ ═══════════════════════════════════════════ */
 
379
 
380
+ .quick-queries {
 
381
  display: flex;
382
+ flex-wrap: wrap;
383
+ gap: 0.75rem;
 
 
384
  }
385
 
386
+ /* ═══════════════════════════════════════════
387
+ Backup Actions
388
+ ═══════════════════════════════════════════ */
 
389
 
 
390
  .backup-actions {
391
  display: flex;
 
392
  flex-wrap: wrap;
393
+ gap: 0.75rem;
394
  }
395
 
396
+ /* ═══════════════════════════════════════════
397
+ Info Text
398
+ ═══════════════════════════════════════════ */
399
+
400
+ .info-text {
401
+ color: var(--text-muted);
402
+ margin-bottom: 1rem;
403
  }
404
 
405
+ .info-text code {
406
+ background: var(--background);
407
+ padding: 0.2rem 0.5rem;
408
+ border-radius: 4px;
409
+ font-size: 0.85rem;
410
+ }
411
+
412
+ /* ═══════════════════════════════════════════
413
+ Strategy Info
414
+ ═══════════════════════════════════════════ */
415
+
416
+ .strategy-info {
417
+ display: grid;
418
+ gap: 1rem;
419
  }
420
 
421
  .strategy-layer {
422
+ background: var(--background);
423
+ padding: 1rem;
424
  border-radius: 8px;
425
+ border-left: 3px solid var(--primary);
426
  }
427
 
428
+ .strategy-layer h3 {
429
+ font-size: 0.9rem;
430
+ color: var(--primary);
 
431
  margin-bottom: 0.5rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  }
433
 
434
  .strategy-layer p {
435
+ font-size: 0.85rem;
436
  color: var(--text-muted);
437
+ line-height: 1.5;
 
 
 
 
 
 
438
  }
439
 
440
+ /* ═══════════════════════════════════════════
441
+ Footer
442
+ ═══════════════════════════════════════════ */
443
 
444
+ .footer {
445
+ text-align: center;
446
+ padding: 1.5rem;
447
  color: var(--text-muted);
448
+ font-size: 0.85rem;
449
+ border-top: 1px solid var(--border);
450
  }
451
 
452
+ /* ═══════════════════════════════════════════
453
+ Responsive
454
+ ═══════════════════════════════════════════ */
 
 
 
 
 
 
 
 
 
 
 
455
 
 
456
  @media (max-width: 768px) {
457
+ .navbar {
458
+ flex-direction: column;
459
+ gap: 1rem;
 
460
  }
461
 
462
+ .nav-links a {
463
  margin-left: 0;
464
+ margin-right: 1rem;
465
+ }
466
+
467
+ .container {
468
+ padding: 1rem;
469
  }
470
 
471
+ .page-header {
472
  flex-direction: column;
473
+ gap: 1rem;
474
+ align-items: flex-start;
475
  }
476
 
477
  .health-grid {