4bnode 4.0.3 → 4.0.4
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/README.md +18 -0
- package/add-crud.js +1 -1
- package/add-mongo-update.js +1 -1
- package/index.js +4 -6
- package/lib/ui.js +0 -1
- package/package.json +2 -2
- package/skeleton/.4bnode/assets/js/App.js +4 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +3 -1
- package/skeleton/.4bnode/assets/js/lib.js +20 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +55 -36
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +667 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +176 -147
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +131 -4
- package/skeleton/.4bnode/dev-api.js +774 -39
package/README.md
CHANGED
|
@@ -155,6 +155,24 @@ Access at `http://localhost:3000/_dev` when running `npm run dev`.
|
|
|
155
155
|
|
|
156
156
|
**SerialPort** - Serial port setup with path and baud rate config
|
|
157
157
|
|
|
158
|
+
**Reports** - Export MongoDB data as Excel (.xlsx) reports:
|
|
159
|
+
- Select models and fields to include
|
|
160
|
+
- Include system fields (_id, createdAt, updatedAt)
|
|
161
|
+
- Populate referenced ObjectId fields with sub-field selection
|
|
162
|
+
- Custom column headers and field prefixes
|
|
163
|
+
- Drag-and-drop column reordering
|
|
164
|
+
- Configurable date format and timezone
|
|
165
|
+
- Set row limit for large collections
|
|
166
|
+
- Preview data before downloading
|
|
167
|
+
- Download as `.xlsx` file
|
|
168
|
+
|
|
169
|
+
**Logs** - Real-time server log monitoring:
|
|
170
|
+
- Live log streaming via SSE (Server-Sent Events)
|
|
171
|
+
- Filter by log type (LOG, INFO, WARN, ERROR)
|
|
172
|
+
- Search through log entries
|
|
173
|
+
- Auto-scroll with toggle
|
|
174
|
+
- Keeps last 500 log entries
|
|
175
|
+
|
|
158
176
|
**Environment** - View and edit `.env.development` and `.env.production`
|
|
159
177
|
|
|
160
178
|
## Generated Project Structure
|
package/add-crud.js
CHANGED
|
@@ -97,7 +97,7 @@ ${assignments}
|
|
|
97
97
|
};
|
|
98
98
|
${passwordLogic}
|
|
99
99
|
try {
|
|
100
|
-
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, {
|
|
100
|
+
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
|
|
101
101
|
if (!updatedDocument) {
|
|
102
102
|
return res.status(404).json({ message: 'Document not found' });
|
|
103
103
|
}
|
package/add-mongo-update.js
CHANGED
|
@@ -84,7 +84,7 @@ ${fieldAssignments}
|
|
|
84
84
|
};
|
|
85
85
|
${passwordLogic}
|
|
86
86
|
try {
|
|
87
|
-
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, {
|
|
87
|
+
const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { returnDocument: 'after' });
|
|
88
88
|
if (!updatedDocument) {
|
|
89
89
|
return res.status(404).json({ message: 'Document not found' });
|
|
90
90
|
}
|
package/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import crypto from "crypto";
|
|
6
|
-
import { execFileSync } from "child_process";
|
|
6
|
+
import { execSync, execFileSync } from "child_process";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import chalk from "chalk";
|
|
9
9
|
import { input, select, password } from "@inquirer/prompts";
|
|
@@ -107,7 +107,7 @@ async function createSkeletonApp(projectName) {
|
|
|
107
107
|
|
|
108
108
|
const installSpinner = createSpinner("Installing dependencies...");
|
|
109
109
|
installSpinner.start();
|
|
110
|
-
|
|
110
|
+
execSync("npm install", { stdio: "pipe", cwd: targetPath });
|
|
111
111
|
installSpinner.succeed(chalk.hex("#00f260").bold("Dependencies installed"));
|
|
112
112
|
|
|
113
113
|
showSuccess([
|
|
@@ -123,9 +123,8 @@ async function createSkeletonApp(projectName) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
function runCommand(script, args = []) {
|
|
126
|
-
execFileSync(
|
|
126
|
+
execFileSync(process.execPath, [path.join(__dirname, script), ...args], {
|
|
127
127
|
stdio: "inherit",
|
|
128
|
-
shell: true,
|
|
129
128
|
});
|
|
130
129
|
}
|
|
131
130
|
|
|
@@ -369,10 +368,9 @@ async function main() {
|
|
|
369
368
|
process.exit(1);
|
|
370
369
|
}
|
|
371
370
|
console.log(chalk.hex("#00f260").bold("\n Starting dev server...\n"));
|
|
372
|
-
|
|
371
|
+
execSync("npm run dev", {
|
|
373
372
|
stdio: "inherit",
|
|
374
373
|
cwd: projectRoot,
|
|
375
|
-
shell: true,
|
|
376
374
|
});
|
|
377
375
|
}
|
|
378
376
|
// Legacy commands → route through handleAdd
|
package/lib/ui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "4bnode",
|
|
3
|
-
"version": "4.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "4.0.4",
|
|
4
|
+
"description": "4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -12,6 +12,8 @@ import { RoutesPage } from "./pages/RoutesPage.js";
|
|
|
12
12
|
import { ApiTesterPage } from "./pages/ApiTesterPage.js";
|
|
13
13
|
import { RealtimePage } from "./pages/RealtimePage.js";
|
|
14
14
|
import { SerialPortPage } from "./pages/SerialPortPage.js";
|
|
15
|
+
import { LogsPage } from "./pages/LogsPage.js";
|
|
16
|
+
import { ReportsPage } from "./pages/ReportsPage.js";
|
|
15
17
|
import { EnvironmentPage } from "./pages/EnvironmentPage.js";
|
|
16
18
|
|
|
17
19
|
function getHashPage() {
|
|
@@ -79,6 +81,8 @@ export function App() {
|
|
|
79
81
|
"api-tester": html`<${ApiTesterPage} />`,
|
|
80
82
|
realtime: html`<${RealtimePage} />`,
|
|
81
83
|
serialport: html`<${SerialPortPage} />`,
|
|
84
|
+
reports: html`<${ReportsPage} />`,
|
|
85
|
+
logs: html`<${LogsPage} />`,
|
|
82
86
|
env: html`<${EnvironmentPage} />`,
|
|
83
87
|
};
|
|
84
88
|
|
|
@@ -35,7 +35,9 @@ export function Sidebar() {
|
|
|
35
35
|
section: "Features",
|
|
36
36
|
},
|
|
37
37
|
{ id: "serialport", label: "SerialPort", icon: Icons.serial },
|
|
38
|
-
{ id: "
|
|
38
|
+
{ id: "reports", label: "Reports", icon: Icons.reports, locked: !dbOk, lockMsg: "Requires database" },
|
|
39
|
+
{ id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
|
|
40
|
+
{ id: "env", label: "Environment", icon: Icons.env },
|
|
39
41
|
];
|
|
40
42
|
|
|
41
43
|
let currentSection = null;
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
LuFlaskConical,
|
|
20
20
|
LuSend,
|
|
21
21
|
LuSettings,
|
|
22
|
+
LuScrollText,
|
|
23
|
+
LuFileSpreadsheet,
|
|
22
24
|
} from "react-icons/lu";
|
|
23
25
|
|
|
24
26
|
export const html = htm.bind(React.createElement);
|
|
@@ -28,6 +30,20 @@ export function getStoredPasskey() {
|
|
|
28
30
|
return sessionStorage.getItem("_dev_passkey") || "";
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
export function copyToClipboard(text) {
|
|
34
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
35
|
+
return navigator.clipboard.writeText(text);
|
|
36
|
+
}
|
|
37
|
+
const ta = document.createElement("textarea");
|
|
38
|
+
ta.value = text;
|
|
39
|
+
ta.style.cssText = "position:fixed;opacity:0";
|
|
40
|
+
document.body.appendChild(ta);
|
|
41
|
+
ta.select();
|
|
42
|
+
document.execCommand("copy");
|
|
43
|
+
document.body.removeChild(ta);
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
export async function api(path, options = {}) {
|
|
32
48
|
const passkey = getStoredPasskey();
|
|
33
49
|
const headers = { "Content-Type": "application/json" };
|
|
@@ -69,6 +85,8 @@ export const Icons = {
|
|
|
69
85
|
box: html`<${LuBox} size=${16} />`,
|
|
70
86
|
apiTester: html`<${LuFlaskConical} size=${16} />`,
|
|
71
87
|
send: html`<${LuSend} size=${16} />`,
|
|
88
|
+
logs: html`<${LuScrollText} size=${16} />`,
|
|
89
|
+
reports: html`<${LuFileSpreadsheet} size=${16} />`,
|
|
72
90
|
};
|
|
73
91
|
|
|
74
92
|
export const PAGE_TITLES = {
|
|
@@ -79,5 +97,7 @@ export const PAGE_TITLES = {
|
|
|
79
97
|
"api-tester": "API Tester",
|
|
80
98
|
realtime: "Real-time",
|
|
81
99
|
serialport: "SerialPort",
|
|
100
|
+
logs: "Logs",
|
|
101
|
+
reports: "Reports",
|
|
82
102
|
env: "Environment",
|
|
83
103
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
-
import { motion } from "framer-motion";
|
|
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 } from "../lib.js";
|
|
5
|
+
import { html, api, Icons, copyToClipboard } from "../lib.js";
|
|
6
6
|
import { useApp } from "../context.js";
|
|
7
7
|
import { Modal } from "../components/Modal.js";
|
|
8
8
|
|
|
@@ -66,7 +66,6 @@ function SocketIOTester({ selectedSaved, isSaved, loadedTest, onSaveTest, onSave
|
|
|
66
66
|
try { parsed = JSON.parse(parsed); } catch {}
|
|
67
67
|
sioSocket.emit(sioEvent, parsed);
|
|
68
68
|
addLog('sent', sioEvent + ': ' + (typeof parsed === 'object' ? JSON.stringify(parsed) : parsed));
|
|
69
|
-
setSioData('');
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
function addListener() {
|
|
@@ -672,7 +671,7 @@ export function ApiTesterPage() {
|
|
|
672
671
|
body: { tests: savedTests, responses: savedResponses },
|
|
673
672
|
});
|
|
674
673
|
const shareUrl = window.location.origin + '/_dev/shared/' + result.id;
|
|
675
|
-
|
|
674
|
+
copyToClipboard(shareUrl);
|
|
676
675
|
const now = Date.now();
|
|
677
676
|
setLastSharedAt(now);
|
|
678
677
|
const snap = JSON.stringify({ t: savedTests.length, r: Object.keys(savedResponses).length, ids: savedTests.map(t => t.id).join(',') });
|
|
@@ -1496,7 +1495,7 @@ export function ApiTesterPage() {
|
|
|
1496
1495
|
</span>
|
|
1497
1496
|
<div style=${{ marginLeft: 'auto', display: 'flex', gap: '6px' }}>
|
|
1498
1497
|
<button className="btn btn-ghost btn-sm" style=${{ fontSize: '11px' }}
|
|
1499
|
-
onClick=${() => {
|
|
1498
|
+
onClick=${() => { copyToClipboard(response.body || ''); toast('Copied', 'success'); }}>
|
|
1500
1499
|
<${LuCopy} size=${12} /> Copy
|
|
1501
1500
|
</button>
|
|
1502
1501
|
<button className="btn btn-ghost btn-sm" style=${{ fontSize: '11px' }}
|
|
@@ -1557,38 +1556,58 @@ export function ApiTesterPage() {
|
|
|
1557
1556
|
</div>
|
|
1558
1557
|
</div>
|
|
1559
1558
|
|
|
1560
|
-
<!-- Import Route
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1559
|
+
<!-- Import Route Panel -->
|
|
1560
|
+
<${AnimatePresence}>
|
|
1561
|
+
${showImport && html`<${React.Fragment}>
|
|
1562
|
+
<${motion.div}
|
|
1563
|
+
key="import-overlay"
|
|
1564
|
+
initial=${{ opacity: 0 }}
|
|
1565
|
+
animate=${{ opacity: 1 }}
|
|
1566
|
+
exit=${{ opacity: 0 }}
|
|
1567
|
+
transition=${{ duration: 0.2 }}
|
|
1568
|
+
onClick=${() => setShowImport(false)}
|
|
1569
|
+
style=${{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.3)', zIndex: 1000 }}
|
|
1570
|
+
/>
|
|
1571
|
+
<${motion.div}
|
|
1572
|
+
key="import-panel"
|
|
1573
|
+
initial=${{ x: '100%' }}
|
|
1574
|
+
animate=${{ x: 0 }}
|
|
1575
|
+
exit=${{ x: '100%' }}
|
|
1576
|
+
transition=${{ type: 'tween', duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
|
1577
|
+
style=${{
|
|
1578
|
+
position: 'fixed', top: 0, right: 0, bottom: 0, width: '25%', minWidth: '300px',
|
|
1579
|
+
background: 'var(--bg-card)', borderLeft: '1px solid var(--border)',
|
|
1580
|
+
boxShadow: '-8px 0 30px rgba(0,0,0,0.3)', zIndex: 1001,
|
|
1581
|
+
display: 'flex', flexDirection: 'column',
|
|
1582
|
+
}}>
|
|
1583
|
+
<div style=${{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
|
1584
|
+
<div style=${{ fontWeight: 600, fontSize: '15px' }}>Import from Routes</div>
|
|
1585
|
+
<button onClick=${() => setShowImport(false)}
|
|
1586
|
+
style=${{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: '18px', cursor: 'pointer', padding: '4px 8px', borderRadius: '6px', lineHeight: 1 }}
|
|
1587
|
+
onMouseOver=${(e) => e.currentTarget.style.color = 'var(--text)'}
|
|
1588
|
+
onMouseOut=${(e) => e.currentTarget.style.color = 'var(--text-muted)'}>✕</button>
|
|
1589
|
+
</div>
|
|
1590
|
+
<div style=${{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
|
1591
|
+
${importLoading && html`<div style=${{ padding: '40px', textAlign: 'center' }}><span className="spinner" /></div>`}
|
|
1592
|
+
${!importLoading && importRoutes.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>No routes found</div>`}
|
|
1593
|
+
${!importLoading && importRoutes.map(route => html`<div key=${route.name}>
|
|
1594
|
+
<div style=${{ padding: '10px 20px 6px', fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', position: 'sticky', top: 0, background: 'var(--bg-card)', zIndex: 1 }}>
|
|
1595
|
+
${route.name} <span style=${{ fontWeight: 400, textTransform: 'none' }}>(${route.prefix})</span>
|
|
1596
|
+
</div>
|
|
1597
|
+
${route.endpoints.map((ep, i) => html`<div key=${i}
|
|
1598
|
+
style=${{ padding: '9px 20px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px', transition: 'background 0.1s', borderBottom: '1px solid rgba(255,255,255,0.02)' }}
|
|
1599
|
+
onClick=${() => importEndpoint(route, ep)}
|
|
1600
|
+
onMouseOver=${(e) => e.currentTarget.style.background = 'var(--bg-input)'}
|
|
1601
|
+
onMouseOut=${(e) => e.currentTarget.style.background = 'transparent'}>
|
|
1602
|
+
<span className=${`route-method ${ep.method.toLowerCase()}`} style=${{ fontSize: '10px', padding: '2px 7px' }}>${ep.method}</span>
|
|
1603
|
+
<span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${ep.fullPath}</span>
|
|
1604
|
+
${ep.bodyFields?.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', flexShrink: 0 }}>${ep.bodyFields.length} fields</span>`}
|
|
1605
|
+
</div>`)}
|
|
1587
1606
|
</div>`)}
|
|
1588
|
-
</div
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1607
|
+
</div>
|
|
1608
|
+
<//>
|
|
1609
|
+
<//>`}
|
|
1610
|
+
<//>
|
|
1592
1611
|
</div>`;
|
|
1593
1612
|
}
|
|
1594
1613
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import { html, api, API, getStoredPasskey } from "../lib.js";
|
|
3
|
+
import { useApp } from "../context.js";
|
|
4
|
+
|
|
5
|
+
const TYPE_COLORS = {
|
|
6
|
+
log: "var(--text)",
|
|
7
|
+
info: "var(--info)",
|
|
8
|
+
warn: "var(--warning)",
|
|
9
|
+
error: "var(--error)",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const TYPE_BADGES = {
|
|
13
|
+
log: "LOG",
|
|
14
|
+
info: "INFO",
|
|
15
|
+
warn: "WARN",
|
|
16
|
+
error: "ERR",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function LogsPage() {
|
|
20
|
+
const { toast } = useApp();
|
|
21
|
+
const [logs, setLogs] = useState([]);
|
|
22
|
+
const [filter, setFilter] = useState("all");
|
|
23
|
+
const [search, setSearch] = useState("");
|
|
24
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
25
|
+
const [connected, setConnected] = useState(false);
|
|
26
|
+
const bottomRef = useRef(null);
|
|
27
|
+
const containerRef = useRef(null);
|
|
28
|
+
const eventSourceRef = useRef(null);
|
|
29
|
+
|
|
30
|
+
// Load existing logs
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
api("/logs").then(setLogs).catch(() => {});
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
// SSE stream for real-time logs
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const passkey = getStoredPasskey();
|
|
38
|
+
const url = API + "/logs/stream" + (passkey ? "?passkey=" + encodeURIComponent(passkey) : "");
|
|
39
|
+
const es = new EventSource(url);
|
|
40
|
+
eventSourceRef.current = es;
|
|
41
|
+
|
|
42
|
+
es.onopen = () => setConnected(true);
|
|
43
|
+
es.onmessage = (e) => {
|
|
44
|
+
try {
|
|
45
|
+
const entry = JSON.parse(e.data);
|
|
46
|
+
setLogs((prev) => {
|
|
47
|
+
const next = [...prev, entry];
|
|
48
|
+
return next.length > 500 ? next.slice(-500) : next;
|
|
49
|
+
});
|
|
50
|
+
} catch {}
|
|
51
|
+
};
|
|
52
|
+
es.onerror = () => setConnected(false);
|
|
53
|
+
|
|
54
|
+
return () => es.close();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
// Auto-scroll to bottom
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (autoScroll && bottomRef.current) {
|
|
60
|
+
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
|
61
|
+
}
|
|
62
|
+
}, [logs, autoScroll]);
|
|
63
|
+
|
|
64
|
+
// Detect manual scroll
|
|
65
|
+
const handleScroll = useCallback(() => {
|
|
66
|
+
if (!containerRef.current) return;
|
|
67
|
+
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
|
68
|
+
setAutoScroll(scrollHeight - scrollTop - clientHeight < 60);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const handleClear = async () => {
|
|
72
|
+
try {
|
|
73
|
+
await api("/logs", { method: "DELETE" });
|
|
74
|
+
setLogs([]);
|
|
75
|
+
toast("Logs cleared", "success");
|
|
76
|
+
} catch (err) {
|
|
77
|
+
toast(err.message, "error");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const filtered = logs.filter((l) => {
|
|
82
|
+
if (filter !== "all" && l.type !== filter) return false;
|
|
83
|
+
if (search && !l.message.toLowerCase().includes(search.toLowerCase())) return false;
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const counts = {
|
|
88
|
+
all: logs.length,
|
|
89
|
+
log: logs.filter((l) => l.type === "log").length,
|
|
90
|
+
error: logs.filter((l) => l.type === "error").length,
|
|
91
|
+
warn: logs.filter((l) => l.type === "warn").length,
|
|
92
|
+
info: logs.filter((l) => l.type === "info").length,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const formatTime = (ts) => {
|
|
96
|
+
try {
|
|
97
|
+
const d = new Date(ts);
|
|
98
|
+
return d.toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
99
|
+
+ "." + String(d.getMilliseconds()).padStart(3, "0");
|
|
100
|
+
} catch { return ""; }
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return html`<div style=${{ display: "flex", flexDirection: "column", height: "100%", gap: "12px" }}>
|
|
104
|
+
|
|
105
|
+
<!-- Toolbar -->
|
|
106
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}>
|
|
107
|
+
<div style=${{ display: "flex", gap: "4px", background: "var(--bg-surface)", borderRadius: "8px", padding: "3px" }}>
|
|
108
|
+
${["all", "log", "error", "warn", "info"].map(
|
|
109
|
+
(f) => html`<button key=${f} onClick=${() => setFilter(f)}
|
|
110
|
+
style=${{
|
|
111
|
+
padding: "4px 10px", fontSize: "11px", borderRadius: "6px", border: "none", cursor: "pointer",
|
|
112
|
+
background: filter === f ? "var(--accent)" : "transparent",
|
|
113
|
+
color: filter === f ? "#fff" : "var(--text-muted)",
|
|
114
|
+
fontWeight: filter === f ? 600 : 400,
|
|
115
|
+
}}>
|
|
116
|
+
${f.toUpperCase()}${counts[f] > 0 ? ` (${counts[f]})` : ""}
|
|
117
|
+
</button>`
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<input
|
|
122
|
+
type="text"
|
|
123
|
+
placeholder="Search logs..."
|
|
124
|
+
value=${search}
|
|
125
|
+
onInput=${(e) => setSearch(e.target.value)}
|
|
126
|
+
style=${{
|
|
127
|
+
flex: 1, minWidth: "160px", padding: "6px 10px", fontSize: "12px",
|
|
128
|
+
background: "var(--bg-surface)", border: "1px solid var(--border)", borderRadius: "6px",
|
|
129
|
+
color: "var(--text)", outline: "none",
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
|
|
133
|
+
<div style=${{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
134
|
+
<span style=${{
|
|
135
|
+
width: "8px", height: "8px", borderRadius: "50%",
|
|
136
|
+
background: connected ? "var(--success)" : "var(--error)",
|
|
137
|
+
display: "inline-block",
|
|
138
|
+
}} />
|
|
139
|
+
<span style=${{ fontSize: "11px", color: "var(--text-muted)" }}>
|
|
140
|
+
${connected ? "Live" : "Disconnected"}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<button onClick=${() => setAutoScroll(!autoScroll)}
|
|
145
|
+
style=${{
|
|
146
|
+
padding: "4px 10px", fontSize: "11px", borderRadius: "6px", border: "1px solid var(--border)",
|
|
147
|
+
cursor: "pointer",
|
|
148
|
+
background: autoScroll ? "var(--accent)" : "var(--bg-surface)",
|
|
149
|
+
color: autoScroll ? "#fff" : "var(--text-muted)",
|
|
150
|
+
}}>
|
|
151
|
+
Auto-scroll ${autoScroll ? "ON" : "OFF"}
|
|
152
|
+
</button>
|
|
153
|
+
|
|
154
|
+
<button onClick=${handleClear}
|
|
155
|
+
style=${{
|
|
156
|
+
padding: "4px 10px", fontSize: "11px", borderRadius: "6px",
|
|
157
|
+
border: "1px solid var(--border)", cursor: "pointer",
|
|
158
|
+
background: "var(--bg-surface)", color: "var(--error)",
|
|
159
|
+
}}>
|
|
160
|
+
Clear
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<!-- Log Output -->
|
|
165
|
+
<div ref=${containerRef} onScroll=${handleScroll}
|
|
166
|
+
style=${{
|
|
167
|
+
flex: 1, overflow: "auto", background: "#0a0a0f", borderRadius: "8px",
|
|
168
|
+
border: "1px solid var(--border)", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
|
169
|
+
fontSize: "12px", lineHeight: "1.7", padding: "12px 0",
|
|
170
|
+
}}>
|
|
171
|
+
${filtered.length === 0 && html`
|
|
172
|
+
<div style=${{ padding: "40px", textAlign: "center", color: "var(--text-muted)", fontSize: "12px" }}>
|
|
173
|
+
${logs.length === 0 ? "No logs yet. Console output from your app will appear here." : "No logs match the current filter."}
|
|
174
|
+
</div>
|
|
175
|
+
`}
|
|
176
|
+
${filtered.map(
|
|
177
|
+
(entry) => html`<div key=${entry.id}
|
|
178
|
+
style=${{
|
|
179
|
+
padding: "1px 14px", display: "flex", gap: "10px", alignItems: "flex-start",
|
|
180
|
+
borderLeft: entry.type === "error" ? "2px solid var(--error)" : entry.type === "warn" ? "2px solid var(--warning)" : "2px solid transparent",
|
|
181
|
+
background: entry.type === "error" ? "rgba(239,68,68,0.04)" : entry.type === "warn" ? "rgba(245,158,11,0.04)" : "transparent",
|
|
182
|
+
}}>
|
|
183
|
+
<span style=${{ color: "var(--text-muted)", fontSize: "10px", minWidth: "80px", paddingTop: "2px", userSelect: "none" }}>
|
|
184
|
+
${formatTime(entry.timestamp)}
|
|
185
|
+
</span>
|
|
186
|
+
<span style=${{
|
|
187
|
+
fontSize: "9px", fontWeight: 700, minWidth: "32px", textAlign: "center",
|
|
188
|
+
padding: "1px 4px", borderRadius: "3px", letterSpacing: "0.5px", paddingTop: "3px",
|
|
189
|
+
background: entry.type === "error" ? "rgba(239,68,68,0.15)" : entry.type === "warn" ? "rgba(245,158,11,0.15)" : entry.type === "info" ? "rgba(59,130,246,0.15)" : "rgba(255,255,255,0.06)",
|
|
190
|
+
color: TYPE_COLORS[entry.type] || "var(--text)",
|
|
191
|
+
}}>
|
|
192
|
+
${TYPE_BADGES[entry.type] || "LOG"}
|
|
193
|
+
</span>
|
|
194
|
+
<span style=${{
|
|
195
|
+
color: TYPE_COLORS[entry.type] || "var(--text)",
|
|
196
|
+
whiteSpace: "pre-wrap", wordBreak: "break-all", flex: 1,
|
|
197
|
+
}}>
|
|
198
|
+
${entry.message}
|
|
199
|
+
</span>
|
|
200
|
+
</div>`
|
|
201
|
+
)}
|
|
202
|
+
<div ref=${bottomRef} />
|
|
203
|
+
</div>
|
|
204
|
+
</div>`;
|
|
205
|
+
}
|