summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/mytime.c57
-rw-r--r--src/mytime.h9
-rw-r--r--src/web.c748
-rw-r--r--src/web.h24
-rw-r--r--test/http.c21
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);
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 <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 &nbsp;&middot;&nbsp; <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, " &nbsp;&middot;&nbsp; <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 &rarr;</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> &gt; %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 &nbsp;&middot;&nbsp; <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\">&larr; Back to clients</a>\n"
+ " <article%s>\n"
+ " <h3>%s</h3>\n"
+ " <p>%d backups &nbsp;&middot;&nbsp; <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");
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 <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