4bnode 4.0.3 → 4.0.5

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 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
@@ -162,22 +180,16 @@ Access at `http://localhost:3000/_dev` when running `npm run dev`.
162
180
  ```
163
181
  my-app/
164
182
  ├── index.js # Express app entry point
165
- ├── dev-api.js # Dev dashboard API (auto-included)
166
183
  ├── package.json
167
184
  ├── .env.development # Dev environment variables
168
185
  ├── .env.production # Production environment variables
169
- ├── .4bnode/ # Dashboard data (tester, shared collections)
186
+ ├── .4bnode/ # Dashboard engine & data (managed by 4bnode)
170
187
  ├── public/ # Static files
171
- │ ├── index.html # Dashboard UI
172
- │ ├── fonts/
173
- │ └── images/
174
- ├── uploads/ # File uploads (when using multer)
188
+ │ └── index.html # App status landing page
175
189
  └── src/
176
190
  ├── db.js # MongoDB connection (after `add mongo`)
177
191
  ├── models/ # Mongoose schemas
178
- ├── routes/ # API route files
179
- └── middleware/
180
- └── auth.js # JWT auth middleware (after `add login`)
192
+ └── routes/ # API route files
181
193
  ```
182
194
 
183
195
  ## Real-time Communication
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, { new: true });
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
  }
@@ -84,7 +84,7 @@ ${fieldAssignments}
84
84
  };
85
85
  ${passwordLogic}
86
86
  try {
87
- const updatedDocument = await ${pascalName}.findByIdAndUpdate(req.params.id, updatedData, { new: true });
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
- execFileSync("npm", ["install"], { stdio: "pipe", cwd: targetPath, shell: true });
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("node", [path.join(__dirname, script), ...args], {
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
- execFileSync("npm", ["run", "dev"], {
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
@@ -140,7 +140,6 @@ export async function installDeps(packages, opts = {}) {
140
140
  execSync(`npm install ${names.join(" ")}`, {
141
141
  stdio: "pipe",
142
142
  ...opts,
143
- shell: true,
144
143
  });
145
144
  spinner.succeed(success(`Installed ${label}`));
146
145
  } catch (e) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "4bnode",
3
- "version": "4.0.3",
4
- "description": "A professional tool to generate a Node.js app by 4Brains Technologies",
3
+ "version": "4.0.5",
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: "env", label: "Environment", icon: Icons.env, section: "Settings" },
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
- navigator.clipboard.writeText(shareUrl);
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=${() => { navigator.clipboard.writeText(response.body || ''); toast('Copied', 'success'); }}>
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 Modal -->
1561
- ${showImport && html`<div style=${{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
1562
- onClick=${(e) => { if (e.target === e.currentTarget) setShowImport(false); }}>
1563
- <${motion.div}
1564
- initial=${{ opacity: 0, scale: 0.95 }}
1565
- animate=${{ opacity: 1, scale: 1 }}
1566
- transition=${{ duration: 0.15 }}
1567
- style=${{ background: 'var(--bg-card)', borderRadius: '12px', border: '1px solid var(--border)', width: '500px', maxHeight: '70vh', overflow: 'hidden', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
1568
- <div style=${{ padding: '16px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1569
- <div style=${{ fontWeight: 600, fontSize: '15px' }}>Import from Routes</div>
1570
- <button className="btn btn-ghost btn-sm" onClick=${() => setShowImport(false)}>✕</button>
1571
- </div>
1572
- <div style=${{ maxHeight: '55vh', overflowY: 'auto', padding: '8px 0' }}>
1573
- ${importLoading && html`<div style=${{ padding: '40px', textAlign: 'center' }}><span className="spinner" /></div>`}
1574
- ${!importLoading && importRoutes.length === 0 && html`<div style=${{ padding: '30px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>No routes found</div>`}
1575
- ${!importLoading && importRoutes.map(route => html`<div key=${route.name}>
1576
- <div style=${{ padding: '8px 20px', fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>
1577
- ${route.name} <span style=${{ fontWeight: 400, textTransform: 'none' }}>(${route.prefix})</span>
1578
- </div>
1579
- ${route.endpoints.map((ep, i) => html`<div key=${i}
1580
- style=${{ padding: '8px 20px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '10px', transition: 'background 0.1s' }}
1581
- onClick=${() => importEndpoint(route, ep)}
1582
- onMouseOver=${(e) => e.currentTarget.style.background = 'var(--bg-input)'}
1583
- onMouseOut=${(e) => e.currentTarget.style.background = 'transparent'}>
1584
- <span className=${`route-method ${ep.method.toLowerCase()}`} style=${{ fontSize: '10px', padding: '1px 6px' }}>${ep.method}</span>
1585
- <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', color: 'var(--text-primary)' }}>${ep.fullPath}</span>
1586
- ${ep.bodyFields?.length > 0 && html`<span style=${{ fontSize: '10px', color: 'var(--text-muted)', marginLeft: 'auto' }}>${ep.bodyFields.length} fields</span>`}
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
- </div>
1590
- <//>
1591
- </div>`}
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
+ }