Add REST call history panel

This commit is contained in:
2026-06-01 16:06:48 -06:00
parent e9cd7de1b2
commit 6a2281c7bb
2 changed files with 323 additions and 20 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
Check,
@@ -19,6 +19,7 @@ import {
import "./styles.css";
const DEFAULT_API_URL = "/api";
const API_HISTORY_LIMIT = 80;
function readStored(key, fallback) {
try {
@@ -87,6 +88,8 @@ function App() {
const [status, setStatus] = useState({ tone: "idle", message: "Ready" });
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [apiHistory, setApiHistory] = useState([]);
const apiHistoryEndRef = useRef(null);
const normalizedApiUrl = useMemo(() => normalizeApiUrl(apiUrl), [apiUrl]);
const selectedNote = useMemo(
@@ -122,11 +125,22 @@ function App() {
}
}, [token]);
useEffect(() => {
apiHistoryEndRef.current?.scrollIntoView({ block: "end" });
}, [apiHistory.length]);
function setMessage(tone, message) {
setStatus({ tone, message });
}
function recordApiCall(entry) {
setApiHistory((current) => [...current, entry].slice(-API_HISTORY_LIMIT));
}
async function apiRequest(path, options = {}) {
const method = options.method || "GET";
const url = `${normalizedApiUrl}${path}`;
const startedAt = performance.now();
const headers = {
Accept: "application/json",
...(options.body ? { "Content-Type": "application/json" } : {}),
@@ -134,20 +148,56 @@ function App() {
...(options.headers || {}),
};
const response = await fetch(`${normalizedApiUrl}${path}`, {
try {
const response = await fetch(url, {
...options,
headers,
});
const text = await response.text();
const payload = text ? parsePayload(text) : null;
const elapsedMs = Math.round(performance.now() - startedAt);
recordApiCall({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
method,
path,
url,
ok: response.ok,
status: response.status,
statusText: response.statusText,
elapsedMs,
timestamp: new Date().toLocaleTimeString(),
request: formatBody(options.body),
response: payload,
});
if (!response.ok) {
const message = payload?.message || payload?.error || text || `${response.status} ${response.statusText}`;
throw new Error(message);
const httpError = new Error(message);
httpError.apiLogged = true;
throw httpError;
}
return payload;
} catch (error) {
if (!error?.apiLogged) {
recordApiCall({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
method,
path,
url,
ok: false,
status: "ERR",
statusText: "Network error",
elapsedMs: Math.round(performance.now() - startedAt),
timestamp: new Date().toLocaleTimeString(),
request: formatBody(options.body),
response: { error: error.message },
});
}
throw error;
}
}
async function handleAuth(event) {
@@ -478,6 +528,12 @@ function App() {
<p>Point the app at the API, register or log in, then create, edit, delete, and share notes.</p>
</section>
)}
<ApiHistoryPanel
entries={apiHistory}
onClear={() => setApiHistory([])}
endRef={apiHistoryEndRef}
/>
</section>
</main>
);
@@ -531,6 +587,53 @@ function StatusBadge({ status }) {
return <div className={`status-badge ${status.tone}`}>{status.message}</div>;
}
function ApiHistoryPanel({ entries, onClear, endRef }) {
return (
<section className="api-history" aria-label="REST call history">
<header className="api-history-header">
<div>
<p className="eyebrow">REST history</p>
<h3>Requests and responses</h3>
</div>
<button className="ghost-icon" onClick={onClear} disabled={!entries.length} title="Clear history" aria-label="Clear history">
<Trash2 size={16} />
</button>
</header>
<div className="api-history-list">
{entries.length ? (
entries.map((entry) => (
<article className="api-entry" key={entry.id}>
<div className="api-entry-summary">
<span className={`method method-${entry.method.toLowerCase()}`}>{entry.method}</span>
<code>{entry.path}</code>
<span className={entry.ok ? "api-status ok" : "api-status error"}>
{entry.status} {entry.statusText}
</span>
<time>{entry.timestamp}</time>
<span>{entry.elapsedMs}ms</span>
</div>
<div className="api-entry-details">
<div>
<strong>Request</strong>
<pre>{formatDisplay(entry.request)}</pre>
</div>
<div>
<strong>Response</strong>
<pre>{formatDisplay(entry.response)}</pre>
</div>
</div>
</article>
))
) : (
<p className="api-history-empty">REST calls will appear here.</p>
)}
<div ref={endRef} />
</div>
</section>
);
}
function parsePayload(text) {
try {
return JSON.parse(text);
@@ -539,6 +642,37 @@ function parsePayload(text) {
}
}
function formatBody(body) {
if (!body) return null;
if (typeof body !== "string") return redactSensitive(body);
try {
return redactSensitive(JSON.parse(body));
} catch {
return body;
}
}
function formatDisplay(value) {
if (value === null || value === undefined || value === "") return "No body";
if (typeof value === "string") return value;
return JSON.stringify(redactSensitive(value), null, 2);
}
function redactSensitive(value) {
if (Array.isArray(value)) return value.map(redactSensitive);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, child]) => {
const lowerKey = key.toLowerCase();
if (lowerKey.includes("password") || lowerKey.includes("token") || lowerKey.includes("authorization")) {
return [key, "redacted"];
}
return [key, redactSensitive(child)];
}),
);
}
function normalizeApiUrl(value) {
const trimmed = value.trim();
if (!trimmed || trimmed === "/") return "";

View File

@@ -17,6 +17,7 @@ body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
button,
@@ -39,7 +40,8 @@ textarea:disabled {
.app-shell {
display: grid;
grid-template-columns: minmax(300px, 380px) 1fr;
min-height: 100vh;
height: 100vh;
min-height: 0;
background:
linear-gradient(90deg, #efe7db 0, #f5f2eb 34%, #eef5f0 100%);
}
@@ -48,7 +50,8 @@ textarea:disabled {
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100vh;
min-height: 0;
overflow-y: auto;
padding: 24px;
border-right: 1px solid rgba(40, 43, 46, 0.12);
background: rgba(250, 248, 243, 0.78);
@@ -82,7 +85,8 @@ textarea:disabled {
}
h1,
h2 {
h2,
h3 {
margin: 0;
letter-spacing: 0;
}
@@ -99,6 +103,11 @@ h2 {
line-height: 1;
}
h3 {
font-size: 1.02rem;
line-height: 1.1;
}
.panel {
border: 1px solid rgba(42, 44, 46, 0.13);
border-radius: 8px;
@@ -353,10 +362,13 @@ textarea:focus {
}
.workspace {
display: flex;
flex-direction: column;
gap: 22px;
display: grid;
grid-template-rows: auto minmax(0, 1fr) minmax(220px, 30vh);
gap: 18px;
height: 100vh;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 34px;
}
@@ -387,9 +399,14 @@ textarea:focus {
.editor {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.editor textarea {
flex: 1;
min-height: 0;
}
.title-input {
@@ -424,7 +441,7 @@ textarea:focus {
display: grid;
place-items: center;
align-content: center;
min-height: 60vh;
min-height: 0;
color: #4c5550;
text-align: center;
}
@@ -439,6 +456,139 @@ textarea:focus {
line-height: 1.55;
}
.api-history {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid rgba(42, 44, 46, 0.13);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 16px 48px rgba(54, 49, 40, 0.07);
overflow: hidden;
}
.api-history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
border-bottom: 1px solid rgba(42, 44, 46, 0.11);
}
.api-history-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
.api-entry {
display: grid;
gap: 9px;
padding: 10px;
border: 1px solid rgba(42, 44, 46, 0.1);
border-radius: 8px;
background: rgba(250, 248, 243, 0.8);
}
.api-entry + .api-entry {
margin-top: 8px;
}
.api-entry-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 8px;
color: #58625d;
font-size: 0.78rem;
font-weight: 760;
}
.api-entry-summary code {
min-width: 0;
overflow: hidden;
color: #1f2d29;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.method,
.api-status {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 900;
text-transform: uppercase;
white-space: nowrap;
}
.method {
color: #17332e;
background: #dcefe8;
}
.method-post,
.method-put {
color: #173042;
background: #dbe8f0;
}
.method-delete {
color: #8f2d28;
background: #f7ded8;
}
.api-status.ok {
color: #135b4b;
background: #e1f2ea;
}
.api-status.error {
color: #8f2d28;
background: #f7ded8;
}
.api-entry-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.api-entry-details strong {
display: block;
margin-bottom: 5px;
color: #4c5550;
font-size: 0.75rem;
text-transform: uppercase;
}
.api-entry-details pre {
max-height: 130px;
margin: 0;
overflow: auto;
padding: 9px;
border-radius: 7px;
background: #18211f;
color: #eff7f2;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.76rem;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.api-history-empty {
margin: 56px 0;
color: #6c756f;
text-align: center;
}
.spin {
animation: spin 900ms linear infinite;
}
@@ -450,8 +600,14 @@ textarea:focus {
}
@media (max-width: 840px) {
body {
overflow: auto;
}
.app-shell {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
}
.sidebar {
@@ -465,13 +621,26 @@ textarea:focus {
}
.workspace {
grid-template-rows: auto;
height: auto;
overflow: visible;
padding: 22px;
}
.api-history {
min-height: 220px;
max-height: 36vh;
}
.workspace-header {
align-items: flex-start;
flex-direction: column;
}
.api-entry-summary,
.api-entry-details {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {