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 { createRoot } from "react-dom/client";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const DEFAULT_API_URL = "/api";
|
const DEFAULT_API_URL = "/api";
|
||||||
|
const API_HISTORY_LIMIT = 80;
|
||||||
|
|
||||||
function readStored(key, fallback) {
|
function readStored(key, fallback) {
|
||||||
try {
|
try {
|
||||||
@@ -87,6 +88,8 @@ function App() {
|
|||||||
const [status, setStatus] = useState({ tone: "idle", message: "Ready" });
|
const [status, setStatus] = useState({ tone: "idle", message: "Ready" });
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [apiHistory, setApiHistory] = useState([]);
|
||||||
|
const apiHistoryEndRef = useRef(null);
|
||||||
|
|
||||||
const normalizedApiUrl = useMemo(() => normalizeApiUrl(apiUrl), [apiUrl]);
|
const normalizedApiUrl = useMemo(() => normalizeApiUrl(apiUrl), [apiUrl]);
|
||||||
const selectedNote = useMemo(
|
const selectedNote = useMemo(
|
||||||
@@ -122,11 +125,22 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiHistoryEndRef.current?.scrollIntoView({ block: "end" });
|
||||||
|
}, [apiHistory.length]);
|
||||||
|
|
||||||
function setMessage(tone, message) {
|
function setMessage(tone, message) {
|
||||||
setStatus({ tone, message });
|
setStatus({ tone, message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordApiCall(entry) {
|
||||||
|
setApiHistory((current) => [...current, entry].slice(-API_HISTORY_LIMIT));
|
||||||
|
}
|
||||||
|
|
||||||
async function apiRequest(path, options = {}) {
|
async function apiRequest(path, options = {}) {
|
||||||
|
const method = options.method || "GET";
|
||||||
|
const url = `${normalizedApiUrl}${path}`;
|
||||||
|
const startedAt = performance.now();
|
||||||
const headers = {
|
const headers = {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||||
@@ -134,20 +148,56 @@ function App() {
|
|||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${normalizedApiUrl}${path}`, {
|
try {
|
||||||
...options,
|
const response = await fetch(url, {
|
||||||
headers,
|
...options,
|
||||||
});
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const payload = text ? parsePayload(text) : null;
|
const payload = text ? parsePayload(text) : null;
|
||||||
|
const elapsedMs = Math.round(performance.now() - startedAt);
|
||||||
|
|
||||||
if (!response.ok) {
|
recordApiCall({
|
||||||
const message = payload?.message || payload?.error || text || `${response.status} ${response.statusText}`;
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
throw new Error(message);
|
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) {
|
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>
|
<p>Point the app at the API, register or log in, then create, edit, delete, and share notes.</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ApiHistoryPanel
|
||||||
|
entries={apiHistory}
|
||||||
|
onClear={() => setApiHistory([])}
|
||||||
|
endRef={apiHistoryEndRef}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -531,6 +587,53 @@ function StatusBadge({ status }) {
|
|||||||
return <div className={`status-badge ${status.tone}`}>{status.message}</div>;
|
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) {
|
function parsePayload(text) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
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) {
|
function normalizeApiUrl(value) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed || trimmed === "/") return "";
|
if (!trimmed || trimmed === "/") return "";
|
||||||
|
|||||||
185
src/styles.css
185
src/styles.css
@@ -17,6 +17,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -39,7 +40,8 @@ textarea:disabled {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(300px, 380px) 1fr;
|
grid-template-columns: minmax(300px, 380px) 1fr;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, #efe7db 0, #f5f2eb 34%, #eef5f0 100%);
|
linear-gradient(90deg, #efe7db 0, #f5f2eb 34%, #eef5f0 100%);
|
||||||
}
|
}
|
||||||
@@ -48,7 +50,8 @@ textarea:disabled {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
min-height: 100vh;
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-right: 1px solid rgba(40, 43, 46, 0.12);
|
border-right: 1px solid rgba(40, 43, 46, 0.12);
|
||||||
background: rgba(250, 248, 243, 0.78);
|
background: rgba(250, 248, 243, 0.78);
|
||||||
@@ -82,7 +85,8 @@ textarea:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2,
|
||||||
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,11 @@ h2 {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid rgba(42, 44, 46, 0.13);
|
border: 1px solid rgba(42, 44, 46, 0.13);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -353,10 +362,13 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-rows: auto minmax(0, 1fr) minmax(220px, 30vh);
|
||||||
gap: 22px;
|
gap: 18px;
|
||||||
|
height: 100vh;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 34px;
|
padding: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,9 +399,14 @@ textarea:focus {
|
|||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-input {
|
.title-input {
|
||||||
@@ -424,7 +441,7 @@ textarea:focus {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
min-height: 60vh;
|
min-height: 0;
|
||||||
color: #4c5550;
|
color: #4c5550;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -439,6 +456,139 @@ textarea:focus {
|
|||||||
line-height: 1.55;
|
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 {
|
.spin {
|
||||||
animation: spin 900ms linear infinite;
|
animation: spin 900ms linear infinite;
|
||||||
}
|
}
|
||||||
@@ -450,8 +600,14 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 840px) {
|
@media (max-width: 840px) {
|
||||||
|
body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -465,13 +621,26 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
|
grid-template-rows: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-history {
|
||||||
|
min-height: 220px;
|
||||||
|
max-height: 36vh;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-header {
|
.workspace-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-entry-summary,
|
||||||
|
.api-entry-details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user