Files
MBMNET-APP/source/logview.c
T

543 lines
21 KiB
C

// Werkzeuge > Logs: Log-Zeilen aller MBMNET-Server, wie auf der Log-Webseite (MBMNET-Tools/logserver).
//
// Der Log-Server ist nur übers Accounting erreichbar (/mbmnet/logs/..., logsproxy.js), und zwar nur über
// HTTPS mit der MBMNET-CA (dieselbe wie für Miiverse): Das Log-Passwort geht nie im Klartext übers Netz.
// Oben die Zeilen (neueste unten, Steuerkreuz blättert, ganz oben werden ältere nachgeladen), unten die
// Quellen zum Filtern. Ist man ganz unten, lädt die Ansicht alle 5 Sekunden neu (wie "live" im Browser).
#include <3ds.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include "logview.h"
#include "https.h"
#include "ui.h"
#define TOKEN_FILE "sdmc:/3ds/mbmnet/logs-token"
#define MAX_LINES 600
#define MAX_SOURCES 48
#define ROW_H 13
#define ROWS 16
#define SRC_ROW_H 24
#define SRC_ROWS 7
#define RESP_MAX (192 * 1024)
typedef struct {
u32 id;
u64 t; // ms seit 1970 (UTC, vom Server)
char level; // 'i', 'w', 'e'
char src[28]; // Quelle (ohne Host)
char msg[404];
} Line;
typedef struct {
char key[80], name[32], host[32];
bool online;
u32 errors, warns;
} Source;
typedef enum { LV_NEED_PASSWORD, LV_LOADING, LV_READY, LV_ERROR } LvState;
enum { JOB_NONE, JOB_LOGIN, JOB_RELOAD, JOB_OLDER };
// --- geteilt mit dem Lade-Thread (lock) ---
static LightLock lock;
static LightEvent wake;
static Thread thread;
static volatile bool quit, busy;
static volatile int job;
static char server[64], token[72], password[72];
static Line *lines;
static int lineCount;
static Source sources[MAX_SOURCES];
static int sourceCount;
static volatile LvState state = LV_LOADING;
static char status[128];
static s64 timeOffset; // Sekunden: Konsolenuhr - Serveruhr (für die Uhrzeit der Zeilen)
static u64 lastLoad; // osGetTime() des letzten Ladens
// --- nur Hauptthread ---
static int cursor; // gewählte Zeile
static int top; // erste sichtbare Zeile
static int srcSel; // 0 = alle, sonst sources[srcSel - 1]
static int srcTop;
static bool errorsOnly, detail, following = true;
static char filterKey[80]; // gewählte Quelle (key), leer = alle
// --- HTTP ----------------------------------------------------------------------------
static int parse_month(const char *m)
{
static const char *names[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
for (int i = 0; i < 12; i++) if (!strncmp(m, names[i], 3)) return i + 1;
return 0;
}
// Tage seit 1970-01-01 (proleptischer gregorianischer Kalender)
static s64 days_from_civil(int y, int m, int d)
{
y -= m <= 2;
s64 era = (y >= 0 ? y : y - 399) / 400;
s64 yoe = y - era * 400;
s64 doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
s64 doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + doe - 719468;
}
// Server-Uhrzeit aus der Date-Kopfzeile ("Mon, 26 Oct 2026 12:34:56 GMT") -> Abstand zur Konsolenuhr
static void sync_clock(const char *date)
{
int d, y, hh, mm, ss;
char mon[4] = "";
if (sscanf(date, "%*3s, %d %3s %d %d:%d:%d", &d, mon, &y, &hh, &mm, &ss) != 6) return;
int m = parse_month(mon);
if (!m) return;
s64 serverNow = days_from_civil(y, m, d) * 86400 + hh * 3600 + mm * 60 + ss;
timeOffset = (s64)time(NULL) - serverNow;
}
// HTTPS-Anfrage ans Accounting (https.c, MBMNET-CA). Status 0 = keine Verbindung (status bekommt den Grund).
static u32 request(const char *path, const char *form, char *out, u32 cap, u32 *outLen)
{
char date[64];
Result rc = 0;
u32 code = https_request(server, path, form, token[0] ? "X-Logs-Token" : NULL, token, out, cap, outLen, date, sizeof(date), &rc);
if (date[0]) sync_clock(date);
if (!code) snprintf(status, sizeof(status), rc == -1 ? "MBMNET-Zertifikat fehlt (Server nicht erreichbar?)"
: "Keine Verbindung (%08lX)", (unsigned long)rc);
return code;
}
static void url_encode(char *out, size_t len, const char *in)
{
static const char hex[] = "0123456789ABCDEF";
size_t n = 0;
for (; *in && n + 4 < len; in++) {
unsigned char c = *in;
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') out[n++] = c;
else { out[n++] = '%'; out[n++] = hex[c >> 4]; out[n++] = hex[c & 15]; }
}
out[n] = 0;
}
static void save_token(const char *t)
{
mkdir("sdmc:/3ds/mbmnet", 0777);
FILE *f = fopen(TOKEN_FILE, "w");
if (f) { fputs(t, f); fclose(f); }
}
static void load_token(void)
{
token[0] = 0;
FILE *f = fopen(TOKEN_FILE, "r");
if (!f) return;
if (fgets(token, sizeof(token), f)) token[strcspn(token, "\r\n")] = 0;
fclose(f);
if (strlen(token) != 64) token[0] = 0;
}
static void forget_token(void)
{
token[0] = 0;
remove(TOKEN_FILE);
}
// Zeile mit Tabs zerlegen
static int split(char *line, char **f, int max)
{
int n = 0;
while (n < max) {
f[n++] = line;
char *tab = strchr(line, '\t');
if (!tab) break;
*tab = 0;
line = tab + 1;
}
return n;
}
static void parse_line(Line *l, char **f)
{
l->id = strtoul(f[0], NULL, 10);
l->t = strtoull(f[1], NULL, 10);
l->level = f[2][0] == 'e' ? 'e' : f[2][0] == 'w' ? 'w' : 'i';
char *bar = strchr(f[3], '|'); // key = "quelle|host"
if (bar) *bar = 0;
snprintf(l->src, sizeof(l->src), "%s", f[3]);
snprintf(l->msg, sizeof(l->msg), "%s", f[4]);
}
// --- Lade-Thread ---------------------------------------------------------------------
static bool load_sources(char *buf)
{
u32 len;
u32 code = request("/mbmnet/logs/sources", NULL, buf, RESP_MAX, &len);
if (code == 401) return false;
if (code != 200) { if (code) snprintf(status, sizeof(status), "Fehler beim Laden (HTTP %lu)", (unsigned long)code); return true; }
Source tmp[MAX_SOURCES];
int n = 0;
char *save = NULL, *f[8];
for (char *line = strtok_r(buf, "\n", &save); line && n < MAX_SOURCES; line = strtok_r(NULL, "\n", &save)) {
if (split(line, f, 8) < 6) continue;
Source *s = &tmp[n++];
snprintf(s->key, sizeof(s->key), "%s", f[0]);
snprintf(s->name, sizeof(s->name), "%s", f[1]);
snprintf(s->host, sizeof(s->host), "%s", f[2]);
s->online = f[3][0] == '1';
s->errors = strtoul(f[4], NULL, 10);
s->warns = strtoul(f[5], NULL, 10);
}
LightLock_Lock(&lock);
memcpy(sources, tmp, sizeof(Source) * n);
sourceCount = n;
LightLock_Unlock(&lock);
return true;
}
// older = vor der ältesten Zeile weiterlesen (sonst die neuesten). false = nicht angemeldet.
static bool load_lines(char *buf, bool older)
{
char path[200], key[96];
url_encode(key, sizeof(key), filterKey);
LightLock_Lock(&lock);
u32 before = older && lineCount ? lines[0].id : 0;
LightLock_Unlock(&lock);
snprintf(path, sizeof(path), "/mbmnet/logs/lines?limit=%d%s%s%s%s", older ? 100 : 200, key[0] ? "&source=" : "", key,
errorsOnly ? "&level=error" : "", "");
if (before) snprintf(path + strlen(path), sizeof(path) - strlen(path), "&before=%lu", (unsigned long)before);
u32 len;
u32 code = request(path, NULL, buf, RESP_MAX, &len);
if (code == 401) return false;
if (code != 200) { if (code) snprintf(status, sizeof(status), "Fehler beim Laden (HTTP %lu)", (unsigned long)code); return true; }
// Neue Zeilen zählen und einlesen
int n = 0;
for (char *p = buf; *p; p++) if (*p == '\n') n++;
if (len && buf[len - 1] != '\n') n++;
Line *fresh = n ? malloc(sizeof(Line) * n) : NULL;
int got = 0;
char *save = NULL, *f[6];
for (char *line = strtok_r(buf, "\n", &save); line && got < n; line = strtok_r(NULL, "\n", &save)) {
if (split(line, f, 6) < 5) continue;
parse_line(&fresh[got++], f);
}
LightLock_Lock(&lock);
if (older) {
// vorne anhängen, höchstens MAX_LINES (die neuesten fallen dann weg)
int keep = lineCount;
if (got + keep > MAX_LINES) keep = MAX_LINES - got;
if (keep < 0) keep = 0;
memmove(lines + got, lines, sizeof(Line) * keep);
memcpy(lines, fresh, sizeof(Line) * (got > MAX_LINES ? MAX_LINES : got));
lineCount = got + keep;
cursor += got;
top += got;
} else {
if (got > MAX_LINES) got = MAX_LINES;
memcpy(lines, fresh, sizeof(Line) * got);
lineCount = got;
}
LightLock_Unlock(&lock);
free(fresh);
snprintf(status, sizeof(status), "%s", "");
lastLoad = osGetTime();
return true;
}
static void worker(void *arg)
{
(void)arg;
char *buf = malloc(RESP_MAX);
while (!quit) {
LightEvent_Wait(&wake);
if (quit) break;
int j = job;
job = JOB_NONE;
if (j == JOB_NONE || !buf) continue;
busy = true;
if (j == JOB_LOGIN) {
// Passwort in der Kopfzeile X-Logs-Password (URL-kodiert, nur HTTPS). Als POST-Inhalt kam es nicht an:
// Der HTTP-Dienst des 3DS schickte 0 Bytes.
char enc[200];
url_encode(enc, sizeof(enc), password);
memset(password, 0, sizeof(password));
token[0] = 0;
u32 len;
char date[64];
Result hrc = 0;
u32 code = https_request(server, "/mbmnet/logs/login", "", "X-Logs-Password", enc, buf, 256, &len, date, sizeof(date), &hrc);
memset(enc, 0, sizeof(enc));
if (date[0]) sync_clock(date);
if (!code) snprintf(status, sizeof(status), "Keine Verbindung (%08lX)", (unsigned long)hrc);
if (code == 200 && len == 64) {
memcpy(token, buf, 64);
token[64] = 0;
save_token(token);
j = JOB_RELOAD;
} else {
if (code == 401) snprintf(status, sizeof(status), "Falsches Passwort.");
else if (code == 429) snprintf(status, sizeof(status), "Zu viele Versuche, 15 Minuten warten.");
else if (code) snprintf(status, sizeof(status), "Anmeldung fehlgeschlagen (HTTP %lu)", (unsigned long)code);
// code 0: status sagt schon, warum keine Verbindung
state = code == 401 ? LV_NEED_PASSWORD : LV_ERROR;
busy = false;
continue;
}
}
bool ok = j == JOB_OLDER ? load_lines(buf, true) : load_sources(buf) && load_lines(buf, false);
if (!ok) { forget_token(); state = LV_NEED_PASSWORD; snprintf(status, sizeof(status), "Bitte anmelden."); }
else state = LV_READY;
busy = false;
}
free(buf);
}
static void start_job(int j)
{
if (busy || job != JOB_NONE) return;
job = j;
if (state != LV_READY) state = LV_LOADING;
LightEvent_Signal(&wake);
}
void logview_open(const char *srv)
{
snprintf(server, sizeof(server), "%s", srv);
if (!lines) lines = calloc(MAX_LINES, sizeof(Line));
if (!thread) {
LightLock_Init(&lock);
LightEvent_Init(&wake, RESET_ONESHOT);
s32 prio = 0x30;
svcGetThreadPriority(&prio, CUR_THREAD_HANDLE);
thread = threadCreate(worker, NULL, 32 * 1024, prio + 1, -2, false);
}
detail = false;
following = true;
load_token();
status[0] = 0;
if (!token[0]) { state = LV_NEED_PASSWORD; return; }
state = LV_LOADING;
start_job(JOB_RELOAD);
}
bool logview_finish(void)
{
if (!thread) return true;
quit = true;
LightEvent_Signal(&wake);
if (R_FAILED(threadJoin(thread, 100ULL * 1000 * 1000))) return false;
threadFree(thread);
thread = NULL;
free(lines);
lines = NULL;
return true;
}
// --- Eingaben --------------------------------------------------------------------------
static u64 srcPendingAt; // Quelle mit dem Steuerkreuz gewechselt: kurz warten, dann laden (nicht bei jedem Schritt)
// Quelle wählen. now = sofort laden (L/R, Antippen), sonst nach einer kurzen Pause (Steuerkreuz)
static void select_source(int sel, bool now)
{
if (sel < 0) sel = sourceCount;
if (sel > sourceCount) sel = 0;
srcSel = sel;
snprintf(filterKey, sizeof(filterKey), "%s", sel ? sources[sel - 1].key : "");
if (srcSel < srcTop) srcTop = srcSel;
if (srcSel >= srcTop + SRC_ROWS) srcTop = srcSel - SRC_ROWS + 1;
following = true;
if (now) { srcPendingAt = 0; start_job(JOB_RELOAD); }
else srcPendingAt = osGetTime();
}
static bool ask_password(void)
{
SwkbdState kb;
char input[64] = "";
swkbdInit(&kb, SWKBD_TYPE_NORMAL, 2, sizeof(input) - 1);
swkbdSetPasswordMode(&kb, SWKBD_PASSWORD_HIDE_DELAY);
swkbdSetHintText(&kb, "Passwort der Log-Seite");
swkbdSetButton(&kb, SWKBD_BUTTON_LEFT, "Abbrechen", false);
swkbdSetButton(&kb, SWKBD_BUTTON_RIGHT, "Anmelden", true);
if (swkbdInputText(&kb, input, sizeof(input)) != SWKBD_BUTTON_RIGHT || !input[0]) return false;
snprintf(password, sizeof(password), "%s", input);
memset(input, 0, sizeof(input));
state = LV_LOADING;
snprintf(status, sizeof(status), "Melde an ...");
job = JOB_LOGIN;
LightEvent_Signal(&wake);
return true;
}
typedef struct { float x, y, w, h; } Rect;
// Knopfleiste unten: Live | Nur Fehler | Neu laden | Zurück
static const Rect btnLive = { 6, 204, 74, 30 }, btnFilter = { 84, 204, 76, 30 }, btnReload = { 164, 204, 74, 30 },
btnBack = { 242, 204, 72, 30 };
static bool in(Rect r, touchPosition tp) { return tp.px >= r.x && tp.px < r.x + r.w && tp.py >= r.y && tp.py < r.y + r.h; }
bool logview_update(u32 down, u32 repeat, u32 held)
{
(void)held;
if (state == LV_NEED_PASSWORD && !busy && job == JOB_NONE) {
if (!ask_password()) return false; // abgebrochen: zurück zu den Werkzeugen
}
touchPosition tp = { 0 };
if (down & KEY_TOUCH) hidTouchRead(&tp);
if (down & KEY_B || (down & KEY_TOUCH && in(btnBack, tp))) {
if (detail) detail = false;
else return false;
}
if (down & KEY_A) detail = !detail && lineCount > 0;
if (down & KEY_X || (down & KEY_TOUCH && in(btnFilter, tp))) { errorsOnly = !errorsOnly; following = true; start_job(JOB_RELOAD); }
// Live: ans Ende springen, neu laden und wieder mitlaufen (alle 5 Sekunden neu)
if (down & KEY_Y || (down & KEY_TOUCH && in(btnLive, tp))) { following = true; detail = false; start_job(JOB_RELOAD); }
if (down & KEY_TOUCH && in(btnReload, tp)) start_job(JOB_RELOAD);
if (down & KEY_R) select_source(srcSel + 1, true);
if (down & KEY_L) select_source(srcSel - 1, true);
// Steuerkreuz (unten): Quellen auf dem unteren Bildschirm
if (repeat & KEY_DDOWN) select_source(srcSel + 1, false);
if (repeat & KEY_DUP) select_source(srcSel - 1, false);
if (srcPendingAt && osGetTime() - srcPendingAt > 400 && !busy && job == JOB_NONE) { srcPendingAt = 0; start_job(JOB_RELOAD); }
if (down & KEY_TOUCH && tp.py >= 24 && tp.py < 24 + SRC_ROWS * SRC_ROW_H) {
int row = srcTop + (tp.py - 24) / SRC_ROW_H;
if (row <= sourceCount) select_source(row, true);
}
LightLock_Lock(&lock);
int count = lineCount;
// Schiebepad (oben): Zeilen auf dem oberen Bildschirm, links/rechts seitenweise
bool moved = false;
if (!detail) {
if (repeat & KEY_CPAD_UP) {
moved = true;
if (cursor > 0) cursor--;
else if (count && state == LV_READY) start_job(JOB_OLDER); // ganz oben: ältere nachladen
}
if (repeat & KEY_CPAD_DOWN) { cursor++; moved = true; }
if (repeat & KEY_CPAD_LEFT) { cursor -= ROWS; moved = true; }
if (repeat & KEY_CPAD_RIGHT) { cursor += ROWS; moved = true; }
}
// Live mitlaufen nur, solange man nicht selbst blättert (sonst sprang es sofort wieder nach unten)
if (following && !moved) cursor = count - 1;
if (cursor >= count) cursor = count - 1;
if (cursor < 0) cursor = 0;
following = count == 0 || cursor == count - 1;
if (cursor < top) top = cursor;
if (cursor >= top + ROWS) top = cursor - ROWS + 1;
if (top > count - ROWS) top = count - ROWS;
if (top < 0) top = 0;
LightLock_Unlock(&lock);
// Live: unten angekommen, alle 5 Sekunden neu laden
if (following && state == LV_READY && osGetTime() - lastLoad > 5000) start_job(JOB_RELOAD);
return true;
}
// --- Zeichnen ---------------------------------------------------------------------------
static u32 level_color(char level) { return level == 'e' ? COL_RED : level == 'w' ? COL_AMBER : COL_CARD_HI; }
void logview_draw_top(void)
{
C2D_DrawRectSolid(0, 0, 0, TOP_W, SCR_H, COL_BG);
C2D_DrawRectSolid(0, 0, 0.5f, TOP_W, 20, COL_BG2);
char head[96], line[160];
snprintf(head, sizeof(head), "Logs · %s%s", srcSel ? sources[srcSel - 1].name : "alle Quellen", errorsOnly ? " · nur Fehler" : "");
ui_text(head, 6, 3, 0.45f, COL_WHITE, 0);
const char *right = busy || state == LV_LOADING ? "lädt ..." : status[0] ? status : following ? "live" : "angehalten";
ui_fit(line, sizeof(line), right, 0.38f, 180);
ui_text(line, TOP_W - 6, 4, 0.38f, status[0] ? COL_AMBER : following ? COL_GREEN : COL_MUTED, C2D_AlignRight);
if (state == LV_NEED_PASSWORD) {
ui_text_wrap(status[0] ? status : "Anmeldung mit dem Passwort der Log-Seite.", 20, 100, 0.5f, COL_MUTED, TOP_W - 40);
return;
}
LightLock_Lock(&lock);
if (!lineCount) {
ui_text(state == LV_LOADING ? "Einen Moment ..." : "Keine Zeilen.", TOP_W / 2, 110, 0.5f, COL_MUTED, C2D_AlignCenter);
LightLock_Unlock(&lock);
return;
}
for (int r = 0; r < ROWS && top + r < lineCount; r++) {
const Line *l = &lines[top + r];
float y = 22 + r * ROW_H;
if (top + r == cursor) C2D_DrawRectSolid(0, y, 0.5f, TOP_W, ROW_H, COL_CARD);
time_t when = (time_t)(l->t / 1000 + timeOffset);
struct tm *tm = gmtime(&when);
char hhmm[12];
snprintf(hhmm, sizeof(hhmm), "%02d:%02d:%02d", tm ? tm->tm_hour : 0, tm ? tm->tm_min : 0, tm ? tm->tm_sec : 0);
ui_text(hhmm, 4, y + 1, 0.34f, COL_MUTED, 0);
C2D_DrawRectSolid(46, y + 1, 0.5f, 3, ROW_H - 2, level_color(l->level));
ui_fit(line, sizeof(line), l->src, 0.34f, 56);
ui_text(line, 53, y + 1, 0.34f, COL_MUTED, 0);
ui_fit(line, sizeof(line), l->msg, 0.36f, TOP_W - 116);
ui_text(line, 112, y + 1, 0.36f, l->level == 'e' ? C2D_Color32(0xFF, 0x8A, 0x8A, 0xFF) : COL_WHITE, 0);
}
if (detail && cursor < lineCount) {
const Line *l = &lines[cursor];
ui_round_rect(14, 30, TOP_W - 28, 200, 10, COL_BG2);
C2D_DrawRectSolid(14, 40, 0.5f, 4, 180, level_color(l->level));
snprintf(head, sizeof(head), "%s · Zeile %lu", l->src, (unsigned long)l->id);
ui_text(head, 26, 36, 0.42f, COL_MUTED, 0);
ui_text_wrap(l->msg, 26, 56, 0.42f, COL_WHITE, TOP_W - 60);
}
LightLock_Unlock(&lock);
}
static void button(Rect r, const char *label, bool on)
{
ui_round_rect(r.x, r.y, r.w, r.h, 10, on ? COL_RED : COL_CARD);
ui_text(label, r.x + r.w / 2, r.y + 8, 0.4f, COL_WHITE, C2D_AlignCenter);
}
static void live_button(void)
{
Rect r = btnLive;
ui_round_rect(r.x, r.y, r.w, r.h, 10, following ? C2D_Color32(0x15, 0x80, 0x3D, 0xFF) : COL_CARD);
C2D_DrawCircleSolid(r.x + 14, r.y + r.h / 2, 0.5f, 4, following ? COL_WHITE : COL_MUTED);
ui_text("Live", r.x + r.w / 2 + 6, r.y + 8, 0.4f, following ? COL_WHITE : COL_MUTED, C2D_AlignCenter);
}
void logview_draw_bottom(void)
{
C2D_DrawRectSolid(0, 0, 0, BOT_W, SCR_H, COL_BG);
C2D_DrawRectSolid(0, 0, 0.5f, BOT_W, 22, COL_BG2);
ui_text("Quellen", 6, 3, 0.45f, COL_WHITE, 0);
ui_text("Steuerkreuz/L/R wechseln · Y Live · A Details", BOT_W - 6, 5, 0.32f, COL_MUTED, C2D_AlignRight);
char line[96];
LightLock_Lock(&lock);
for (int r = 0; r < SRC_ROWS && srcTop + r <= sourceCount; r++) {
int i = srcTop + r;
float y = 24 + r * SRC_ROW_H;
if (i == srcSel) {
C2D_DrawRectSolid(0, y, 0.5f, BOT_W, SRC_ROW_H, COL_CARD_HI);
C2D_DrawRectSolid(0, y + 4, 0.5f, 3, SRC_ROW_H - 8, COL_RED);
}
if (i == 0) { ui_text("Alle Quellen", 14, y + 5, 0.45f, COL_WHITE, 0); continue; }
const Source *s = &sources[i - 1];
C2D_DrawCircleSolid(14, y + SRC_ROW_H / 2, 0.5f, 4, s->online ? COL_GREEN : COL_MUTED);
snprintf(line, sizeof(line), "%s · %s", s->name, s->host);
char fitted[96];
ui_fit(fitted, sizeof(fitted), line, 0.42f, s->errors ? 200 : 270);
ui_text(fitted, 24, y + 5, 0.42f, COL_WHITE, 0);
if (s->errors) {
snprintf(line, sizeof(line), "%lu Fehler", (unsigned long)s->errors);
float w = ui_text_width(line, 0.36f) + 12;
ui_round_rect(BOT_W - 8 - w, y + 4, w, 16, 8, COL_RED);
ui_text(line, BOT_W - 8 - w / 2, y + 5, 0.36f, COL_WHITE, C2D_AlignCenter);
}
}
LightLock_Unlock(&lock);
live_button();
button(btnFilter, errorsOnly ? "Alle" : "Nur Fehler", errorsOnly);
button(btnReload, "Neu laden", false);
button(btnBack, detail ? "Schließen" : "Zurück", false);
}