Add REST call history panel
This commit is contained in:
158
src/main.jsx
158
src/main.jsx
@@ -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}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const payload = text ? parsePayload(text) : null;
|
||||
const text = await response.text();
|
||||
const payload = text ? parsePayload(text) : null;
|
||||
const elapsedMs = Math.round(performance.now() - startedAt);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = payload?.message || payload?.error || text || `${response.status} ${response.statusText}`;
|
||||
throw new Error(message);
|
||||
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}`;
|
||||
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;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
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 "";
|
||||
|
||||
185
src/styles.css
185
src/styles.css
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user