From 560d1c9a16a3bcbf8234bf297ec36e8a6e7bf453 Mon Sep 17 00:00:00 2001 From: Jesper Jensen Date: Fri, 20 Feb 2026 23:42:09 +0100 Subject: Fancy new UI --- src/mytime.c | 57 +++++ src/mytime.h | 9 + src/web.c | 748 +++++++++++++++++++++++++++++++++++++++++++++++------------ src/web.h | 24 +- 4 files changed, 686 insertions(+), 152 deletions(-) (limited to 'src') diff --git a/src/mytime.c b/src/mytime.c index ffb7323..e71259b 100644 --- a/src/mytime.c +++ b/src/mytime.c @@ -1,6 +1,8 @@ #include "mytime.h" #include +#include +#include #include #include @@ -36,3 +38,58 @@ void progress_time(uint64_t diff) { // Right now the diff is only seconds resolution global_time.tv_sec += diff; } + +struct timespec time_from_double(double t) { + double fraction; + double integral; + fraction = modf(t, &integral); + return (struct timespec) { + .tv_sec = integral, + .tv_nsec = fraction * 1000000000.0, + }; +} + +struct formatted_time format_time(struct timespec time) { + struct formatted_time res = {0}; + + struct tm tm; + gmtime_r(&time.tv_sec, &tm); + if(strftime(res.buf, 32, "%Y-%m-%d %H:%M:%S", &tm) >= 32) { + abort(); + } + + return res; +} + +struct formatted_time format_duration(struct timespec time) { + struct formatted_time res = {0}; + + int hours = time.tv_sec / 3600; + int mins = (time.tv_sec % 3600) / 60; + int secs = time.tv_sec % 60; + + if (hours > 0) { + snprintf(res.buf, 32, "%dh %02dm %02ds", hours, mins, secs); + } else if (mins > 0) { + snprintf(res.buf, 32, "%dm %02ds", mins, secs); + } else { + snprintf(res.buf, 32, "%ds", secs); + } + + + return res; +} + +struct timespec time_sub(const struct timespec *t1, const struct timespec *t2) { + assert(t1); + assert(t2); + struct timespec diff = { + .tv_sec = t1->tv_sec - t2->tv_sec, + .tv_nsec = t1->tv_nsec - t2->tv_nsec + }; + if (diff.tv_nsec < 0) { + diff.tv_nsec += 1000000000; + diff.tv_sec--; + } + return diff; +} diff --git a/src/mytime.h b/src/mytime.h index 48eba06..ce270ad 100644 --- a/src/mytime.h +++ b/src/mytime.h @@ -10,3 +10,12 @@ struct timespec get_current_time(); void enable_fake_time(); void progress_time(uint64_t diff); + +struct timespec time_from_double(double t); + +struct formatted_time { + char buf[32]; +}; +struct formatted_time format_time(struct timespec time); +struct formatted_time format_duration(struct timespec time); +struct timespec time_sub(const struct timespec *t1, const struct timespec *t2); diff --git a/src/web.c b/src/web.c index 3967af9..412f222 100644 --- a/src/web.c +++ b/src/web.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -514,7 +515,15 @@ int list_clients(struct Request *req, struct ListClientResult *result) { { sqlite3_stmt *stmt; - if(sqlite3_prepare_v2(conn, "SELECT c.id, c.name, c.secret FROM clients c WHERE c.id > ?1 ORDER BY c.id ASC LIMIT ?2", -1, &stmt, NULL) != SQLITE_OK) { + const char *sql = + "SELECT c.id, c.name, c.secret, unixepoch(b_success.completed,'subsec'), b_last.status " + "FROM clients c " + "LEFT JOIN backups b_success ON b_success.client = c.id " + " AND b_success.id = (SELECT id FROM backups WHERE client = c.id AND status = 'FINISHED' ORDER BY id DESC LIMIT 1) " + "LEFT JOIN backups b_last ON b_last.client = c.id " + " AND b_last.id = (SELECT id FROM backups WHERE client = c.id ORDER BY id DESC LIMIT 1) " + "WHERE c.id > ?1 ORDER BY c.id ASC LIMIT ?2"; + if(sqlite3_prepare_v2(conn, sql, -1, &stmt, NULL) != SQLITE_OK) { abort(); } if(sqlite3_bind_int64(stmt, 1, -1) != SQLITE_OK) { @@ -536,10 +545,27 @@ int list_clients(struct Request *req, struct ListClientResult *result) { int64_t clientId = sqlite3_column_int64(stmt, 0); const uint8_t *name = sqlite3_column_text(stmt, 1); const uint8_t *secret = sqlite3_column_text(stmt, 2); + const double last_backup = sqlite3_column_double(stmt, 3); + const uint8_t *backup_status = sqlite3_column_text(stmt, 4); result->clients[index].id = clientId; strncpy(result->clients[index].name, (char*)name, CLIENT_NAME_MAX); - strncpy(result->clients[index].secret, (char*)secret, CLIENT_SECRET_LEN); + strncpy(result->clients[index].secret, secret ? (char*)secret : "", CLIENT_SECRET_LEN); + result->clients[index].last_backup = time_from_double(last_backup); + + if (backup_status == NULL) { + strncpy(result->clients[index].status, "MISSING", BACKUP_STATUS_MAX); + result->clients[index].is_stale = 0; + } else if (strcmp((char*)backup_status, "RUNNING") == 0) { + strncpy(result->clients[index].status, "RUNNING", BACKUP_STATUS_MAX); + result->clients[index].is_stale = 0; + } else if (strcmp((char*)backup_status, "INTERRUPTED") == 0) { + strncpy(result->clients[index].status, "INTERRUPTED", BACKUP_STATUS_MAX); + result->clients[index].is_stale = 0; + } else { + strncpy(result->clients[index].status, "OK", BACKUP_STATUS_MAX); + result->clients[index].is_stale = 0; + } index++; } @@ -560,7 +586,7 @@ int list_clients(struct Request *req, struct ListClientResult *result) { return 0; } -int get_client_with_backups(struct Request *req, int64_t client_id, struct GetClientResult *result) { +int get_client(struct Request *req, int64_t client_id, struct GetClientOnlyResult *result) { sqlite3 *conn; int err; if((err = sqlite3_open(req->server->app->dbname, &conn)) != SQLITE_OK) { @@ -569,76 +595,81 @@ int get_client_with_backups(struct Request *req, int64_t client_id, struct GetCl result->found = 0; - // Query 1: Get client info - { - sqlite3_stmt *stmt; - if(sqlite3_prepare_v2(conn, "SELECT id, name, secret FROM clients WHERE id = ?1", -1, &stmt, NULL) != SQLITE_OK) { - abort(); - } - if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) { - abort(); - } + sqlite3_stmt *stmt; + if(sqlite3_prepare_v2(conn, "SELECT id, name, secret FROM clients WHERE id = ?1", -1, &stmt, NULL) != SQLITE_OK) { + abort(); + } + if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) { + abort(); + } - int ret = sqlite3_step(stmt); - if(ret == SQLITE_ROW) { - result->found = 1; - result->id = sqlite3_column_int64(stmt, 0); - const uint8_t *name = sqlite3_column_text(stmt, 1); - const uint8_t *secret = sqlite3_column_text(stmt, 2); - strncpy(result->name, (char*)name, CLIENT_NAME_MAX); - strncpy(result->secret, (char*)secret, CLIENT_SECRET_LEN); - } else if(ret != SQLITE_DONE) { - abort(); - } + int ret = sqlite3_step(stmt); + if(ret == SQLITE_ROW) { + result->found = 1; + result->id = sqlite3_column_int64(stmt, 0); + const uint8_t *name = sqlite3_column_text(stmt, 1); + const uint8_t *secret = sqlite3_column_text(stmt, 2); + strncpy(result->name, (char*)name, CLIENT_NAME_MAX); + strncpy(result->secret, (char*)secret, CLIENT_SECRET_LEN); + } else if(ret != SQLITE_DONE) { + abort(); + } - if(sqlite3_finalize(stmt) != SQLITE_OK) { - abort(); - } + if(sqlite3_finalize(stmt) != SQLITE_OK) { + abort(); } - if(!result->found) { - if(sqlite3_close(conn) != SQLITE_OK) { - abort(); - } - return 0; + if(sqlite3_close(conn) != SQLITE_OK) { + abort(); } + return 0; +} - // Query 2: Get backups for this client - { - sqlite3_stmt *stmt; - if(sqlite3_prepare_v2(conn, "SELECT id, status, started, completed FROM backups WHERE client = ?1 ORDER BY started DESC LIMIT 50", -1, &stmt, NULL) != SQLITE_OK) { - abort(); - } - if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) { - abort(); - } +int get_client_backups(struct Request *req, int64_t client_id, struct GetBackupsResult *result) { + sqlite3 *conn; + int err; + if((err = sqlite3_open(req->server->app->dbname, &conn)) != SQLITE_OK) { + abort(); + } - int ret; - int index = 0; - while((ret = sqlite3_step(stmt)) == SQLITE_ROW) { - if(index >= MAX_BACKUPS_DISPLAY) break; + uint16_t limit = result->backups_len; - result->backups[index].id = sqlite3_column_int64(stmt, 0); + sqlite3_stmt *stmt; + if(sqlite3_prepare_v2(conn, "SELECT id, status, unixepoch(started, 'subsec'), unixepoch(completed, 'subsec') FROM backups WHERE client = ?1 ORDER BY started DESC LIMIT ?2", -1, &stmt, NULL) != SQLITE_OK) { + abort(); + } + if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) { + abort(); + } + if(sqlite3_bind_int(stmt, 2, limit) != SQLITE_OK) { + abort(); + } - const uint8_t *status = sqlite3_column_text(stmt, 1); - const uint8_t *started = sqlite3_column_text(stmt, 2); - const uint8_t *completed = sqlite3_column_text(stmt, 3); + int ret; + int index = 0; + while((ret = sqlite3_step(stmt)) == SQLITE_ROW) { + if(index >= limit) break; - strncpy(result->backups[index].status, status ? (char*)status : "", BACKUP_STATUS_MAX); - strncpy(result->backups[index].started, started ? (char*)started : "", BACKUP_DATETIME_MAX); - strncpy(result->backups[index].completed, completed ? (char*)completed : "", BACKUP_DATETIME_MAX); + result->backups[index].id = sqlite3_column_int64(stmt, 0); - index++; - } - if(ret != SQLITE_DONE && ret != SQLITE_ROW) { - abort(); - } + const uint8_t *status = sqlite3_column_text(stmt, 1); + const double started = sqlite3_column_double(stmt, 2); + const double completed = sqlite3_column_double(stmt, 3); - result->backups_len = index; + strncpy(result->backups[index].status, status ? (char*)status : "", BACKUP_STATUS_MAX); + result->backups[index].started = time_from_double(started); + result->backups[index].completed = time_from_double(completed); - if(sqlite3_finalize(stmt) != SQLITE_OK) { - abort(); - } + index++; + } + if(ret != SQLITE_DONE && ret != SQLITE_ROW) { + abort(); + } + + result->backups_len = index; + + if(sqlite3_finalize(stmt) != SQLITE_OK) { + abort(); } if(sqlite3_close(conn) != SQLITE_OK) { @@ -647,6 +678,320 @@ int get_client_with_backups(struct Request *req, int64_t client_id, struct GetCl return 0; } +static void emit_stylesheet(FILE *f) { + fprintf(f, + "\n" + ); +} + +static void mask_secret(const char *secret, char *out, size_t out_len) { + size_t len = strlen(secret); + if (len >= 8 && out_len >= 13) { + snprintf(out, out_len, "%.4s....%.4s", secret, secret + len - 4); + } else if (len > 0) { + snprintf(out, out_len, "****"); + } else { + out[0] = '\0'; + } +} + +static const char *status_to_badge_class(const char *status) { + if (strcmp(status, "OK") == 0) return "ok"; + if (strcmp(status, "RUNNING") == 0) return "run"; + if (strcmp(status, "STALE") == 0) return "stale"; + if (strcmp(status, "INTERRUPTED") == 0) return "fail"; + if (strcmp(status, "FINISHED") == 0) return "ok"; + return "ok"; +} + +static const char *status_to_display(const char *status) { + if (strcmp(status, "OK") == 0) return "OK"; + if (strcmp(status, "RUNNING") == 0) return "Running"; + if (strcmp(status, "STALE") == 0) return "Stale"; + if (strcmp(status, "INTERRUPTED") == 0) return "Failed"; + if (strcmp(status, "FINISHED") == 0) return "Done"; + return status; +} + static char *render_clients_page(struct Request *request, size_t *out_len) { int ret; struct ListClientResult *result = malloc(sizeof(struct ListClientResult) + sizeof(struct ListClientResultClient) * 16); @@ -656,6 +1001,12 @@ static char *render_clients_page(struct Request *request, size_t *out_len) { return NULL; } + // Count stale clients + int stale_count = 0; + for (int i = 0; i < result->clients_len; i++) { + if (result->clients[i].is_stale) stale_count++; + } + char *buf = NULL; size_t buf_len = 0; FILE *f = open_memstream(&buf, &buf_len); @@ -666,44 +1017,73 @@ static char *render_clients_page(struct Request *request, size_t *out_len) { fprintf(f, "\n" - "\n" + "\n" "\n" - " Clients\n" + " \n" + " \n" + " BorgFlag - Clients\n"); + emit_stylesheet(f); + fprintf(f, "\n" "\n" - "

Clients

\n" - "
\n" - " \n" - "
\n" - " \n" - " \n"); + "
\n" + " BorgFlag\n" + " admin  · 
\n" + "
\n" + "
\n" + "

\n" + " Clients\n" + " %d registered", result->clients_len); + + if (stale_count > 0) { + fprintf(f, "  ·  %d stale", stale_count); + } + + fprintf(f, + "\n" + " \n" + "

\n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + " \n" + " \n" + "
\n" + " Name\n" + " Secret\n" + " Last Backup\n" + " Status\n" + " \n" + ); for (int i = 0; i < result->clients_len; i++) { + char masked_secret[16]; + mask_secret(result->clients[i].secret, masked_secret, sizeof(masked_secret)); + fprintf(f, - "
\n" - " \n" - " \n" - " \n" - " \n", + " \n" + " %s\n" + " %s\n" + " %s\n" + " %s\n" + " \n" + " \n", result->clients[i].id, + result->clients[i].is_stale ? " class=\"stale\"" : "", result->clients[i].name, - result->clients[i].secret, + masked_secret, + result->clients[i].last_backup.tv_sec > 0.0 ? format_time(result->clients[i].last_backup).buf : "-", + status_to_badge_class(result->clients[i].status), + status_to_display(result->clients[i].status), result->clients[i].id ); } fprintf(f, - "
NameSecretActions
%s%s\n" - "
\n" - " \n" - " \n" - "
\n" - "
\n" - "

Add New Client

\n" - "
\n" - " \n" - " \n" - "
\n" + " \n" + " \n" "\n" "\n"); @@ -714,95 +1094,165 @@ static char *render_clients_page(struct Request *request, size_t *out_len) { } static char *render_client_detail_page(struct Request *request, int64_t client_id, size_t *out_len) { - struct GetClientResult *result = malloc(sizeof(struct GetClientResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY); - int ret = get_client_with_backups(request, client_id, result); + struct GetClientOnlyResult client_result; + int ret = get_client(request, client_id, &client_result); if(ret != 0) { - free(result); return NULL; } - if(!result->found) { - free(result); + if(!client_result.found) { return NULL; } + struct GetBackupsResult *backups_result = malloc(sizeof(struct GetBackupsResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY); + backups_result->backups_len = MAX_BACKUPS_DISPLAY; + ret = get_client_backups(request, client_id, backups_result); + if(ret != 0) { + free(backups_result); + return NULL; + } + + // Determine status and staleness + int is_stale = 0; + const char *current_status = "OK"; + if (backups_result->backups_len > 0) { + const char *last_status = backups_result->backups[0].status; + if (strcmp(last_status, "RUNNING") == 0) { + current_status = "RUNNING"; + } else if (strcmp(last_status, "INTERRUPTED") == 0) { + current_status = "INTERRUPTED"; + } + } + char *buf = NULL; size_t buf_len = 0; FILE *f = open_memstream(&buf, &buf_len); if (!f) { - free(result); + free(backups_result); return NULL; } fprintf(f, "\n" - "\n" + "\n" "\n" - " Client: %s\n" + " \n" + " \n" + " BorgFlag - %s\n", client_result.name); + emit_stylesheet(f); + fprintf(f, + "\n" "\n" "\n" - "

Clients > %s

\n" - "

Client: %s

\n" - "

Details

\n" - " \n" - " \n" - " \n" - " \n" - "
ID%ld
Name%s
Secret%s
\n", - result->name, - result->name, - result->name, - result->id, - result->name, - result->secret + "
\n" + " BorgFlag\n" + " admin  · 
\n" + "
\n" + "
\n" + " ← Back to clients\n" + " \n" + "

%s

\n" + "

%d backups  ·  %s

\n", + is_stale ? " class=\"stale\"" : "", + client_result.name, + backups_result->backups_len, + status_to_badge_class(current_status), + status_to_display(current_status) ); + if (is_stale) { + fprintf(f, + "

Warning: Last backup was more than 24 hours ago. Expected every 24 hours.

\n" + ); + } + fprintf(f, - "

Submit Report

\n" - "

Use this curl command to submit a backup report:

\n" - "
curl -X POST http://%s/api/report \\\n"
-		"  -H \"Content-Type: application/json\" \\\n"
-		"  -d '{\"client\": \"%s\", \"secret\": \"%s\", \"event\": \"begin_backup\"}'
\n" - "

Replace begin_backup with end_backup to mark completion.

\n", + " Secret: %s\n" + " \n" + "
\n" + " API Snippets\n" + "
\n" + "
\n" + "

Start backup

\n" + "
curl -X POST http://%s/api/report \\\n"
+		"     -H \"Content-Type: application/json\" \\\n"
+		"     -d '{\n"
+		"           \"client\": \"%s\",\n"
+		"           \"secret\": \"%s\",\n"
+		"           \"event\":  \"begin_backup\"\n"
+		"         }'
\n" + "
\n" + "
\n" + "

End backup

\n" + "
curl -X POST http://%s/api/report \\\n"
+		"     -H \"Content-Type: application/json\" \\\n"
+		"     -d '{\n"
+		"           \"client\": \"%s\",\n"
+		"           \"secret\": \"%s\",\n"
+		"           \"event\":  \"end_backup\"\n"
+		"         }'
\n" + "
\n" + "
\n" + "
\n", + client_result.secret, request->host ? request->host : "localhost", - result->name, - result->secret + client_result.name, + client_result.secret, + request->host ? request->host : "localhost", + client_result.name, + client_result.secret ); - fprintf(f, "

Backup History

\n"); + fprintf(f, "

Recent Backups

\n"); - if(result->backups_len == 0) { - fprintf(f, "

No backups recorded.

\n"); + if(backups_result->backups_len == 0) { + fprintf(f, "

No backups recorded.

\n"); } else { - fprintf(f, - " \n" - " \n"); + fprintf(f, "
\n"); + + for (int i = 0; i < backups_result->backups_len; i++) { + const char *badge_class; + const char *badge_text; + if (strcmp(backups_result->backups[i].status, "RUNNING") == 0) { + badge_class = "run"; + badge_text = "Running"; + } else if (strcmp(backups_result->backups[i].status, "INTERRUPTED") == 0) { + badge_class = "fail"; + badge_text = "Failed"; + } else { + badge_class = "ok"; + badge_text = "Done"; + } - for (int i = 0; i < result->backups_len; i++) { + printf("Started %ld Ended %ld", backups_result->backups[i].started, backups_result->backups[i].completed); + struct timespec duration = time_sub(&backups_result->backups[i].completed, &backups_result->backups[i].started); fprintf(f, - "
\n" - " \n" - " \n" - " \n" - " \n" - " \n", - result->backups[i].id, - result->backups[i].status, - result->backups[i].started, - result->backups[i].completed[0] ? result->backups[i].completed : "-" + "
%s%s
\n", + format_time(backups_result->backups[i].started).buf, + format_duration(duration).buf, + badge_class, + badge_text ); } - fprintf(f, "
IDStatusStartedCompleted
%ld%s%s%s
\n"); + fprintf(f, " \n"); } fprintf(f, + "
\n" "\n" "\n"); fclose(f); *out_len = buf_len; - free(result); + free(backups_result); return buf; } @@ -814,23 +1264,35 @@ static char *render_login_page(const char *error_msg, size_t *out_len) { fprintf(f, "\n" - "\n" + "\n" "\n" - " Login\n" + " \n" + " \n" + " BorgFlag - Login\n"); + emit_stylesheet(f); + fprintf(f, "\n" "\n" - "

Login

\n"); + "
\n" + "
\n" + "

BorgFlag

\n"); if (error_msg) { - fprintf(f, "

%s

\n", error_msg); + fprintf(f, "
%s
\n", error_msg); } fprintf(f, - " \n" - "

\n" - "

\n" - "

\n" - "
\n" + "
\n" + " \n" + " \n" + "
\n" + "
\n" + " \n" + " \n" + "
\n" + " \n" + " \n" + "
\n" "\n" "\n"); diff --git a/src/web.h b/src/web.h index 0e33018..dd6e824 100644 --- a/src/web.h +++ b/src/web.h @@ -2,11 +2,14 @@ #include #include +#include struct MHD_PostProcessor; #define CLIENT_NAME_MAX 32 #define CLIENT_SECRET_LEN 33 +#define BACKUP_STATUS_MAX 16 +#define MAX_BACKUPS_DISPLAY 50 struct App { const char* dbname; @@ -64,6 +67,9 @@ struct ListClientResultClient { int64_t id; char name[CLIENT_NAME_MAX]; char secret[CLIENT_SECRET_LEN]; + struct timespec last_backup; + char status[BACKUP_STATUS_MAX]; // "OK", "RUNNING", "STALE", "FAILED", "INTERRUPTED" + int is_stale; }; struct ListClientResult { @@ -74,24 +80,24 @@ struct ListClientResult { int list_clients(struct Request *req, struct ListClientResult *result); -#define BACKUP_STATUS_MAX 16 -#define BACKUP_DATETIME_MAX 32 -#define MAX_BACKUPS_DISPLAY 50 - struct BackupRecord { int64_t id; char status[BACKUP_STATUS_MAX]; - char started[BACKUP_DATETIME_MAX]; - char completed[BACKUP_DATETIME_MAX]; + struct timespec started; + struct timespec completed; }; -struct GetClientResult { +struct GetClientOnlyResult { int found; int64_t id; char name[CLIENT_NAME_MAX]; char secret[CLIENT_SECRET_LEN]; - uint16_t backups_len; +}; + +struct GetBackupsResult { + uint16_t backups_len; // On input: max backups to fetch; On output: actual count struct BackupRecord backups[]; }; -int get_client_with_backups(struct Request *req, int64_t client_id, struct GetClientResult *result); +int get_client(struct Request *req, int64_t client_id, struct GetClientOnlyResult *result); +int get_client_backups(struct Request *req, int64_t client_id, struct GetBackupsResult *result); -- cgit v1.2.3