diff options
| -rw-r--r-- | src/mytime.c | 57 | ||||
| -rw-r--r-- | src/mytime.h | 9 | ||||
| -rw-r--r-- | src/web.c | 748 | ||||
| -rw-r--r-- | src/web.h | 24 | ||||
| -rw-r--r-- | test/http.c | 21 |
5 files changed, 699 insertions, 160 deletions
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 <assert.h> +#include <math.h> +#include <stdio.h> #include <stdlib.h> #include <stdbool.h> @@ -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); @@ -5,6 +5,7 @@ #include <errno.h> #include <linux/limits.h> #include <libgen.h> +#include <math.h> #include <sys/socket.h> #include <string.h> #include <sys/stat.h> @@ -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, + "<style>\n" + "*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n" + ":root {\n" + " --bg: #0e0f11;\n" + " --sur: #16181c;\n" + " --bor: #2a2d35;\n" + " --dim: #4a4f5e;\n" + " --txt: #c8cdd8;\n" + " --hi: #eef0f5;\n" + " --green: #4af0a0;\n" + " --yellow: #f0a04a;\n" + " --red: #f06060;\n" + " --purple: #c084fc;\n" + " --mono: 'Courier New', 'Lucida Console', monospace;\n" + "}\n" + "body {\n" + " background: var(--bg);\n" + " color: var(--txt);\n" + " font-family: var(--mono);\n" + " font-size: 14px;\n" + " line-height: 1.5;\n" + " min-height: 100vh;\n" + "}\n" + "/* TOP BAR */\n" + "header {\n" + " background: var(--sur);\n" + " border-bottom: 1px solid var(--bor);\n" + " padding: 0 20px;\n" + " height: 44px;\n" + " display: flex;\n" + " align-items: center;\n" + " justify-content: space-between;\n" + " position: sticky;\n" + " top: 0;\n" + " z-index: 1;\n" + "}\n" + "header b { color: var(--green); letter-spacing: 2px; }\n" + "header small { color: var(--dim); font-size: 12px; font-family: var(--mono); }\n" + "header a { color: var(--dim); font-size: 12px; }\n" + "header a:hover { color: var(--green); }\n" + "/* MAIN CONTENT */\n" + "main { max-width: 860px; margin: 0 auto; padding: 24px 16px; }\n" + "/* LOGIN */\n" + ".login-page {\n" + " min-height: 100vh;\n" + " display: flex;\n" + " align-items: center;\n" + " justify-content: center;\n" + " padding: 24px;\n" + "}\n" + ".login-page form {\n" + " background: var(--sur);\n" + " border: 1px solid var(--bor);\n" + " padding: 36px 28px;\n" + " width: 100%%;\n" + " max-width: 320px;\n" + " display: flex;\n" + " flex-direction: column;\n" + " gap: 14px;\n" + "}\n" + ".login-page h1 { color: var(--green); letter-spacing: 3px; text-align: center; font-size: 20px; }\n" + ".login-page label { display: block; color: var(--dim); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 5px; }\n" + ".login-page input {\n" + " width: 100%%;\n" + " background: var(--bg);\n" + " border: 1px solid var(--bor);\n" + " color: var(--hi);\n" + " font-family: var(--mono);\n" + " font-size: 16px;\n" + " padding: 9px 10px;\n" + " outline: none;\n" + "}\n" + ".login-page input:focus { border-color: var(--green); }\n" + ".login-page .error {\n" + " color: var(--red);\n" + " background: #2e0a0a;\n" + " border: 1px solid #5c1a1a;\n" + " padding: 7px 10px;\n" + " font-size: 12px;\n" + "}\n" + "/* SHARED BUTTON */\n" + "button {\n" + " font-family: var(--mono);\n" + " font-size: 11px;\n" + " letter-spacing: 1px;\n" + " text-transform: uppercase;\n" + " padding: 6px 14px;\n" + " border: 1px solid var(--green);\n" + " background: transparent;\n" + " color: var(--green);\n" + " cursor: pointer;\n" + " touch-action: manipulation;\n" + "}\n" + "button:hover, button:active { background: var(--green); color: var(--bg); }\n" + ".login-page button { padding: 9px; width: 100%%; }\n" + "/* PAGE HEADING */\n" + "main > h2 {\n" + " color: var(--hi);\n" + " font-size: 15px;\n" + " letter-spacing: 2px;\n" + " text-transform: uppercase;\n" + " border-bottom: 1px solid var(--bor);\n" + " padding-bottom: 12px;\n" + " margin-bottom: 20px;\n" + " display: flex;\n" + " justify-content: space-between;\n" + " align-items: baseline;\n" + " flex-wrap: wrap;\n" + " gap: 8px;\n" + "}\n" + "main > h2 small { color: var(--dim); font-size: 12px; font-weight: normal; letter-spacing: 0; }\n" + "/* CLIENT GRID */\n" + ".clients {\n" + " display: grid;\n" + " grid-template-columns: 1fr 150px 155px 80px 70px;\n" + " gap: 0;\n" + "}\n" + ".clients > span {\n" + " font-size: 11px;\n" + " letter-spacing: 1px;\n" + " text-transform: uppercase;\n" + " color: var(--dim);\n" + " padding: 0 12px 10px 12px;\n" + " border-bottom: 1px solid var(--bor);\n" + "}\n" + ".clients > span:last-child { text-align: right; }\n" + ".clients > a {\n" + " display: grid;\n" + " grid-column: 1 / -1;\n" + " grid-template-columns: subgrid;\n" + " align-items: center;\n" + " padding: 11px 12px;\n" + " border-bottom: 1px solid var(--bor);\n" + " text-decoration: none;\n" + " color: var(--txt);\n" + " transition: background .1s;\n" + "}\n" + ".clients > a:hover { background: var(--sur); }\n" + ".clients > a > :first-child { color: var(--hi); }\n" + ".clients > a > :last-child { text-align: right; }\n" + ".clients > a.stale > :first-child { border-left: 2px solid var(--purple); padding-left: 8px; }\n" + ".clients > a.stale > :nth-child(3) { color: var(--purple); }\n" + ".clients > a > code { font-size: 12px; color: var(--dim); letter-spacing: 1px; }\n" + "@media (max-width: 580px) {\n" + " .clients > span { display: none; }\n" + " .clients > a {\n" + " grid-template-columns: 1fr auto;\n" + " grid-template-rows: auto auto;\n" + " padding: 13px 14px;\n" + " }\n" + " .clients > a > :nth-child(1) { grid-column: 1; grid-row: 1; }\n" + " .clients > a > :nth-child(2) { display: none; }\n" + " .clients > a > :nth-child(3) { grid-column: 1; grid-row: 2; font-size: 11px; color: var(--dim); }\n" + " .clients > a > :nth-child(4) { grid-column: 2; grid-row: 1 / 3; align-self: center; }\n" + " .clients > a > :nth-child(5) { display: none; }\n" + " .clients > a.stale { border-left: 3px solid var(--purple); padding-left: 11px; }\n" + " .clients > a.stale > :first-child { border-left: none; padding-left: 0; }\n" + "}\n" + "/* BADGES */\n" + "b.ok { color: var(--green); background: #0a2e1a; border: 1px solid #1a5c34; padding: 2px 7px; font-size: 10px; letter-spacing: 1px; }\n" + "b.run { color: var(--yellow); background: #2e200a; border: 1px solid #5c400e; padding: 2px 7px; font-size: 10px; letter-spacing: 1px; animation: blink 1.2s steps(1) infinite; }\n" + "b.fail { color: var(--red); background: #2e0a0a; border: 1px solid #5c1a1a; padding: 2px 7px; font-size: 10px; letter-spacing: 1px; }\n" + "b.stale { color: var(--purple); background: #1e0a2e; border: 1px solid #5c1a8c; padding: 2px 7px; font-size: 10px; letter-spacing: 1px; }\n" + "@keyframes blink { 50%% { opacity: .4; } }\n" + "/* ADD FORM */\n" + "#add-form {\n" + " background: var(--sur);\n" + " border: 1px solid var(--bor);\n" + " padding: 16px 20px;\n" + " margin-bottom: 20px;\n" + " display: none;\n" + " gap: 12px;\n" + " flex-direction: column;\n" + "}\n" + "#add-form.open { display: flex; }\n" + "#add-form label { color: var(--dim); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 5px; }\n" + "#add-form input {\n" + " background: var(--bg);\n" + " border: 1px solid var(--bor);\n" + " color: var(--hi);\n" + " font-family: var(--mono);\n" + " font-size: 16px;\n" + " padding: 8px 10px;\n" + " outline: none;\n" + " width: 100%%;\n" + " max-width: 280px;\n" + "}\n" + "#add-form input:focus { border-color: var(--green); }\n" + "/* DETAIL HEADER */\n" + "article {\n" + " background: var(--sur);\n" + " border: 1px solid var(--bor);\n" + " padding: 16px 20px;\n" + " margin-bottom: 24px;\n" + "}\n" + "article.stale { border-left: 3px solid var(--purple); }\n" + "article h3 { color: var(--green); font-size: 17px; letter-spacing: 1px; margin-bottom: 4px; }\n" + "article p { color: var(--dim); font-size: 12px; }\n" + "article p.warning {\n" + " color: var(--purple);\n" + " background: #1e0a2e;\n" + " border: 1px solid #5c1a8c;\n" + " padding: 7px 10px;\n" + " margin-top: 10px;\n" + " font-size: 12px;\n" + "}\n" + "article code {\n" + " display: block;\n" + " margin-top: 10px;\n" + " padding-top: 10px;\n" + " border-top: 1px solid var(--bor);\n" + " font-size: 12px;\n" + " color: var(--txt);\n" + " word-break: break-all;\n" + "}\n" + "/* BACK LINK */\n" + "main > a { display: inline-block; color: var(--dim); font-size: 12px; margin-bottom: 20px; text-decoration: none; }\n" + "main > a:hover { color: var(--green); }\n" + "/* SNIPPETS */\n" + "details {\n" + " margin-bottom: 28px;\n" + " border-bottom: 1px solid var(--bor);\n" + "}\n" + "details summary {\n" + " font-size: 11px;\n" + " letter-spacing: 2px;\n" + " text-transform: uppercase;\n" + " color: var(--dim);\n" + " padding-bottom: 10px;\n" + " cursor: pointer;\n" + " list-style: none;\n" + " display: flex;\n" + " justify-content: space-between;\n" + "}\n" + "details summary:hover { color: var(--txt); }\n" + "details summary::after { content: '>'; transition: transform .2s; }\n" + "details[open] summary::after { transform: rotate(90deg); }\n" + "details summary::-webkit-details-marker { display: none; }\n" + "details > div { padding-top: 14px; display: flex; flex-direction: column; gap: 10px; }\n" + "details h4 { font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--dim); margin-bottom: 6px; font-weight: normal; }\n" + "details pre {\n" + " background: var(--bg);\n" + " border: 1px solid var(--bor);\n" + " padding: 12px 60px 12px 14px;\n" + " font-family: var(--mono);\n" + " font-size: 12px;\n" + " overflow-x: auto;\n" + " line-height: 1.6;\n" + " position: relative;\n" + " white-space: pre;\n" + "}\n" + "details pre .cmd { color: var(--yellow); }\n" + "details pre .flag { color: var(--dim); }\n" + "details pre .url { color: var(--green); }\n" + "details pre .key { color: #7dd3fc; }\n" + "details pre .val { color: #f9a8d4; }\n" + "details pre .secret { color: var(--purple); }\n" + "details pre button {\n" + " position: absolute;\n" + " top: 8px; right: 8px;\n" + " background: var(--sur);\n" + " border-color: var(--bor);\n" + " color: var(--dim);\n" + " font-size: 10px;\n" + "}\n" + "details pre button:hover { border-color: var(--green); color: var(--green); background: transparent; }\n" + "/* BACKUP LIST */\n" + "main > h4 { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; color: var(--dim); margin-bottom: 12px; font-weight: normal; }\n" + ".backups { display: flex; flex-direction: column; gap: 8px; }\n" + ".backups > div {\n" + " background: var(--sur);\n" + " border: 1px solid var(--bor);\n" + " padding: 10px 16px;\n" + " display: flex;\n" + " align-items: center;\n" + " gap: 12px;\n" + "}\n" + ".backups > div > time { color: var(--hi); font-size: 13px; flex: 1; }\n" + ".backups > div > span { color: var(--dim); font-size: 12px; min-width: 70px; text-align: right; }\n" + "</style>\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, "<!DOCTYPE html>\n" - "<html>\n" + "<html lang=\"en\">\n" "<head>\n" - " <title>Clients</title>\n" + " <meta charset=\"UTF-8\">\n" + " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" + " <title>BorgFlag - Clients</title>\n"); + emit_stylesheet(f); + fprintf(f, "</head>\n" "<body>\n" - " <h1>Clients</h1>\n" - " <form method=\"POST\" action=\"/logout\" style=\"float:right;margin-top:-40px;\">\n" - " <button type=\"submit\">Logout</button>\n" - " </form>\n" - " <table border=\"1\">\n" - " <tr><th>Name</th><th>Secret</th><th>Actions</th></tr>\n"); + " <header>\n" + " <b>BorgFlag</b>\n" + " <small>admin · <form method=\"POST\" action=\"/logout\" style=\"display:inline\"><button type=\"submit\" style=\"border:none;background:none;color:var(--dim);font-size:12px;padding:0;cursor:pointer;text-transform:none;letter-spacing:0\">Sign out</button></form></small>\n" + " </header>\n" + " <main>\n" + " <h2>\n" + " Clients\n" + " <small>%d registered", result->clients_len); + + if (stale_count > 0) { + fprintf(f, " · <span style=\"color:var(--purple)\">%d stale</span>", stale_count); + } + + fprintf(f, + "</small>\n" + " <button onclick=\"document.getElementById('add-form').classList.toggle('open')\">+ Add</button>\n" + " </h2>\n" + " <form id=\"add-form\" method=\"POST\" action=\"/clients\">\n" + " <div>\n" + " <label>Name</label>\n" + " <input type=\"text\" name=\"name\" maxlength=\"31\" placeholder=\"prod-web-02\" required>\n" + " </div>\n" + " <button type=\"submit\">Create</button>\n" + " </form>\n" + " <div class=\"clients\">\n" + " <span>Name</span>\n" + " <span>Secret</span>\n" + " <span>Last Backup</span>\n" + " <span>Status</span>\n" + " <span></span>\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, - " <tr>\n" - " <td><a href=\"/client/%ld\">%s</a></td>\n" - " <td>%s</td>\n" - " <td>\n" - " <form method=\"POST\" action=\"/clients/delete\" style=\"display:inline\">\n" - " <input type=\"hidden\" name=\"client_id\" value=\"%ld\">\n" - " <button type=\"submit\">Delete</button>\n" - " </form>\n" - " </td>\n" - " </tr>\n", + " <a href=\"/client/%ld\"%s>\n" + " <span>%s</span>\n" + " <code>%s</code>\n" + " <span>%s</span>\n" + " <b class=\"%s\">%s</b>\n" + " <button onclick=\"event.stopPropagation();window.location='/client/%ld'\">View →</button>\n" + " </a>\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, - " </table>\n" - " <h2>Add New Client</h2>\n" - " <form method=\"POST\" action=\"/clients\">\n" - " <input type=\"text\" name=\"name\" maxlength=\"31\" placeholder=\"Client name\" required>\n" - " <button type=\"submit\">Add Client</button>\n" - " </form>\n" + " </div>\n" + " </main>\n" "</body>\n" "</html>\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, "<!DOCTYPE html>\n" - "<html>\n" + "<html lang=\"en\">\n" "<head>\n" - " <title>Client: %s</title>\n" + " <meta charset=\"UTF-8\">\n" + " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" + " <title>BorgFlag - %s</title>\n", client_result.name); + emit_stylesheet(f); + fprintf(f, + "<script>\n" + "function copy(btn) {\n" + " navigator.clipboard.writeText(btn.closest('pre').innerText.replace('Copy','').trim()).then(() => {\n" + " btn.textContent = 'Copied!';\n" + " setTimeout(() => btn.textContent = 'Copy', 2000);\n" + " });\n" + "}\n" + "</script>\n" "</head>\n" "<body>\n" - " <p><a href=\"/clients\">Clients</a> > %s</p>\n" - " <h1>Client: %s</h1>\n" - " <h2>Details</h2>\n" - " <table border=\"1\">\n" - " <tr><th>ID</th><td>%ld</td></tr>\n" - " <tr><th>Name</th><td>%s</td></tr>\n" - " <tr><th>Secret</th><td>%s</td></tr>\n" - " </table>\n", - result->name, - result->name, - result->name, - result->id, - result->name, - result->secret + " <header>\n" + " <b>BorgFlag</b>\n" + " <small>admin · <form method=\"POST\" action=\"/logout\" style=\"display:inline\"><button type=\"submit\" style=\"border:none;background:none;color:var(--dim);font-size:12px;padding:0;cursor:pointer;text-transform:none;letter-spacing:0\">Sign out</button></form></small>\n" + " </header>\n" + " <main>\n" + " <a href=\"/clients\">← Back to clients</a>\n" + " <article%s>\n" + " <h3>%s</h3>\n" + " <p>%d backups · <b class=\"%s\">%s</b></p>\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, + " <p class=\"warning\">Warning: Last backup was more than 24 hours ago. Expected every 24 hours.</p>\n" + ); + } + fprintf(f, - " <h2>Submit Report</h2>\n" - " <p>Use this curl command to submit a backup report:</p>\n" - " <pre>curl -X POST http://%s/api/report \\\n" - " -H \"Content-Type: application/json\" \\\n" - " -d '{\"client\": \"%s\", \"secret\": \"%s\", \"event\": \"begin_backup\"}'</pre>\n" - " <p>Replace <code>begin_backup</code> with <code>end_backup</code> to mark completion.</p>\n", + " <code>Secret: %s</code>\n" + " </article>\n" + " <details>\n" + " <summary>API Snippets</summary>\n" + " <div>\n" + " <div>\n" + " <h4>Start backup</h4>\n" + " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n" + " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n" + " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n" + " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n" + " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n" + " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"begin_backup\"</span>\n" + " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n" + " </div>\n" + " <div>\n" + " <h4>End backup</h4>\n" + " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n" + " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n" + " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n" + " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n" + " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n" + " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"end_backup\"</span>\n" + " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n" + " </div>\n" + " </div>\n" + " </details>\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, " <h2>Backup History</h2>\n"); + fprintf(f, " <h4>Recent Backups</h4>\n"); - if(result->backups_len == 0) { - fprintf(f, " <p>No backups recorded.</p>\n"); + if(backups_result->backups_len == 0) { + fprintf(f, " <p style=\"color:var(--dim)\">No backups recorded.</p>\n"); } else { - fprintf(f, - " <table border=\"1\">\n" - " <tr><th>ID</th><th>Status</th><th>Started</th><th>Completed</th></tr>\n"); + fprintf(f, " <div class=\"backups\">\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, - " <tr>\n" - " <td>%ld</td>\n" - " <td>%s</td>\n" - " <td>%s</td>\n" - " <td>%s</td>\n" - " </tr>\n", - result->backups[i].id, - result->backups[i].status, - result->backups[i].started, - result->backups[i].completed[0] ? result->backups[i].completed : "-" + " <div><time>%s</time><span>%s</span><b class=\"%s\">%s</b></div>\n", + format_time(backups_result->backups[i].started).buf, + format_duration(duration).buf, + badge_class, + badge_text ); } - fprintf(f, " </table>\n"); + fprintf(f, " </div>\n"); } fprintf(f, + " </main>\n" "</body>\n" "</html>\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, "<!DOCTYPE html>\n" - "<html>\n" + "<html lang=\"en\">\n" "<head>\n" - " <title>Login</title>\n" + " <meta charset=\"UTF-8\">\n" + " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" + " <title>BorgFlag - Login</title>\n"); + emit_stylesheet(f); + fprintf(f, "</head>\n" "<body>\n" - " <h1>Login</h1>\n"); + " <div class=\"login-page\">\n" + " <form method=\"POST\" action=\"/login\">\n" + " <h1>BorgFlag</h1>\n"); if (error_msg) { - fprintf(f, " <p style=\"color: red;\">%s</p>\n", error_msg); + fprintf(f, " <div class=\"error\">%s</div>\n", error_msg); } fprintf(f, - " <form method=\"POST\" action=\"/login\">\n" - " <p><label>Username: <input type=\"text\" name=\"username\" required></label></p>\n" - " <p><label>Password: <input type=\"password\" name=\"password\" required></label></p>\n" - " <p><button type=\"submit\">Login</button></p>\n" - " </form>\n" + " <div>\n" + " <label>Username</label>\n" + " <input type=\"text\" name=\"username\" autocomplete=\"username\" required>\n" + " </div>\n" + " <div>\n" + " <label>Password</label>\n" + " <input type=\"password\" name=\"password\" autocomplete=\"current-password\" required>\n" + " </div>\n" + " <button type=\"submit\">Sign In</button>\n" + " </form>\n" + " </div>\n" "</body>\n" "</html>\n"); @@ -2,11 +2,14 @@ #include <stddef.h> #include <stdint.h> +#include <time.h> 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); diff --git a/test/http.c b/test/http.c index fa8650d..97f7a6b 100644 --- a/test/http.c +++ b/test/http.c @@ -322,24 +322,29 @@ int main(int argc, char **argv) { // Use internal function to verify backup statuses struct Request req = { .server = &server }; - struct GetClientResult *result = malloc(sizeof(struct GetClientResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY); - int ret = get_client_with_backups(&req, 1, result); + struct GetClientOnlyResult client_result; + int ret = get_client(&req, 1, &client_result); + assert(ret == 0); + assert(client_result.found == 1); + + 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(&req, 1, backups_result); assert(ret == 0); - assert(result->found == 1); // Should have at least 2 backups (the interrupted one and the running one) // Note: There may be more from earlier tests - assert(result->backups_len >= 2); + assert(backups_result->backups_len >= 2); // Backups are ordered by started DESC, so newest first // The most recent backup should be RUNNING - assert(strcmp(result->backups[0].status, "RUNNING") == 0); + assert(strcmp(backups_result->backups[0].status, "RUNNING") == 0); // The previous backup should be INTERRUPTED - assert(strcmp(result->backups[1].status, "INTERRUPTED") == 0); + assert(strcmp(backups_result->backups[1].status, "INTERRUPTED") == 0); // The interrupted backup should have a completed timestamp - assert(result->backups[1].completed[0] != '\0'); + assert(backups_result->backups[1].completed.tv_sec > 0); - free(result); + free(backups_result); } // Test: Full login flow |
