|
| 1 | +import { useEffect, useState, useCallback } from "react"; |
| 2 | +import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; |
| 3 | +import { fetchAuditRecent, type AuditEntry } from "../api/dashboard"; |
| 4 | +import "../styles/layout.css"; |
| 5 | + |
| 6 | +const PAGE_SIZES = [10, 25, 50, 100]; |
| 7 | + |
| 8 | +// Row padding by page size: 10 = bigger rows, 25 = medium, 50/100 = compact |
| 9 | +function getRowPadding(pageSize: number): string { |
| 10 | + if (pageSize <= 10) return "0.85rem 1rem"; |
| 11 | + if (pageSize <= 25) return "0.65rem 0.85rem"; |
| 12 | + return "0.5rem 0.75rem"; |
| 13 | +} |
| 14 | + |
| 15 | +export function RequestAudit() { |
| 16 | + const [entries, setEntries] = useState<AuditEntry[]>([]); |
| 17 | + const [loading, setLoading] = useState(true); |
| 18 | + const [error, setError] = useState<string | null>(null); |
| 19 | + const [timestamp, setTimestamp] = useState<string | null>(null); |
| 20 | + const [hint, setHint] = useState<string | null>(null); |
| 21 | + const [page, setPage] = useState(1); |
| 22 | + const [pageSize, setPageSize] = useState(10); |
| 23 | + const [total, setTotal] = useState(0); |
| 24 | + const [totalPages, setTotalPages] = useState(0); |
| 25 | + |
| 26 | + const load = useCallback(async (pageNum: number, size: number) => { |
| 27 | + setLoading(true); |
| 28 | + setError(null); |
| 29 | + setHint(null); |
| 30 | + const result = await fetchAuditRecent(size, pageNum); |
| 31 | + if (result) { |
| 32 | + setEntries(result.entries); |
| 33 | + setTimestamp(result.timestamp); |
| 34 | + const t = result.total ?? 0; |
| 35 | + setTotal(t); |
| 36 | + setTotalPages(result.totalPages ?? Math.max(1, Math.ceil(t / size))); |
| 37 | + if (result.hint) setHint(result.hint); |
| 38 | + } else { |
| 39 | + setError("Could not load audit log. Is the Gateway running and REDIS_URL set?"); |
| 40 | + setEntries([]); |
| 41 | + setTotal(0); |
| 42 | + setTotalPages(0); |
| 43 | + } |
| 44 | + setLoading(false); |
| 45 | + }, []); |
| 46 | + |
| 47 | + useEffect(() => { |
| 48 | + load(page, pageSize); |
| 49 | + }, [page, pageSize, load]); |
| 50 | + |
| 51 | + useEffect(() => { |
| 52 | + const interval = setInterval(() => load(page, pageSize), 15_000); |
| 53 | + return () => clearInterval(interval); |
| 54 | + }, [page, pageSize, load]); |
| 55 | + |
| 56 | + return ( |
| 57 | + <div> |
| 58 | + <h1 style={{ marginBottom: "0.5rem", fontSize: "1.5rem" }}>Request Audit</h1> |
| 59 | + <p style={{ color: "var(--text-muted)", marginBottom: "1.5rem" }}> |
| 60 | + Recent requests through the Gateway: who (DID) requested what (method + path) and whether access was granted or denied. Data is stored in Gateway Redis and does not affect the request path. |
| 61 | + </p> |
| 62 | + |
| 63 | + {loading && entries.length === 0 && ( |
| 64 | + <div className="card"> |
| 65 | + <p style={{ color: "var(--text-muted)" }}>Loading audit log…</p> |
| 66 | + </div> |
| 67 | + )} |
| 68 | + |
| 69 | + {error && entries.length === 0 && ( |
| 70 | + <div className="card" style={{ borderColor: "var(--danger)" }}> |
| 71 | + <p style={{ color: "var(--danger)" }}>{error}</p> |
| 72 | + <button type="button" className="btn btn-secondary" onClick={() => load(page, pageSize)} style={{ marginTop: "0.75rem" }}> |
| 73 | + <RefreshCw size={16} /> Retry |
| 74 | + </button> |
| 75 | + </div> |
| 76 | + )} |
| 77 | + |
| 78 | + {(entries.length > 0 || (!loading && timestamp)) && ( |
| 79 | + <div className="card"> |
| 80 | + <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem", flexWrap: "wrap", gap: "0.75rem" }}> |
| 81 | + <h2 className="card-title" style={{ margin: 0 }}>Recent requests</h2> |
| 82 | + <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}> |
| 83 | + <label style={{ fontSize: "0.85rem", color: "var(--text-muted)", display: "flex", alignItems: "center", gap: "0.35rem" }}> |
| 84 | + Per page |
| 85 | + <select |
| 86 | + value={pageSize} |
| 87 | + onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }} |
| 88 | + style={{ padding: "0.35rem 0.5rem", borderRadius: "6px", border: "1px solid var(--border)", background: "var(--input-bg)", color: "var(--text-primary)", fontSize: "0.85rem" }} |
| 89 | + > |
| 90 | + {PAGE_SIZES.map((n) => ( |
| 91 | + <option key={n} value={n}>{n}</option> |
| 92 | + ))} |
| 93 | + </select> |
| 94 | + </label> |
| 95 | + <button type="button" className="btn btn-secondary" onClick={() => load(page, pageSize)} disabled={loading}> |
| 96 | + <RefreshCw size={16} /> Refresh |
| 97 | + </button> |
| 98 | + </div> |
| 99 | + </div> |
| 100 | + {timestamp && ( |
| 101 | + <p style={{ fontSize: "0.85rem", color: "var(--text-muted)", marginBottom: "1rem" }}> |
| 102 | + Last updated: {timestamp} |
| 103 | + </p> |
| 104 | + )} |
| 105 | + {total > 0 && ( |
| 106 | + <p style={{ fontSize: "0.85rem", color: "var(--text-muted)", marginBottom: "0.75rem" }}> |
| 107 | + Showing {((page - 1) * pageSize) + 1}–{Math.min(page * pageSize, total)} of {total} |
| 108 | + </p> |
| 109 | + )} |
| 110 | + |
| 111 | + <div style={{ overflowX: "auto" }}> |
| 112 | + <table style={{ width: "100%", borderCollapse: "collapse", fontSize: pageSize <= 10 ? "0.95rem" : pageSize <= 25 ? "0.9rem" : "0.875rem" }}> |
| 113 | + <thead> |
| 114 | + <tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}> |
| 115 | + <th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Time</th> |
| 116 | + <th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>From (DID)</th> |
| 117 | + <th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Method</th> |
| 118 | + <th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Path</th> |
| 119 | + <th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Result</th> |
| 120 | + <th style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontWeight: 600 }}>Reason</th> |
| 121 | + </tr> |
| 122 | + </thead> |
| 123 | + <tbody> |
| 124 | + {entries.map((e, i) => ( |
| 125 | + <tr key={`${e.timestamp}-${i}`} style={{ borderBottom: "1px solid var(--border)" }}> |
| 126 | + <td style={{ padding: getRowPadding(pageSize), color: "var(--text-secondary)", whiteSpace: "nowrap" }}> |
| 127 | + {new Date(e.timestamp).toLocaleString()} |
| 128 | + </td> |
| 129 | + <td style={{ padding: getRowPadding(pageSize), color: "var(--text-primary)", wordBreak: "break-all", maxWidth: "200px" }}> |
| 130 | + {e.did ?? "—"} |
| 131 | + </td> |
| 132 | + <td style={{ padding: getRowPadding(pageSize) }}> |
| 133 | + <code style={{ fontSize: "0.8em", background: "var(--input-bg)", padding: "0.2rem 0.4rem", borderRadius: "4px" }}> |
| 134 | + {e.method} |
| 135 | + </code> |
| 136 | + </td> |
| 137 | + <td style={{ padding: getRowPadding(pageSize), color: "var(--text-secondary)", wordBreak: "break-all", maxWidth: "280px" }}> |
| 138 | + {e.path} |
| 139 | + </td> |
| 140 | + <td style={{ padding: getRowPadding(pageSize) }}> |
| 141 | + <span className={`pill ${e.granted ? "pill-success" : "pill-error"}`}> |
| 142 | + {e.granted ? "Granted" : "Denied"} |
| 143 | + </span> |
| 144 | + </td> |
| 145 | + <td style={{ padding: getRowPadding(pageSize), color: "var(--text-muted)", fontSize: "0.8rem", maxWidth: "180px" }}> |
| 146 | + {e.reason ?? "—"} |
| 147 | + </td> |
| 148 | + </tr> |
| 149 | + ))} |
| 150 | + </tbody> |
| 151 | + </table> |
| 152 | + </div> |
| 153 | + {entries.length === 0 && !loading && ( |
| 154 | + <div style={{ marginTop: "1rem" }}> |
| 155 | + <p style={{ color: "var(--text-muted)" }}>No audit entries yet.</p> |
| 156 | + {hint && ( |
| 157 | + <p style={{ color: "var(--text-muted)", fontSize: "0.85rem", marginTop: "0.5rem" }}>{hint}</p> |
| 158 | + )} |
| 159 | + <p style={{ color: "var(--text-muted)", fontSize: "0.85rem", marginTop: "0.5rem" }}> |
| 160 | + Only requests to protected routes are logged: <code style={{ fontSize: "0.8em" }}>/api/farmer/*</code>, <code style={{ fontSize: "0.8em" }}>/api/agents/*</code>, <code style={{ fontSize: "0.8em" }}>/api/data</code>. Check Gateway logs for "Audit:" if Redis write fails. |
| 161 | + </p> |
| 162 | + </div> |
| 163 | + )} |
| 164 | + |
| 165 | + {/* Bottom pagination: Back / Page N of M / Next */} |
| 166 | + {(entries.length > 0 || total > 0) && ( |
| 167 | + <nav style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "1rem", paddingTop: "1rem", borderTop: "1px solid var(--border)", flexWrap: "wrap", gap: "0.5rem" }} aria-label="Audit pagination"> |
| 168 | + <button |
| 169 | + type="button" |
| 170 | + className="btn btn-secondary" |
| 171 | + onClick={() => setPage((p) => Math.max(1, p - 1))} |
| 172 | + disabled={loading || page <= 1} |
| 173 | + aria-label="Previous page" |
| 174 | + > |
| 175 | + <ChevronLeft size={16} /> Back |
| 176 | + </button> |
| 177 | + <span style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}> |
| 178 | + Page {page} of {Math.max(1, totalPages)} |
| 179 | + </span> |
| 180 | + <button |
| 181 | + type="button" |
| 182 | + className="btn btn-secondary" |
| 183 | + onClick={() => setPage((p) => Math.min(Math.max(1, totalPages), p + 1))} |
| 184 | + disabled={loading || page >= Math.max(1, totalPages)} |
| 185 | + aria-label="Next page" |
| 186 | + > |
| 187 | + Next <ChevronRight size={16} /> |
| 188 | + </button> |
| 189 | + </nav> |
| 190 | + )} |
| 191 | + </div> |
| 192 | + )} |
| 193 | + </div> |
| 194 | + ); |
| 195 | +} |
0 commit comments