diff --git a/public/css/logs.css b/public/css/logs.css index 761068f..a03a615 100644 --- a/public/css/logs.css +++ b/public/css/logs.css @@ -1,32 +1,75 @@ - body { - font-family: monospace, monospace; - margin: 20px; - } +body { + font-family: monospace, monospace; + margin: 20px; +} - table { - border-collapse: collapse; - width: 100%; - } +table { + border-collapse: collapse; + width: 100%; +} - th, - td { - border: 1px solid #ccc; - padding: 8px; - text-align: left; - } +th, +td { + border: 1px solid #ccc; + padding: 8px; + text-align: left; +} - th { - background: #eee; - } +th { + background: #eee; +} - textarea { - width: 100%; - height: 100px; - font-family: monospace; - white-space: pre-wrap; - } +textarea { + width: 100%; + height: 100px; + font-family: monospace; + white-space: pre-wrap; +} - select, - button { - margin: 5px 0; - } +select, +button { + margin: 5px 0; +} +.pagination-container { + margin: 20px 0; + padding: 15px; + background: #f5f5f5; + border-radius: 5px; +} + +.pagination-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.pagination-controls { + display: flex; + gap: 10px; + align-items: center; +} + +.pagination-controls button { + padding: 5px 10px; + border: 1px solid #ddd; + background: white; + cursor: pointer; + border-radius: 3px; +} + +.pagination-controls button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-controls button:not(:disabled):hover { + background: #e9e9e9; +} + +.loading-indicator { + text-align: center; + padding: 20px; + color: #666; + font-style: italic; +} diff --git a/public/js/logs.js b/public/js/logs.js index 2e0a92c..b11051e 100644 --- a/public/js/logs.js +++ b/public/js/logs.js @@ -1,46 +1,195 @@ -const form = document.getElementById("filterForm"); -const theadRow = document.getElementById("logsTableHeaderRow"); -const tbody = document.querySelector("#logsTable tbody"); +class LogsViewer { + constructor() { + this.form = document.getElementById("filterForm"); + this.theadRow = document.getElementById("logsTableHeaderRow"); + this.tbody = document.querySelector("#logsTable tbody"); + this.currentPage = 1; + this.limit = 50; + this.columns = []; + this.isLoading = false; + this.hasMore = true; + this.totalPages = 0; + this.currentFilters = {}; -form.addEventListener("submit", async (e) => { - e.preventDefault(); - theadRow.innerHTML = ""; - tbody.innerHTML = ""; - const params = new URLSearchParams(new FormData(form)); + this.setupEventListeners(); + this.setupPaginationControls(); + } - try { - const res = await fetch("/logs", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: params.toString(), + setupEventListeners() { + this.form.addEventListener("submit", (e) => { + e.preventDefault(); + this.resetAndLoad(); }); - if (!res.ok) throw new Error(await res.text()); - const logs = await res.json(); + // Continuous scrolling + window.addEventListener("scroll", () => { + if (this.isNearBottom() && !this.isLoading && this.hasMore) { + this.loadNextPage(); + } + }); + } - if (logs.length === 0) { - tbody.innerHTML = 'No logs found'; - return; + setupPaginationControls() { + // Create pagination container + const paginationContainer = document.createElement("div"); + paginationContainer.id = "paginationContainer"; + paginationContainer.className = "pagination-container"; + paginationContainer.innerHTML = ` +
+ Page 0 of 0 (0 total logs) +
+ + + + + +
+
+ + `; + + // Insert after the form + this.form.parentNode.insertBefore( + paginationContainer, + this.form.nextSibling + ); + + // Add pagination event listeners + document + .getElementById("firstPage") + .addEventListener("click", () => this.goToPage(1)); + document + .getElementById("prevPage") + .addEventListener("click", () => this.goToPage(this.currentPage - 1)); + document + .getElementById("nextPage") + .addEventListener("click", () => this.goToPage(this.currentPage + 1)); + document + .getElementById("lastPage") + .addEventListener("click", () => this.goToPage(this.totalPages)); + + document.getElementById("pageInput").addEventListener("keypress", (e) => { + if (e.key === "Enter") { + const page = parseInt(e.target.value); + if (page >= 1 && page <= this.totalPages) { + this.goToPage(page); + } + } + }); + } + + isNearBottom() { + return ( + window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000 + ); + } + + async resetAndLoad() { + this.currentPage = 1; + this.hasMore = true; + this.theadRow.innerHTML = ""; + this.tbody.innerHTML = ""; + this.columns = []; + this.currentFilters = new URLSearchParams(new FormData(this.form)); + await this.loadLogs(false); + } + + async loadNextPage() { + if (this.hasMore && !this.isLoading) { + this.currentPage++; + await this.loadLogs(true); } + } - const columnSet = new Set(); + async goToPage(page) { + if (page >= 1 && page <= this.totalPages && page !== this.currentPage) { + this.currentPage = page; + this.theadRow.innerHTML = ""; + this.tbody.innerHTML = ""; + this.columns = []; + await this.loadLogs(false); + } + } + + async loadLogs(append = false) { + if (this.isLoading) return; + + this.isLoading = true; + this.showLoading(); + + const params = new URLSearchParams(this.currentFilters); + params.append("page", this.currentPage); + params.append("limit", this.limit); + + try { + const res = await fetch("/logs", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: params.toString(), + }); + + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + + this.hasMore = data.pagination.hasMore; + this.totalPages = data.pagination.totalPages; + + if (data.logs.length === 0 && !append) { + this.tbody.innerHTML = 'No logs found'; + this.updatePaginationInfo(0, 0, 0); + return; + } + + this.renderLogs(data.logs, append); + this.updatePaginationInfo( + data.pagination.page, + data.pagination.totalPages, + data.pagination.total + ); + } catch (error) { + const errorMsg = `Error loading logs: ${error.message}`; + if (append) { + this.tbody.innerHTML += errorMsg; + } else { + this.tbody.innerHTML = errorMsg; + } + } finally { + this.isLoading = false; + this.hideLoading(); + } + } + + renderLogs(logs, append = false) { + if (logs.length === 0) return; + + // Build column set + const columnSet = new Set(this.columns); for (const log of logs) { Object.keys(log).forEach((key) => columnSet.add(key)); } - const columns = Array.from(columnSet); - for (const col of columns) { - const th = document.createElement("th"); - th.textContent = col; - theadRow.appendChild(th); + const newColumns = Array.from(columnSet); + + // Update headers if new columns or not appending + if (!append || newColumns.length > this.columns.length) { + this.columns = newColumns; + this.theadRow.innerHTML = ""; + for (const col of this.columns) { + const th = document.createElement("th"); + th.textContent = col; + this.theadRow.appendChild(th); + } } + // Add rows for (const log of logs) { const tr = document.createElement("tr"); - for (const col of columns) { + for (const col of this.columns) { const td = document.createElement("td"); const value = log[col]; if (col === "stack" && typeof value === "string") { @@ -52,9 +201,38 @@ } tr.appendChild(td); } - tbody.appendChild(tr); + this.tbody.appendChild(tr); } - } catch (error) { - tbody.innerHTML = `Error loading logs: ${error.message}`; } + + updatePaginationInfo(page, totalPages, total) { + const pageInfo = document.getElementById("pageInfo"); + const pageInput = document.getElementById("pageInput"); + const firstBtn = document.getElementById("firstPage"); + const prevBtn = document.getElementById("prevPage"); + const nextBtn = document.getElementById("nextPage"); + const lastBtn = document.getElementById("lastPage"); + + pageInfo.textContent = `Page ${page} of ${totalPages} (${total} total logs)`; + pageInput.value = page; + pageInput.max = totalPages; + + firstBtn.disabled = page <= 1; + prevBtn.disabled = page <= 1; + nextBtn.disabled = page >= totalPages; + lastBtn.disabled = page >= totalPages; + } + + showLoading() { + document.getElementById("loadingIndicator").style.display = "block"; + } + + hideLoading() { + document.getElementById("loadingIndicator").style.display = "none"; + } +} + +// Initialize when DOM is loaded +document.addEventListener("DOMContentLoaded", () => { + new LogsViewer(); }); diff --git a/src/middleware/validateRequestIntegrity.js b/src/middleware/validateRequestIntegrity.js index a40eb70..8c49d4b 100644 --- a/src/middleware/validateRequestIntegrity.js +++ b/src/middleware/validateRequestIntegrity.js @@ -1,32 +1,24 @@ -const HttpError = require("../utils/HttpError") +const HttpError = require("../utils/HttpError"); module.exports = (req, res, next) => { - const allowedMethods = ["GET", "POST"]; + const allowedMethods = ["HEAD", "GET", "POST"]; const contentLength = parseInt(req.get("content-length") || "0", 10); const contentType = req.headers["content-type"] || ""; const headerCount = Object.keys(req.headers).length; if (!allowedMethods.includes(req.method)) { - return next( - new HttpError("Method Not Allowed", 405) - ); + return next(new HttpError(`Http Method '${req.method}' Not Allowed`, 405)); } if (contentLength > 4096) { - return next( - new HttpError("Payload Too Large", 413) - ); + return next(new HttpError("Payload Too Large", 413)); } if (contentType.includes("multipart/form-data")) { - return next( - new HttpError("File uploads are not allowed.", 400) - ); + return next(new HttpError("File uploads are not allowed.", 400)); } if (headerCount > 100) { - return next( - new HttpError("Too many headers.", 400) - ); + return next(new HttpError("Too many headers.", 400)); } next(); diff --git a/src/routes/index.js b/src/routes/index.js index f2c856b..689d5ab 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -14,10 +14,14 @@ const pages = require("./pages"); const rssFeed = require("./rssFeed"); const logs = require("./logs"); +const { qualifyLink } = require("../utils/qualifyLinks"); router.get("/error", errorPage); // Landing page after error is logged router.get("/favicon.ico", (req, res) => res.status(204).end()); +router.head("/healthcheck", (req, res) => { + res.sendStatus(200); +}); router.use(logs); @@ -47,9 +51,10 @@ router.get("/blog/:year/:month/:name", post); -router.get("/", async (req, res) => { - res.redirect(301, "/blog"); -}); +// router.get("/", (req, res) => { +// console.log(qualifyLink("/blog")); +// res.redirect(301, qualifyLink("/blog")); +// }); router.use((req, res, next) => { next(new HttpError(null, 404)); diff --git a/src/routes/logs.js b/src/routes/logs.js index c6d205f..ddec110 100644 --- a/src/routes/logs.js +++ b/src/routes/logs.js @@ -10,15 +10,12 @@ const dbPath = path.resolve(__dirname, "../../data/logs.sqlite3"); if (!fs.existsSync(dbPath)) { - // Create empty file to allow readonly open later fs.closeSync(fs.openSync(dbPath, "w")); - // Optionally initialize schema here or open writable once for setup } const db = new Database(dbPath, { readonly: true }); router.get("/logs", secured, (req, res) => { - // res.render("pages/logs", { layout: "logs" }); res.renderWithBaseContext("pages/logs", { showSidebar: false, showFooter: false, @@ -26,16 +23,15 @@ }); router.post("/logs", secured, (req, res) => { - // const log_type = req.query.log_type || "*"; const log_level = req.query.log_level || "*"; const date = req.query.date || "*"; + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 50; + const offset = (page - 1) * limit; if (log_level !== "*" && !allowedLevels.includes(log_level)) { return res.status(400).json({ error: "Invalid log_level" }); } - // if (log_type !== "*" && !allowedTypes.includes(log_type)) { - // return res.status(400).json({ error: "Invalid log_type" }); - // } const conditions = []; const params = []; @@ -50,19 +46,21 @@ params.push(date); } - // if (log_type !== "*") { - // conditions.push(`EXISTS ( - // SELECT 1 FROM log_metadata m - // JOIN keys k ON k.id = m.key_id - // WHERE m.log_id = l.id AND k.key = 'type' AND m.value = ? - // )`); - // params.push(log_type); - // } - const whereClause = conditions.length ? "WHERE " + conditions.join(" AND ") : ""; + // Get total count for pagination + const countQuery = ` + SELECT COUNT(DISTINCT l.id) as total + FROM logs l + ${whereClause} + `; + + const totalResult = db.prepare(countQuery).get(...params); + const total = totalResult.total; + + // Get paginated results const query = ` SELECT l.id, @@ -75,11 +73,11 @@ ${whereClause} GROUP BY l.id ORDER BY l.timestamp DESC - LIMIT 500 + LIMIT ? OFFSET ? `; try { - const rows = db.prepare(query).all(...params); + const rows = db.prepare(query).all(...params, limit, offset); const logs = rows.map((row) => { const meta = {}; @@ -103,7 +101,16 @@ }; }); - res.json(logs); + res.json({ + logs, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasMore: page < Math.ceil(total / limit), + }, + }); } catch { res.status(500).json({ error: "Failed to query logs" }); } diff --git a/src/routes/pages.js b/src/routes/pages.js index f5c6edb..0a72c7f 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -24,7 +24,6 @@ construction.register("/archive", "Archive"); // construction.register("/rss-feed.xml", "RSS Feed"); construction.register("/tags", "Tags"); -construction.register("/blog", "Blog"); construction.register("/about/blog", "About This Blog"); // construction.register("/contact", "Contact Me"); diff --git a/src/utils/logging.js b/src/utils/logging.js index a7846ec..b6839bb 100644 --- a/src/utils/logging.js +++ b/src/utils/logging.js @@ -133,20 +133,7 @@ debug: (...args) => writeLog("DEBUG", logStreams.debug, console.debug, ...args), }; -// // Winston logger -// const winstonLogger = createLogger({ -// transports: [ -// buildTransport("info", "info"), -// buildTransport("error", "error"), -// buildTransport("warn", "warn"), -// buildTransport("debug", "debug"), -// buildTransport("notice", "notice"), -// new transports.Console({ -// level: "debug", -// format: format.combine(format.colorize(), format.simple()), -// }), -// ], -// }); + const winstonLogger = createLogger({ format: format.combine( format.timestamp(), @@ -178,13 +165,6 @@ }) ), }), - // new transports.Console({ - // level: "debug", // or "warn"/"error" - // format: format.combine( - // format.colorize(), - // format.printf(({ level, message }) => `[${level}] ${message}`) - // ), - // }), sqliteTransport, ],