4bnode 4.2.0 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/add-bonjour.js +29 -1
- package/add-email.js +33 -15
- package/add-serialport.js +133 -41
- package/index.js +3 -3
- package/lib/codegen.js +1442 -36
- package/lib/codegen.test.js +233 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +116 -7
- package/skeleton/.4bnode/assets/js/components/CommandPalette.js +165 -0
- package/skeleton/.4bnode/assets/js/components/SerialWorkspace.js +634 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +64 -64
- package/skeleton/.4bnode/assets/js/lib.js +76 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +113 -53
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +240 -403
- package/skeleton/.4bnode/assets/js/pages/DiscoveryPage.js +52 -3
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +37 -19
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1 -1
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +5 -5
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +154 -3
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +455 -139
- package/skeleton/.4bnode/codegen.js +1442 -36
- package/skeleton/.4bnode/dev-api.js +568 -95
- package/skeleton/.4bnode/ui.html +1241 -240
- package/skeleton/.env.example +23 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +8 -0
- package/skeleton/public/index.html +345 -237
- package/skeleton/src/discovery.js +128 -6
- package/skeleton/src/middleware/apiKey.js +63 -20
|
@@ -1,74 +1,74 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { html, Icons } from "../lib.js";
|
|
2
|
+
import { html, Icons, NAV, MOD_KEY } from "../lib.js";
|
|
3
3
|
import { useApp } from "../context.js";
|
|
4
4
|
|
|
5
5
|
export function Sidebar() {
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
page, setPage, data, status, health,
|
|
8
|
+
collapsed, toggleCollapsed, mobileNav, setMobileNav, openPalette,
|
|
9
|
+
} = useApp();
|
|
7
10
|
const dbOk = data.mongoConfigured;
|
|
11
|
+
const online = !!health;
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
icon: Icons.dashboard,
|
|
14
|
-
section: "Overview",
|
|
15
|
-
},
|
|
16
|
-
// AI Builder — temporarily hidden from the menu (re-enable by uncommenting)
|
|
17
|
-
// { id: "ai-builder", label: "AI Builder", icon: Icons.ai },
|
|
18
|
-
{
|
|
19
|
-
id: "database",
|
|
20
|
-
label: "Database",
|
|
21
|
-
icon: Icons.database,
|
|
22
|
-
section: "Setup",
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
id: "schemas",
|
|
26
|
-
label: "Schemas",
|
|
27
|
-
icon: Icons.schema,
|
|
28
|
-
locked: !dbOk,
|
|
29
|
-
lockMsg: "Requires database",
|
|
30
|
-
},
|
|
31
|
-
{ id: "routes", label: "API Routes", icon: Icons.route },
|
|
32
|
-
{ id: "docs", label: "API Docs", icon: Icons.docs },
|
|
33
|
-
{ id: "api-tester", label: "API Tester", icon: Icons.apiTester },
|
|
34
|
-
{
|
|
35
|
-
id: "security",
|
|
36
|
-
label: "Security",
|
|
37
|
-
icon: Icons.shield,
|
|
38
|
-
section: "Features",
|
|
39
|
-
},
|
|
40
|
-
{ id: "realtime", label: "Real-time", icon: Icons.realtime },
|
|
41
|
-
{ id: "serialport", label: "SerialPort", icon: Icons.serial },
|
|
42
|
-
{ id: "mail", label: "Email", icon: Icons.mail },
|
|
43
|
-
{ id: "discovery", label: "Discovery", icon: Icons.network },
|
|
44
|
-
{ id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
|
|
45
|
-
{ id: "env", label: "Environment", icon: Icons.env },
|
|
46
|
-
];
|
|
13
|
+
function go(id) {
|
|
14
|
+
setPage(id);
|
|
15
|
+
setMobileNav(false);
|
|
16
|
+
}
|
|
47
17
|
|
|
48
18
|
let currentSection = null;
|
|
49
|
-
return html
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
html`<div className="sidebar-section">${item.section}</div>`)
|
|
58
|
-
: null;
|
|
59
|
-
return html`<${React.Fragment} key=${item.id}>
|
|
60
|
-
${sectionHeader}
|
|
61
|
-
<div
|
|
62
|
-
className=${`sidebar-item ${page === item.id ? "active" : ""} ${item.locked ? "locked" : ""}`}
|
|
63
|
-
onClick=${() => !item.locked && setPage(item.id)}
|
|
64
|
-
title=${item.locked ? item.lockMsg : item.label}
|
|
65
|
-
>
|
|
66
|
-
<span className="sidebar-icon">${item.icon}</span>
|
|
67
|
-
${item.label}
|
|
68
|
-
${item.locked &&
|
|
69
|
-
html`<span className="lock-icon">${Icons.lock}</span>`}
|
|
19
|
+
return html`<${React.Fragment}>
|
|
20
|
+
${mobileNav && html`<div className="sidebar-scrim" onClick=${() => setMobileNav(false)} />`}
|
|
21
|
+
<aside className=${`sidebar${collapsed ? " collapsed" : ""}${mobileNav ? " open" : ""}`}>
|
|
22
|
+
<div className="sidebar-brand">
|
|
23
|
+
<div className="brand-mark">4b</div>
|
|
24
|
+
<div className="brand-text">
|
|
25
|
+
<span className="brand-name">4bnode</span>
|
|
26
|
+
<span className="brand-app" title=${status.appName || ""}>${status.appName || "project"}</span>
|
|
70
27
|
</div>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<button className="sidebar-search" onClick=${openPalette} title="Search (${MOD_KEY}K)">
|
|
31
|
+
<span className="sidebar-icon">${Icons.search}</span>
|
|
32
|
+
<span className="sidebar-label">Search…</span>
|
|
33
|
+
<kbd className="sidebar-kbd">${MOD_KEY}K</kbd>
|
|
34
|
+
</button>
|
|
35
|
+
|
|
36
|
+
<nav className="sidebar-nav">
|
|
37
|
+
${NAV.map((item) => {
|
|
38
|
+
const locked = item.needsDb && !dbOk;
|
|
39
|
+
const sectionHeader =
|
|
40
|
+
item.section !== currentSection
|
|
41
|
+
? ((currentSection = item.section),
|
|
42
|
+
html`<div className="sidebar-section"><span>${item.section}</span></div>`)
|
|
43
|
+
: null;
|
|
44
|
+
return html`<${React.Fragment} key=${item.id}>
|
|
45
|
+
${sectionHeader}
|
|
46
|
+
<button
|
|
47
|
+
className=${`sidebar-item${page === item.id ? " active" : ""}${locked ? " locked" : ""}`}
|
|
48
|
+
onClick=${() => !locked && go(item.id)}
|
|
49
|
+
title=${locked ? "Requires database" : item.label}
|
|
50
|
+
aria-current=${page === item.id ? "page" : undefined}
|
|
51
|
+
>
|
|
52
|
+
<span className="sidebar-icon">${item.icon}</span>
|
|
53
|
+
<span className="sidebar-label">${item.label}</span>
|
|
54
|
+
${locked && html`<span className="lock-icon">${Icons.lock}</span>`}
|
|
55
|
+
</button>
|
|
56
|
+
<//>`;
|
|
57
|
+
})}
|
|
58
|
+
</nav>
|
|
59
|
+
|
|
60
|
+
<div className="sidebar-footer">
|
|
61
|
+
<div className="sidebar-status" title=${online ? "Server online" : "Server unreachable"}>
|
|
62
|
+
<span className=${`status-dot ${online ? "on" : "off"}`} />
|
|
63
|
+
<span className="sidebar-label">
|
|
64
|
+
${online ? `Online · ${health.node}` : "Connecting…"}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<button className="sidebar-collapse" onClick=${toggleCollapsed}
|
|
68
|
+
title=${collapsed ? "Expand sidebar ([)" : "Collapse sidebar ([)"}>
|
|
69
|
+
${collapsed ? Icons.expand : Icons.collapse}
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</aside>
|
|
73
|
+
<//>`;
|
|
74
74
|
}
|
|
@@ -24,6 +24,15 @@ import {
|
|
|
24
24
|
LuBookOpen,
|
|
25
25
|
LuMail,
|
|
26
26
|
LuWifi,
|
|
27
|
+
LuSearch,
|
|
28
|
+
LuPanelLeftClose,
|
|
29
|
+
LuPanelLeftOpen,
|
|
30
|
+
LuMenu,
|
|
31
|
+
LuExternalLink,
|
|
32
|
+
LuRefreshCw,
|
|
33
|
+
LuKeyboard,
|
|
34
|
+
LuCommand,
|
|
35
|
+
LuX,
|
|
27
36
|
} from "react-icons/lu";
|
|
28
37
|
|
|
29
38
|
export const html = htm.bind(React.createElement);
|
|
@@ -96,8 +105,75 @@ export const Icons = {
|
|
|
96
105
|
docs: html`<${LuBookOpen} size=${16} />`,
|
|
97
106
|
mail: html`<${LuMail} size=${16} />`,
|
|
98
107
|
network: html`<${LuWifi} size=${16} />`,
|
|
108
|
+
search: html`<${LuSearch} size=${15} />`,
|
|
109
|
+
collapse: html`<${LuPanelLeftClose} size=${16} />`,
|
|
110
|
+
expand: html`<${LuPanelLeftOpen} size=${16} />`,
|
|
111
|
+
menu: html`<${LuMenu} size=${18} />`,
|
|
112
|
+
external: html`<${LuExternalLink} size=${14} />`,
|
|
113
|
+
refresh: html`<${LuRefreshCw} size=${14} />`,
|
|
114
|
+
keyboard: html`<${LuKeyboard} size=${16} />`,
|
|
115
|
+
command: html`<${LuCommand} size=${16} />`,
|
|
116
|
+
close: html`<${LuX} size=${16} />`,
|
|
99
117
|
};
|
|
100
118
|
|
|
119
|
+
// Single source of truth for navigation — used by the Sidebar and the ⌘K
|
|
120
|
+
// command palette. `key` is the second key of the `g <key>` jump shortcut.
|
|
121
|
+
export const NAV = [
|
|
122
|
+
{ id: "dashboard", label: "Dashboard", icon: Icons.dashboard, section: "Overview", key: "d" },
|
|
123
|
+
// AI Builder — temporarily hidden from the menu (re-enable by uncommenting)
|
|
124
|
+
// { id: "ai-builder", label: "AI Builder", icon: Icons.ai, section: "Overview" },
|
|
125
|
+
{ id: "database", label: "Database", icon: Icons.database, section: "Build", key: "b" },
|
|
126
|
+
{ id: "schemas", label: "Schemas", icon: Icons.schema, section: "Build", key: "s", needsDb: true },
|
|
127
|
+
{ id: "routes", label: "API Routes", icon: Icons.route, section: "Build", key: "r" },
|
|
128
|
+
{ id: "docs", label: "API Docs", icon: Icons.docs, section: "Build", key: "o" },
|
|
129
|
+
{ id: "api-tester", label: "API Tester", icon: Icons.apiTester, section: "Build", key: "t" },
|
|
130
|
+
{ id: "security", label: "Security", icon: Icons.shield, section: "Features", key: "x" },
|
|
131
|
+
{ id: "realtime", label: "Real-time", icon: Icons.realtime, section: "Features", key: "w" },
|
|
132
|
+
{ id: "serialport", label: "SerialPort", icon: Icons.serial, section: "Features", key: "p" },
|
|
133
|
+
{ id: "mail", label: "Email", icon: Icons.mail, section: "Features", key: "m" },
|
|
134
|
+
{ id: "discovery", label: "Discovery", icon: Icons.network, section: "Features", key: "n" },
|
|
135
|
+
{ id: "logs", label: "Logs", icon: Icons.logs, section: "System", key: "l" },
|
|
136
|
+
{ id: "env", label: "Environment", icon: Icons.env, section: "System", key: "e" },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
140
|
+
export const MOD_KEY = IS_MAC ? "⌘" : "Ctrl";
|
|
141
|
+
|
|
142
|
+
export function formatBytes(n) {
|
|
143
|
+
if (n == null) return "—";
|
|
144
|
+
const u = ["B", "KB", "MB", "GB"];
|
|
145
|
+
let i = 0;
|
|
146
|
+
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
147
|
+
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatDuration(sec) {
|
|
151
|
+
if (sec == null) return "—";
|
|
152
|
+
if (sec < 60) return `${sec}s`;
|
|
153
|
+
const m = Math.floor(sec / 60);
|
|
154
|
+
if (m < 60) return `${m}m ${sec % 60}s`;
|
|
155
|
+
const h = Math.floor(m / 60);
|
|
156
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
157
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function timeAgo(ts) {
|
|
161
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
162
|
+
if (s < 5) return "just now";
|
|
163
|
+
if (s < 60) return `${s}s ago`;
|
|
164
|
+
const m = Math.floor(s / 60);
|
|
165
|
+
if (m < 60) return `${m}m ago`;
|
|
166
|
+
const h = Math.floor(m / 60);
|
|
167
|
+
return h < 24 ? `${h}h ago` : `${Math.floor(h / 24)}d ago`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// True when a key event comes from a text field — global shortcuts skip these.
|
|
171
|
+
export function isTypingTarget(el) {
|
|
172
|
+
if (!el) return false;
|
|
173
|
+
const tag = el.tagName;
|
|
174
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
|
175
|
+
}
|
|
176
|
+
|
|
101
177
|
export const PAGE_TITLES = {
|
|
102
178
|
dashboard: "Dashboard",
|
|
103
179
|
"ai-builder": "AI Builder",
|
|
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
|
|
|
2
2
|
import { motion, AnimatePresence } from "framer-motion";
|
|
3
3
|
import { io as socketIoConnect } from "socket.io-client";
|
|
4
4
|
import { LuChevronLeft, LuCircleDot, LuCopy, LuImport, LuLoader, LuPencil, LuPlugZap, LuPlus, LuRadio, LuSave, LuSend, LuShare2, LuTrash2, LuWifi, LuWifiOff } from "react-icons/lu";
|
|
5
|
-
import { html, api, Icons, copyToClipboard } from "../lib.js";
|
|
5
|
+
import { html, api, Icons, copyToClipboard, timeAgo } from "../lib.js";
|
|
6
6
|
import { useApp } from "../context.js";
|
|
7
7
|
import { Modal } from "../components/Modal.js";
|
|
8
8
|
|
|
@@ -135,7 +135,7 @@ function SocketIOTester({ selectedSaved, isSaved, loadedTest, onSaveTest, onSave
|
|
|
135
135
|
</button>`}
|
|
136
136
|
</div>
|
|
137
137
|
|
|
138
|
-
<div style=${{
|
|
138
|
+
<div className="grid-2" style=${{ marginBottom: '16px' }}>
|
|
139
139
|
<!-- Emit -->
|
|
140
140
|
<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
|
|
141
141
|
<div style=${{ padding: '10px 14px', borderBottom: '1px solid var(--border)', fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
|
|
@@ -430,12 +430,12 @@ export function SavedResponseDetail({ resp, parentTest, onBack, onUpdate }) {
|
|
|
430
430
|
<div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
431
431
|
<span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Request</span>
|
|
432
432
|
<span className=${`route-method ${(parentTest.method || 'GET').toLowerCase()}`} style=${{ fontSize: '10px', padding: '1px 6px' }}>${parentTest.method || 'GET'}</span>
|
|
433
|
-
<span style=${{ fontFamily: "
|
|
433
|
+
<span style=${{ fontFamily: "var(--mono)", fontSize: '12px', color: 'var(--text-primary)' }}>${parentTest.url}</span>
|
|
434
434
|
</div>
|
|
435
435
|
${(editReqBody?.trim() || editing) && html`<div style=${{ padding: editing ? '0' : '10px 16px' }}>
|
|
436
436
|
${!editing && html`<div>
|
|
437
437
|
<div style=${{ padding: '0 0 6px', fontSize: '10px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px', color: 'var(--text-muted)' }}>Body Sent</div>
|
|
438
|
-
<pre style=${{ margin: 0, fontFamily: "
|
|
438
|
+
<pre style=${{ margin: 0, fontFamily: "var(--mono)", fontSize: '12px', lineHeight: '1.5', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>${editReqBody}</pre>
|
|
439
439
|
</div>`}
|
|
440
440
|
${editing && html`<textarea className="api-tester-body-editor" value=${editReqBody}
|
|
441
441
|
onInput=${(e) => setEditReqBody(e.target.value)} spellcheck=${false}
|
|
@@ -616,28 +616,67 @@ export function ApiTesterPage() {
|
|
|
616
616
|
// ── Request history (persisted per-browser) ──
|
|
617
617
|
const [history, setHistory] = useState([]);
|
|
618
618
|
const [showHistory, setShowHistory] = useState(false);
|
|
619
|
+
const [historyQuery, setHistoryQuery] = useState('');
|
|
619
620
|
useEffect(() => {
|
|
620
|
-
try { const h = JSON.parse(
|
|
621
|
+
try { const h = JSON.parse(localStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
|
|
621
622
|
}, []);
|
|
623
|
+
// Each entry stores the full request (not the response) so it can be replayed.
|
|
624
|
+
// File contents are stripped to keep storage small.
|
|
622
625
|
function pushHistory(entry) {
|
|
626
|
+
const req = {
|
|
627
|
+
headers: headers.map(h => ({ ...h })),
|
|
628
|
+
body,
|
|
629
|
+
bodyType,
|
|
630
|
+
params: params.map(p => ({ ...p })),
|
|
631
|
+
urlParams: urlParams.map(p => ({ ...p })),
|
|
632
|
+
formFields: formFields.map(({ fileData, ...f }) => f),
|
|
633
|
+
};
|
|
623
634
|
setHistory(prev => {
|
|
624
|
-
const next = [entry, ...prev].slice(0,
|
|
625
|
-
try {
|
|
635
|
+
const next = [{ ...entry, req }, ...prev].slice(0, 100);
|
|
636
|
+
try { localStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
|
|
626
637
|
return next;
|
|
627
638
|
});
|
|
628
639
|
}
|
|
629
640
|
function clearHistory() {
|
|
630
641
|
setHistory([]);
|
|
631
|
-
try {
|
|
642
|
+
try { localStorage.removeItem('_4bnode_history'); } catch {}
|
|
643
|
+
}
|
|
644
|
+
function historySnap(h) {
|
|
645
|
+
return { method: h.method, url: h.url, ...(h.req || {}), response: null, activeTab: 'body' };
|
|
632
646
|
}
|
|
633
647
|
function loadHistory(h) {
|
|
634
|
-
|
|
635
|
-
setUrl(h.url);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
648
|
+
if (h.req) loadSnap(historySnap(h));
|
|
649
|
+
else { setMethod(h.method); setUrl(h.url); setResponse(null); setSelectedSaved(null); setSelectedResponse(null); }
|
|
650
|
+
setShowHistory(false);
|
|
651
|
+
}
|
|
652
|
+
function openHistoryInNewTab(h) {
|
|
653
|
+
const snap = captureSnap();
|
|
654
|
+
const id = Math.max(0, ...tabs.map(t => t.id)) + 1;
|
|
655
|
+
setTabs(prev => [...prev.map(t => t.id === activeTabId ? { ...t, snap } : t), { id, snap: historySnap(h) }]);
|
|
656
|
+
loadSnap(historySnap(h));
|
|
657
|
+
setActiveTabId(id);
|
|
639
658
|
setShowHistory(false);
|
|
640
659
|
}
|
|
660
|
+
const filteredHistory = historyQuery.trim()
|
|
661
|
+
? history.filter(h => (h.method + ' ' + h.url + ' ' + h.status).toLowerCase().includes(historyQuery.trim().toLowerCase()))
|
|
662
|
+
: history;
|
|
663
|
+
|
|
664
|
+
// Keyboard shortcuts (REST mode): ⌘/Ctrl+Enter send, Alt+N new tab,
|
|
665
|
+
// Alt+W close tab, Alt+H history. A ref keeps the handler on fresh state.
|
|
666
|
+
const shortcutRef = useRef(null);
|
|
667
|
+
shortcutRef.current = (e) => {
|
|
668
|
+
if (testerMode !== 'rest' || document.querySelector('.palette-overlay, .modal-overlay')) return;
|
|
669
|
+
const k = e.key.toLowerCase();
|
|
670
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (!loading && url.trim()) sendRequest(); }
|
|
671
|
+
else if (e.altKey && (k === 'n' || e.code === 'KeyN')) { e.preventDefault(); newTab(); }
|
|
672
|
+
else if (e.altKey && (k === 'w' || e.code === 'KeyW')) { e.preventDefault(); closeTab(activeTabId); }
|
|
673
|
+
else if (e.altKey && (k === 'h' || e.code === 'KeyH')) { e.preventDefault(); setShowHistory(v => !v); setShowVars(false); }
|
|
674
|
+
};
|
|
675
|
+
useEffect(() => {
|
|
676
|
+
const onKey = (e) => shortcutRef.current && shortcutRef.current(e);
|
|
677
|
+
window.addEventListener('keydown', onKey);
|
|
678
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
679
|
+
}, []);
|
|
641
680
|
|
|
642
681
|
// Latest-committed mirrors of tests/responses. Persistence reads these so a PUT
|
|
643
682
|
// always sends the LATEST tests AND responses — never a stale half from an older
|
|
@@ -909,6 +948,15 @@ export function ApiTesterPage() {
|
|
|
909
948
|
hdrs[auth.apiKeyKey.trim()] = subst(auth.apiKeyValue);
|
|
910
949
|
}
|
|
911
950
|
const finalUrl = subst(buildDisplayUrl());
|
|
951
|
+
// App-wide API key gate: send {{apiKey}} as x-api-key, but only to this
|
|
952
|
+
// machine, so the key never leaks to an external URL.
|
|
953
|
+
const apiKeyVar = variables.find(v => v.key === 'apiKey' && v.value);
|
|
954
|
+
if (apiKeyVar && !Object.keys(hdrs).some(h => h.toLowerCase() === 'x-api-key')) {
|
|
955
|
+
try {
|
|
956
|
+
const host = new URL(finalUrl).hostname;
|
|
957
|
+
if ([window.location.hostname, 'localhost', '127.0.0.1', '[::1]'].includes(host)) hdrs['x-api-key'] = apiKeyVar.value;
|
|
958
|
+
} catch {}
|
|
959
|
+
}
|
|
912
960
|
// For base64 send-as, convert form field value into body JSON
|
|
913
961
|
let sendBody = undefined;
|
|
914
962
|
let sendBodyType = bodyType;
|
|
@@ -1304,7 +1352,7 @@ export function ApiTesterPage() {
|
|
|
1304
1352
|
const displayUrl = buildDisplayUrl();
|
|
1305
1353
|
|
|
1306
1354
|
const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
1307
|
-
const methodColors = { GET: 'var(--success)', POST: '
|
|
1355
|
+
const methodColors = { GET: 'var(--success)', POST: 'var(--accent-light)', PUT: 'var(--warning)', PATCH: '#b48cff', DELETE: 'var(--error)' };
|
|
1308
1356
|
|
|
1309
1357
|
const modeTabs = [
|
|
1310
1358
|
{ id: 'rest', label: 'REST API', icon: LuSend },
|
|
@@ -1364,8 +1412,8 @@ export function ApiTesterPage() {
|
|
|
1364
1412
|
</span>`}
|
|
1365
1413
|
${resps.length === 0 && html`<span style=${{ width: '14px', flexShrink: 0 }} />`}
|
|
1366
1414
|
${testType === 'rest' && html`<span className=${`route-method ${(test.method || 'GET').toLowerCase()}`} style=${{ fontSize: '9px', padding: '1px 5px' }}>${test.method || 'GET'}</span>`}
|
|
1367
|
-
${testType === 'socketio' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "
|
|
1368
|
-
${testType === 'websocket' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "
|
|
1415
|
+
${testType === 'socketio' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "var(--mono)", ...typeBadgeStyles.socketio }}>SIO</span>`}
|
|
1416
|
+
${testType === 'websocket' && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "var(--mono)", ...typeBadgeStyles.websocket }}>WS</span>`}
|
|
1369
1417
|
<span style=${{ fontSize: '12px', fontWeight: 500, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${test.name}</span>
|
|
1370
1418
|
${resps.length > 0 && html`<span style=${{ fontSize: '9px', padding: '1px 5px', borderRadius: '8px', background: 'var(--accent-glow)', color: 'var(--accent-light)', fontWeight: 600 }}>${resps.length}</span>`}
|
|
1371
1419
|
<button className="btn btn-ghost" style=${{ padding: '2px', opacity: 0.4 }}
|
|
@@ -1385,7 +1433,7 @@ export function ApiTesterPage() {
|
|
|
1385
1433
|
onClick=${() => viewSavedResponse(test.id, r.id)}>
|
|
1386
1434
|
<div className="flex items-center gap-2">
|
|
1387
1435
|
${isSocketChild
|
|
1388
|
-
? html`<span style=${{ fontSize: '10px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "
|
|
1436
|
+
? html`<span style=${{ fontSize: '10px', padding: '1px 5px', borderRadius: '4px', fontWeight: 700, fontFamily: "var(--mono)",
|
|
1389
1437
|
background: r.dir === 'received' ? 'rgba(52,211,153,0.15)' : r.dir === 'sent' ? 'rgba(245,158,11,0.15)' : 'rgba(148,163,184,0.15)',
|
|
1390
1438
|
color: r.dir === 'received' ? '#34d399' : r.dir === 'sent' ? '#f59e0b' : '#94a3b8' }}>
|
|
1391
1439
|
${r.dir === 'received' ? '↓' : r.dir === 'sent' ? '↑' : '●'}
|
|
@@ -1432,20 +1480,24 @@ export function ApiTesterPage() {
|
|
|
1432
1480
|
|
|
1433
1481
|
${testerMode === 'rest' && html`<div>
|
|
1434
1482
|
<!-- Request tabs -->
|
|
1435
|
-
<div
|
|
1436
|
-
${tabs.map(t => {
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1483
|
+
<div className="req-tabs" role="tablist">
|
|
1484
|
+
${tabs.map(t => {
|
|
1485
|
+
const active = t.id === activeTabId;
|
|
1486
|
+
const s = active ? { method, url } : (t.snap || {});
|
|
1487
|
+
const label = tabLabel(t);
|
|
1488
|
+
const m = s.url ? (s.method || 'GET') : null;
|
|
1489
|
+
return html`<div key=${t.id} role="tab" aria-selected=${active}
|
|
1490
|
+
className=${`req-tab${active ? ' active' : ''}`}
|
|
1491
|
+
onClick=${() => switchTab(t.id)}
|
|
1492
|
+
onAuxClick=${(e) => { if (e.button === 1) { e.preventDefault(); closeTab(t.id); } }}
|
|
1493
|
+
title=${s.url || 'New Request'}>
|
|
1494
|
+
${m && html`<span className="req-tab-method" style=${{ color: methodColors[m] || 'var(--text-secondary)' }}>${m}</span>`}
|
|
1495
|
+
<span className="req-tab-label">${m ? label.slice(m.length + 1) : label}</span>
|
|
1496
|
+
${tabs.length > 1 && html`<button className="req-tab-close" aria-label="Close tab"
|
|
1497
|
+
onClick=${(e) => { e.stopPropagation(); closeTab(t.id); }}>×</button>`}
|
|
1498
|
+
</div>`;
|
|
1499
|
+
})}
|
|
1500
|
+
<button className="req-tab-new" onClick=${newTab} title="New request tab (Alt+N)"><${LuPlus} size=${14} /></button>
|
|
1449
1501
|
</div>
|
|
1450
1502
|
|
|
1451
1503
|
<!-- Request bar -->
|
|
@@ -1464,7 +1516,7 @@ export function ApiTesterPage() {
|
|
|
1464
1516
|
onInput=${(e) => setUrl(e.target.value)}
|
|
1465
1517
|
onKeyDown=${(e) => { if (e.key === 'Enter') sendRequest(); }}
|
|
1466
1518
|
/>
|
|
1467
|
-
<button className="api-tester-send" onClick=${sendRequest} disabled=${loading || !url.trim()}>
|
|
1519
|
+
<button className="api-tester-send" onClick=${sendRequest} disabled=${loading || !url.trim()} title="Send (Ctrl/⌘+Enter)">
|
|
1468
1520
|
${loading ? html`<${LuLoader} size=${14} className="spin" />` : html`<${LuSend} size=${14} />`}
|
|
1469
1521
|
Send
|
|
1470
1522
|
</button>
|
|
@@ -1474,7 +1526,7 @@ export function ApiTesterPage() {
|
|
|
1474
1526
|
Variables${variables.filter(v => v.key?.trim()).length ? ` (${variables.filter(v => v.key?.trim()).length})` : ''}
|
|
1475
1527
|
</button>
|
|
1476
1528
|
<button className="api-tester-send" onClick=${() => { setShowHistory(h => !h); setShowVars(false); }}
|
|
1477
|
-
title="Request history"
|
|
1529
|
+
title="Request history (Alt+H)"
|
|
1478
1530
|
style=${{ background: showHistory ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showHistory ? 'var(--accent-light)' : 'var(--text-primary)' }}>
|
|
1479
1531
|
History
|
|
1480
1532
|
</button>
|
|
@@ -1495,7 +1547,7 @@ export function ApiTesterPage() {
|
|
|
1495
1547
|
|
|
1496
1548
|
${displayUrl !== url && html`<div style=${{
|
|
1497
1549
|
padding: '6px 14px', fontSize: '11px', color: 'var(--text-muted)',
|
|
1498
|
-
fontFamily: "
|
|
1550
|
+
fontFamily: "var(--mono)", background: 'rgba(91,140,255,0.04)',
|
|
1499
1551
|
borderRadius: '6px', marginBottom: '8px', wordBreak: 'break-all',
|
|
1500
1552
|
}}>${displayUrl}</div>`}
|
|
1501
1553
|
|
|
@@ -1520,24 +1572,32 @@ export function ApiTesterPage() {
|
|
|
1520
1572
|
</button>
|
|
1521
1573
|
</div>
|
|
1522
1574
|
<div className="text-sm text-muted" style=${{ marginTop: '6px' }}>
|
|
1523
|
-
Reference anywhere with <span style=${{ fontFamily: "
|
|
1575
|
+
Reference anywhere with <span style=${{ fontFamily: "var(--mono)", color: 'var(--accent-light)' }}>{{name}}</span> — URL, headers, body, or Auth.
|
|
1524
1576
|
</div>
|
|
1525
1577
|
</div>`}
|
|
1526
1578
|
|
|
1527
|
-
${showHistory && html`<div className="
|
|
1528
|
-
<div
|
|
1529
|
-
<span style=${{ fontSize: '
|
|
1530
|
-
|
|
1579
|
+
${showHistory && html`<div className="hist-panel">
|
|
1580
|
+
<div className="hist-head">
|
|
1581
|
+
<span style=${{ fontSize: '12px', fontWeight: 600, marginRight: '4px' }}>History</span>
|
|
1582
|
+
<input placeholder="Filter by method, URL or status…" value=${historyQuery}
|
|
1583
|
+
onInput=${e => setHistoryQuery(e.target.value)} autoFocus />
|
|
1584
|
+
${history.length > 0 && html`<button className="btn btn-ghost btn-sm" onClick=${clearHistory}>Clear</button>`}
|
|
1585
|
+
</div>
|
|
1586
|
+
<div className="hist-list">
|
|
1587
|
+
${filteredHistory.length === 0
|
|
1588
|
+
? html`<div style=${{ padding: '16px', color: 'var(--text-muted)', fontSize: '12px' }}>
|
|
1589
|
+
${history.length ? 'No matching requests.' : 'No requests yet — sent requests show up here.'}
|
|
1590
|
+
</div>`
|
|
1591
|
+
: filteredHistory.map(h => html`<div key=${h.id} className="hist-row" onClick=${() => loadHistory(h)} title="Load into current tab">
|
|
1592
|
+
<span style=${{ fontWeight: 600, fontFamily: 'var(--mono)', fontSize: '11px', color: methodColors[h.method] || 'var(--text-secondary)', minWidth: '50px' }}>${h.method}</span>
|
|
1593
|
+
<span style=${{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--mono)' }}>${h.url}</span>
|
|
1594
|
+
<span style=${{ fontWeight: 600, fontFamily: 'var(--mono)', color: h.status >= 200 && h.status < 300 ? 'var(--success)' : h.status === 0 ? 'var(--text-muted)' : h.status >= 500 ? 'var(--error)' : 'var(--warning)' }}>${h.status || 'ERR'}</span>
|
|
1595
|
+
<span style=${{ color: 'var(--text-muted)', minWidth: '52px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>${h.time ? h.time + ' ms' : ''}</span>
|
|
1596
|
+
<span style=${{ color: 'var(--text-muted)', minWidth: '58px', textAlign: 'right' }}>${timeAgo(h.ts)}</span>
|
|
1597
|
+
<button className="btn btn-ghost btn-sm hist-open" title="Open in new tab"
|
|
1598
|
+
onClick=${(e) => { e.stopPropagation(); openHistoryInNewTab(h); }}><${LuPlus} size=${12} /> Tab</button>
|
|
1599
|
+
</div>`)}
|
|
1531
1600
|
</div>
|
|
1532
|
-
${history.length === 0
|
|
1533
|
-
? html`<div style=${{ padding: '16px', color: 'var(--text-muted)', fontSize: '12px' }}>No requests yet — sent requests show up here.</div>`
|
|
1534
|
-
: history.map(h => html`<div key=${h.id}
|
|
1535
|
-
onClick=${() => loadHistory(h)}
|
|
1536
|
-
style=${{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', fontSize: '12px' }}>
|
|
1537
|
-
<span style=${{ fontWeight: 600, color: methodColors[h.method] || 'var(--text-secondary)', minWidth: '46px' }}>${h.method}</span>
|
|
1538
|
-
<span style=${{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'SF Mono', monospace" }}>${h.url}</span>
|
|
1539
|
-
<span style=${{ fontWeight: 600, color: h.status >= 200 && h.status < 300 ? 'var(--success)' : h.status === 0 ? 'var(--text-muted)' : 'var(--warning)' }}>${h.status || '—'}</span>
|
|
1540
|
-
</div>`)}
|
|
1541
1601
|
</div>`}
|
|
1542
1602
|
|
|
1543
1603
|
<!-- URL Params (:id, etc.) -->
|
|
@@ -1547,12 +1607,12 @@ export function ApiTesterPage() {
|
|
|
1547
1607
|
</div>
|
|
1548
1608
|
${urlParams.map(p => html`<div key=${p.id} className="api-tester-kv-row">
|
|
1549
1609
|
<input value=${p.key} disabled style=${{
|
|
1550
|
-
flex: '0 0 120px', opacity: 0.6, fontWeight: 600, fontFamily: "
|
|
1610
|
+
flex: '0 0 120px', opacity: 0.6, fontWeight: 600, fontFamily: "var(--mono)",
|
|
1551
1611
|
color: 'var(--accent-light)', background: 'var(--bg-input)', fontSize: '12px',
|
|
1552
1612
|
}} />
|
|
1553
1613
|
<input placeholder=${'Enter ' + p.key + ' value'} value=${p.value}
|
|
1554
1614
|
onInput=${(e) => setUrlParams(prev => prev.map(up => up.id === p.id ? { ...up, value: e.target.value } : up))}
|
|
1555
|
-
style=${{ fontFamily: "
|
|
1615
|
+
style=${{ fontFamily: "var(--mono)", fontSize: '12px' }} />
|
|
1556
1616
|
</div>`)}
|
|
1557
1617
|
</div>`}
|
|
1558
1618
|
|
|
@@ -1837,8 +1897,8 @@ export function ApiTesterPage() {
|
|
|
1837
1897
|
display: 'flex', padding: '4px 14px', fontSize: '12px', gap: '12px',
|
|
1838
1898
|
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
|
1839
1899
|
}}>
|
|
1840
|
-
<span style=${{ fontWeight: 600, color: 'var(--accent-light)', minWidth: '180px', fontFamily: "
|
|
1841
|
-
<span style=${{ color: 'var(--text-secondary)', fontFamily: "
|
|
1900
|
+
<span style=${{ fontWeight: 600, color: 'var(--accent-light)', minWidth: '180px', fontFamily: "var(--mono)", fontSize: '11px' }}>${k}</span>
|
|
1901
|
+
<span style=${{ color: 'var(--text-secondary)', fontFamily: "var(--mono)", fontSize: '11px', wordBreak: 'break-all' }}>${v}</span>
|
|
1842
1902
|
</div>`)}
|
|
1843
1903
|
</div>`}
|
|
1844
1904
|
</div>`}
|
|
@@ -1911,7 +1971,7 @@ export function ApiTesterPage() {
|
|
|
1911
1971
|
onMouseOver=${(e) => e.currentTarget.style.background = 'var(--bg-input)'}
|
|
1912
1972
|
onMouseOut=${(e) => e.currentTarget.style.background = 'transparent'}>
|
|
1913
1973
|
<span className=${`route-method ${ep.method.toLowerCase()}`} style=${{ fontSize: '10px', padding: '2px 7px' }}>${ep.method}</span>
|
|
1914
|
-
<span style=${{ fontFamily: "
|
|
1974
|
+
<span style=${{ fontFamily: "var(--mono)", fontSize: '12px', color: 'var(--text-primary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${ep.fullPath}</span>
|
|
1915
1975
|
${ep.bodyFields?.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', flexShrink: 0 }}>${ep.bodyFields.length} fields</span>`}
|
|
1916
1976
|
</div>`)}
|
|
1917
1977
|
</div>`)}
|