feat: Admin-UI Tab-Navigation + Backups-Endpoint (#90)
- Tab-Leiste mit drei Tabs: User, Inventare, Backups - Aktiver Tab visuell hervorgehoben, nur aktiver Inhalt sichtbar - Default-Tab: User - Neuer GET /api/admin/backups Endpoint (JWT-geschützt) → listet .sql.gz-Dateien aus /backups (name, sizeBytes, createdAt) → absteigend nach Datum sortiert - Backups-Tab: Tabelle mit Dateiname, Größe (human-readable), Erstellt → Refresh-Button, Hinweis bei leerem Verzeichnis - docker-compose.yml: backup_data:/backups:ro Mount im Server-Container - 4 neue Tests (Admin-Backups: 200, 403, 401, Dateiliste sortiert)
This commit is contained in:
parent
9004baede1
commit
0fee89ec32
7 changed files with 205 additions and 5 deletions
|
|
@ -23,6 +23,8 @@ services:
|
|||
- KRISENVORRAT_DB_URL=jdbc:postgresql://db:5432/krisenvorrat
|
||||
- KRISENVORRAT_DB_USER=krisenvorrat
|
||||
- KRISENVORRAT_DB_PASSWORD=krisenvorrat
|
||||
volumes:
|
||||
- backup_data:/backups:ro
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import de.krisenvorrat.server.plugins.configureSerialization
|
|||
import de.krisenvorrat.server.plugins.configureStatusPages
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.netty.*
|
||||
import java.io.File
|
||||
|
||||
private const val DEFAULT_JWT_SECRET = "change-me-to-a-secure-jwt-secret-at-least-32-chars"
|
||||
|
||||
|
|
@ -25,11 +26,11 @@ internal fun Application.module() {
|
|||
configurePlugins()
|
||||
}
|
||||
|
||||
internal fun Application.configurePlugins() {
|
||||
internal fun Application.configurePlugins(backupDir: File = File("/backups")) {
|
||||
configureSerialization()
|
||||
configureStatusPages()
|
||||
configureCallLogging()
|
||||
configureRateLimiting()
|
||||
configureAuthentication()
|
||||
configureRouting()
|
||||
configureRouting(backupDir = backupDir)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package de.krisenvorrat.server.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class BackupFileInfo(
|
||||
val name: String,
|
||||
val sizeBytes: Long,
|
||||
val createdAt: String
|
||||
)
|
||||
|
|
@ -33,7 +33,8 @@ internal fun Application.configureRouting(
|
|||
userRepository: UserRepository = UserRepository(),
|
||||
messageRepository: MessageRepository = MessageRepository(),
|
||||
jwtService: JwtService = JwtService(environment.config),
|
||||
wsManager: WebSocketManager = WebSocketManager()
|
||||
wsManager: WebSocketManager = WebSocketManager(),
|
||||
backupDir: File = File("/backups")
|
||||
) {
|
||||
val config = environment.config
|
||||
val appVersionCode = config.propertyOrNull("krisenvorrat.appVersionCode")
|
||||
|
|
@ -79,7 +80,7 @@ internal fun Application.configureRouting(
|
|||
inventoryRoutes(inventoryRepository, wsManager)
|
||||
}
|
||||
rateLimit(RATE_LIMIT_ADMIN) {
|
||||
adminRoutes(userRepository, inventoryRepository)
|
||||
adminRoutes(userRepository, inventoryRepository, backupDir)
|
||||
}
|
||||
rateLimit(RATE_LIMIT_MESSAGES) {
|
||||
messageRoutes(messageRepository, userRepository, wsManager)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package de.krisenvorrat.server.routes
|
||||
|
||||
import de.krisenvorrat.server.model.AssignInventoryRequest
|
||||
import de.krisenvorrat.server.model.BackupFileInfo
|
||||
import de.krisenvorrat.server.model.CreateUserRequest
|
||||
import de.krisenvorrat.server.model.ErrorResponse
|
||||
import de.krisenvorrat.server.model.UpdatePasswordRequest
|
||||
|
|
@ -13,9 +14,17 @@ import io.ktor.server.request.*
|
|||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
internal fun Route.adminRoutes(userRepository: UserRepository, inventoryRepository: InventoryRepository) {
|
||||
internal fun Route.adminRoutes(
|
||||
userRepository: UserRepository,
|
||||
inventoryRepository: InventoryRepository,
|
||||
backupDir: File = File("/backups")
|
||||
) {
|
||||
route("/api/admin/users") {
|
||||
get {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
|
|
@ -186,5 +195,31 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito
|
|||
}
|
||||
call.respond(HttpStatusCode.OK, inventoryRepository.getStatsPerInventory())
|
||||
}
|
||||
|
||||
get("/api/admin/backups") {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
||||
if (!principal.isAdmin) {
|
||||
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
|
||||
return@get
|
||||
}
|
||||
val files = if (backupDir.isDirectory) {
|
||||
backupDir.listFiles { f -> f.isFile && f.name.endsWith(".sql.gz") }
|
||||
?.map { f ->
|
||||
BackupFileInfo(
|
||||
name = f.name,
|
||||
sizeBytes = f.length(),
|
||||
createdAt = Instant.ofEpochMilli(f.lastModified())
|
||||
.atOffset(ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
)
|
||||
}
|
||||
?.sortedByDescending { it.createdAt }
|
||||
?: emptyList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
call.respond(HttpStatusCode.OK, files)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,13 @@
|
|||
.paging-bar button { padding: 4px 10px; font-size: .85rem; }
|
||||
.paging-bar span { font-size: .85rem; color: #A89070; }
|
||||
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #6B5B3E; border-top: 1px solid #4A3F2F; }
|
||||
/* Tab navigation */
|
||||
.tab-bar { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid #4A3F2F; }
|
||||
.tab-btn { background: transparent; border: none; border-bottom: 3px solid transparent; color: #6B5B3E; padding: 12px 24px; cursor: pointer; font-family: 'Share Tech Mono', 'Courier New', monospace; text-transform: uppercase; font-size: .95rem; letter-spacing: .05em; transition: color .2s, border-color .2s; }
|
||||
.tab-btn:hover { color: #E8D5B0; }
|
||||
.tab-btn.active { color: #E8D5B0; border-bottom-color: #C1440E; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: #1A1815; }
|
||||
|
|
@ -111,6 +118,16 @@
|
|||
</section>
|
||||
|
||||
<div id="admin-section" style="display:none">
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="tab-users" onclick="switchTab('tab-users')">User</button>
|
||||
<button class="tab-btn" data-tab="tab-inventories" onclick="switchTab('tab-inventories')">Inventare</button>
|
||||
<button class="tab-btn" data-tab="tab-backups" onclick="switchTab('tab-backups')">Backups</button>
|
||||
</nav>
|
||||
|
||||
<!-- Tab: User -->
|
||||
<div id="tab-users" class="tab-content active">
|
||||
|
||||
<!-- Stats Dashboard -->
|
||||
<section class="card">
|
||||
<h2>Statistiken</h2>
|
||||
|
|
@ -166,6 +183,11 @@
|
|||
</table>
|
||||
</section>
|
||||
|
||||
</div><!-- /tab-users -->
|
||||
|
||||
<!-- Tab: Inventare -->
|
||||
<div id="tab-inventories" class="tab-content">
|
||||
|
||||
<!-- Inventory Overview -->
|
||||
<section class="card">
|
||||
<h2>Inventar-Übersicht</h2>
|
||||
|
|
@ -202,6 +224,23 @@
|
|||
<button class="secondary" onclick="invPage++; renderInvTable()" id="inv-next">Weiter ›</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /tab-inventories -->
|
||||
|
||||
<!-- Tab: Backups -->
|
||||
<div id="tab-backups" class="tab-content">
|
||||
<section class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<h2>Backups</h2>
|
||||
<button class="secondary" onclick="loadBackups()">↻ Aktualisieren</button>
|
||||
</div>
|
||||
<table id="backups-table">
|
||||
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th></tr></thead>
|
||||
<tbody id="backups-body"><tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div><!-- /tab-backups -->
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
@ -634,6 +673,56 @@
|
|||
}
|
||||
|
||||
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
|
||||
|
||||
// ── Tab Navigation ───────────────────────────────────────────────────────
|
||||
|
||||
let backupsLoaded = false;
|
||||
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId));
|
||||
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.toggle('active', tc.id === tabId));
|
||||
if (tabId === 'tab-backups' && !backupsLoaded) {
|
||||
loadBackups();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backups ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadBackups() {
|
||||
const tbody = document.getElementById('backups-body');
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Wird geladen…</td></tr>';
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups', { headers: { 'Authorization': 'Bearer ' + accessToken } });
|
||||
if (!res.ok) { tbody.innerHTML = '<tr><td colspan="3" class="error">Fehler beim Laden</td></tr>'; return; }
|
||||
const backups = await res.json();
|
||||
backupsLoaded = true;
|
||||
if (!backups.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:#6B5B3E">Keine Backups vorhanden</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = '';
|
||||
backups.forEach(b => {
|
||||
const tr = document.createElement('tr');
|
||||
const tdName = document.createElement('td'); tdName.textContent = b.name;
|
||||
const tdSize = document.createElement('td'); tdSize.textContent = formatBytes(b.sizeBytes);
|
||||
const tdDate = document.createElement('td');
|
||||
const d = new Date(b.createdAt);
|
||||
tdDate.textContent = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
tr.append(tdName, tdSize, tdDate);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="error">Verbindungsfehler</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const val = bytes / Math.pow(1024, i);
|
||||
return val.toFixed(i === 0 ? 0 : 1).replace('.', ',') + ' ' + units[i];
|
||||
}
|
||||
</script>
|
||||
<footer>Krisenvorrat Server v0.2 · © 2026 faenocasul</footer>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ import io.ktor.server.config.*
|
|||
import io.ktor.server.testing.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class AdminTest {
|
||||
|
||||
|
|
@ -150,4 +154,62 @@ class AdminTest {
|
|||
}
|
||||
assertEquals(HttpStatusCode.OK, updateResponse.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_listBackups_asAdmin_returns200() = testApp {
|
||||
val response = client.get("/api/admin/backups") { bearerAuth(adminToken) }
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val body = json.decodeFromString<JsonArray>(response.bodyAsText())
|
||||
// Default /backups dir does not exist in tests → empty list
|
||||
assertEquals(0, body.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_listBackups_asNonAdmin_returns403() = testApp {
|
||||
val response = client.get("/api/admin/backups") { bearerAuth(userToken) }
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_listBackups_noToken_returns401() = testApp {
|
||||
val response = client.get("/api/admin/backups")
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_listBackups_withFiles_returnsSortedList() {
|
||||
val tempDir = File.createTempFile("backup-test", "").also { it.delete(); it.mkdirs() }
|
||||
try {
|
||||
// Create test backup files
|
||||
val file1 = File(tempDir, "backup-2026-01-01.sql.gz").apply { writeText("dummy1") }
|
||||
file1.setLastModified(1735689600000) // 2025-01-01
|
||||
val file2 = File(tempDir, "backup-2026-05-15.sql.gz").apply { writeText("dummy2data") }
|
||||
file2.setLastModified(1747267200000) // 2025-05-15
|
||||
// Non-.sql.gz file should be ignored
|
||||
File(tempDir, "readme.txt").apply { writeText("ignored") }
|
||||
|
||||
testApplication {
|
||||
environment {
|
||||
config = MapApplicationConfig(*testMapConfig().toTypedArray())
|
||||
}
|
||||
application {
|
||||
DatabaseFactory.init(
|
||||
jdbcUrl = "jdbc:h2:mem:admin_backup_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
|
||||
driver = "org.h2.Driver",
|
||||
adminPassword = "test-admin-pw"
|
||||
)
|
||||
configurePlugins(backupDir = tempDir)
|
||||
}
|
||||
val response = client.get("/api/admin/backups") { bearerAuth(adminToken) }
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val backups = json.decodeFromString<JsonArray>(response.bodyAsText())
|
||||
assertEquals(2, backups.size)
|
||||
// Sorted descending by date → file2 first
|
||||
assertEquals("backup-2026-05-15.sql.gz", backups[0].jsonObject["name"]?.jsonPrimitive?.content)
|
||||
assertEquals("backup-2026-01-01.sql.gz", backups[1].jsonObject["name"]?.jsonPrimitive?.content)
|
||||
}
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue