4bnode 4.1.1 → 4.1.3
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-security.js +0 -18
- package/app-1/.4bnode/.passkey +1 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +139 -0
- package/app-1/.4bnode/assets/js/Root.js +40 -0
- package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
- package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/app-1/.4bnode/assets/js/context.js +7 -0
- package/app-1/.4bnode/assets/js/lib.js +113 -0
- package/app-1/.4bnode/assets/js/main.js +5 -0
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/app-1/.4bnode/codegen.js +1249 -0
- package/app-1/.4bnode/dev-api.js +3621 -0
- package/app-1/.4bnode/ui.html +1578 -0
- package/app-1/README.md +47 -0
- package/app-1/ecosystem.config.cjs +25 -0
- package/app-1/index.js +145 -0
- package/app-1/package.json +35 -0
- package/app-1/public/index.html +292 -0
- package/app-1/src/db.js +13 -0
- package/app-1/src/docs.js +84 -0
- package/app-1/src/middleware/errorHandler.js +15 -0
- package/app-1/src/middleware/roles.js +14 -0
- package/app-1/src/middleware/validate.js +20 -0
- package/app-1/src/models/login.js +9 -0
- package/app-1/src/openapi.json +62 -0
- package/app-1/src/routes/products.js +26 -0
- package/app-1/src/validators/products.js +7 -0
- package/app-1/src/validators/user.js +7 -0
- package/index.js +2 -17
- package/lib/codegen.js +69 -43
- package/lib/codegen.test.js +21 -2
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +0 -2
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
- package/skeleton/.4bnode/assets/js/lib.js +0 -3
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
- package/skeleton/.4bnode/codegen.js +69 -43
- package/skeleton/.4bnode/dev-api.js +173 -87
- package/skeleton/_gitignore +0 -1
- package/skeleton/ecosystem.config.cjs +25 -0
- package/skeleton/index.js +4 -8
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { LuMail, LuCheck, LuSend } from "react-icons/lu";
|
|
3
|
+
import { html, api } from "../lib.js";
|
|
4
|
+
import { useApp } from "../context.js";
|
|
5
|
+
|
|
6
|
+
export function MailPage() {
|
|
7
|
+
const { toast, refresh } = useApp();
|
|
8
|
+
const [info, setInfo] = useState(null);
|
|
9
|
+
const [provider, setProvider] = useState("brevo");
|
|
10
|
+
const [form, setForm] = useState({ host: "", port: 587, secure: false, user: "", pass: "", from: "" });
|
|
11
|
+
const [busy, setBusy] = useState(false);
|
|
12
|
+
const [testTo, setTestTo] = useState("");
|
|
13
|
+
const [testing, setTesting] = useState(false);
|
|
14
|
+
|
|
15
|
+
const load = useCallback(async () => {
|
|
16
|
+
try {
|
|
17
|
+
const r = await api("/mail");
|
|
18
|
+
setInfo(r);
|
|
19
|
+
setForm((f) => ({
|
|
20
|
+
...f,
|
|
21
|
+
host: r.host || f.host,
|
|
22
|
+
port: r.port || f.port || 587,
|
|
23
|
+
secure: !!r.secure,
|
|
24
|
+
user: r.user || f.user,
|
|
25
|
+
from: r.from || f.from,
|
|
26
|
+
}));
|
|
27
|
+
} catch (err) { toast(err.message, "error"); }
|
|
28
|
+
}, [toast]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => { load(); }, [load]);
|
|
31
|
+
|
|
32
|
+
function setField(patch) { setForm((f) => ({ ...f, ...patch })); }
|
|
33
|
+
|
|
34
|
+
function pickProvider(p) {
|
|
35
|
+
setProvider(p.id);
|
|
36
|
+
if (p.id !== "smtp") setField({ host: p.host, port: p.port, secure: p.secure, user: p.user || form.user });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function save() {
|
|
40
|
+
if (!form.host.trim()) { toast("SMTP host is required", "error"); return; }
|
|
41
|
+
setBusy(true);
|
|
42
|
+
try {
|
|
43
|
+
const r = await api("/mail/setup", { method: "POST", body: { provider, ...form } });
|
|
44
|
+
toast(r.message, "success");
|
|
45
|
+
await load();
|
|
46
|
+
refresh().catch(() => {});
|
|
47
|
+
} catch (err) { toast(err.message, "error"); }
|
|
48
|
+
setBusy(false);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function sendTest() {
|
|
52
|
+
if (!testTo.trim()) { toast("Enter a recipient", "error"); return; }
|
|
53
|
+
setTesting(true);
|
|
54
|
+
try {
|
|
55
|
+
const r = await api("/mail/test", { method: "POST", body: { to: testTo.trim() } });
|
|
56
|
+
toast(r.message, "success");
|
|
57
|
+
} catch (err) { toast(err.message, "error"); }
|
|
58
|
+
setTesting(false);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
|
|
62
|
+
|
|
63
|
+
const current = info.providers.find((p) => p.id === provider) || {};
|
|
64
|
+
|
|
65
|
+
return html`<div>
|
|
66
|
+
<div className="flex items-center justify-between mb-4">
|
|
67
|
+
<div>
|
|
68
|
+
<div className="card-title">Email</div>
|
|
69
|
+
<div className="card-subtitle">Send transactional email via Brevo, SendGrid, Mailgun, Gmail, or any SMTP</div>
|
|
70
|
+
</div>
|
|
71
|
+
${info.configured && html`<span className="badge badge-success">Configured</span>`}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div className="card mb-4">
|
|
75
|
+
<div style=${{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" }}>Provider</div>
|
|
76
|
+
<div style=${{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: "8px" }}>
|
|
77
|
+
${info.providers.map((p) => html`<button key=${p.id} className="badge"
|
|
78
|
+
onClick=${() => pickProvider(p)}
|
|
79
|
+
style=${{ cursor: "pointer", border: "1px solid var(--border)", background: provider === p.id ? "var(--accent-glow)" : "var(--bg-input)", color: provider === p.id ? "var(--accent-light)" : "var(--text-secondary)" }}>${p.label}</button>`)}
|
|
80
|
+
</div>
|
|
81
|
+
${current.hint && html`<div className="text-sm text-muted">${current.hint}</div>`}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="card mb-4">
|
|
85
|
+
<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
86
|
+
<div className="form-group" style=${{ flex: "1 1 260px" }}>
|
|
87
|
+
<label className="form-label">SMTP Host</label>
|
|
88
|
+
<input className="form-input" value=${form.host} onInput=${(e) => setField({ host: e.target.value })} placeholder="smtp-relay.brevo.com" />
|
|
89
|
+
</div>
|
|
90
|
+
<div className="form-group" style=${{ flex: "0 0 110px" }}>
|
|
91
|
+
<label className="form-label">Port</label>
|
|
92
|
+
<input className="form-input" type="number" value=${form.port} onInput=${(e) => setField({ port: e.target.value })} />
|
|
93
|
+
</div>
|
|
94
|
+
<div className="form-group" style=${{ flex: "0 0 130px", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
|
95
|
+
<label style=${{ display: "flex", alignItems: "center", gap: "6px", fontSize: "13px", color: "var(--text-secondary)", cursor: "pointer", paddingBottom: "10px" }}>
|
|
96
|
+
<input type="checkbox" checked=${form.secure} onChange=${(e) => setField({ secure: e.target.checked })} /> Secure (465)
|
|
97
|
+
</label>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
101
|
+
<div className="form-group" style=${{ flex: "1 1 240px" }}>
|
|
102
|
+
<label className="form-label">User</label>
|
|
103
|
+
<input className="form-input" value=${form.user} onInput=${(e) => setField({ user: e.target.value })} placeholder="login / apikey" />
|
|
104
|
+
</div>
|
|
105
|
+
<div className="form-group" style=${{ flex: "1 1 240px" }}>
|
|
106
|
+
<label className="form-label">Password / API key ${info.hasPass ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved — leave blank to keep)</span>` : ""}</label>
|
|
107
|
+
<input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.hasPass ? "••••••••" : "secret"} />
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="form-group">
|
|
111
|
+
<label className="form-label">From address</label>
|
|
112
|
+
<input className="form-input" value=${form.from} onInput=${(e) => setField({ from: e.target.value })} placeholder="no-reply@yourdomain.com" style=${{ maxWidth: "320px" }} />
|
|
113
|
+
</div>
|
|
114
|
+
<button className="btn btn-primary" onClick=${save} disabled=${busy || !form.host.trim()}>
|
|
115
|
+
${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
|
|
116
|
+
${busy ? "Saving..." : "Save & install"}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
${info.configured && html`<div className="card">
|
|
121
|
+
<div className="card-title mb-2">Send test email</div>
|
|
122
|
+
<div style=${{ display: "flex", gap: "8px", alignItems: "flex-end", flexWrap: "wrap" }}>
|
|
123
|
+
<div className="form-group" style=${{ flex: "1 1 260px", marginBottom: 0 }}>
|
|
124
|
+
<label className="form-label">Recipient</label>
|
|
125
|
+
<input className="form-input" value=${testTo} onInput=${(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
|
|
126
|
+
</div>
|
|
127
|
+
<button className="btn btn-primary" onClick=${sendTest} disabled=${testing || !testTo.trim()}>
|
|
128
|
+
${testing ? html`<span className="spinner" />` : html`<${LuSend} size=${16} />`}
|
|
129
|
+
${testing ? "Sending..." : "Send test"}
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>`}
|
|
133
|
+
</div>`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
+
import { LuActivity, LuCheck, LuRadio, LuZap } from "react-icons/lu";
|
|
4
|
+
import { html, api } from "../lib.js";
|
|
5
|
+
import { useApp } from "../context.js";
|
|
6
|
+
|
|
7
|
+
export function RealtimePage() {
|
|
8
|
+
const { toast, refresh, status, showConfirm } = useApp();
|
|
9
|
+
const [selected, setSelected] = useState(status?.socketConfigured ? 'socketio' : status?.websocketConfigured ? 'websocket' : 'socketio');
|
|
10
|
+
|
|
11
|
+
const items = [
|
|
12
|
+
{ id: 'socketio', label: 'Socket.IO', desc: 'Auto-reconnect, rooms, namespaces', configured: status?.socketConfigured },
|
|
13
|
+
{ id: 'websocket', label: 'WebSocket', desc: 'Native ws library, lightweight', configured: status?.websocketConfigured },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
return html`<div>
|
|
17
|
+
<div className="flex items-center justify-between mb-4">
|
|
18
|
+
<div>
|
|
19
|
+
<div className="card-title">Real-time</div>
|
|
20
|
+
<div className="card-subtitle">Configure real-time communication</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div className="routes-layout">
|
|
25
|
+
<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
|
|
26
|
+
<div style=${{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border)' }}>
|
|
27
|
+
<span style=${{ fontWeight: 600, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Protocols</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="routes-list">
|
|
30
|
+
${items.map(item => html`<div key=${item.id}
|
|
31
|
+
className=${`route-item ${selected === item.id ? 'active' : ''}`}
|
|
32
|
+
onClick=${() => setSelected(item.id)}>
|
|
33
|
+
<div style=${{ display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 }}>
|
|
34
|
+
<span className="ri-name">${item.label}</span>
|
|
35
|
+
<span className="ri-file">${item.desc}</span>
|
|
36
|
+
</div>
|
|
37
|
+
${item.configured && html`<span className="badge badge-success" style=${{ fontSize: '10px' }}>Active</span>`}
|
|
38
|
+
</div>`)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div className="routes-detail">
|
|
43
|
+
<${AnimatePresence} mode="wait">
|
|
44
|
+
<${motion.div} key=${selected}
|
|
45
|
+
initial=${{ opacity: 0, y: 6 }} animate=${{ opacity: 1, y: 0 }} exit=${{ opacity: 0, y: -6 }}
|
|
46
|
+
transition=${{ duration: 0.12 }}>
|
|
47
|
+
${selected === 'socketio' ? html`<${SocketIOSetup} />` : html`<${WebSocketSetup} />`}
|
|
48
|
+
<//>
|
|
49
|
+
<//>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function SocketIOSetup() {
|
|
56
|
+
const { toast, refresh, status, showConfirm } = useApp();
|
|
57
|
+
const [cors, setCors] = useState("*");
|
|
58
|
+
const [loading, setLoading] = useState(false);
|
|
59
|
+
|
|
60
|
+
if (status?.socketConfigured) {
|
|
61
|
+
return html`<div>
|
|
62
|
+
<div className="card mb-4" style=${{ borderColor: 'rgba(34,197,94,0.3)' }}>
|
|
63
|
+
<div className="flex items-center gap-3">
|
|
64
|
+
<${LuActivity} size=${20} color="var(--success)" />
|
|
65
|
+
<div style=${{ flex: 1 }}>
|
|
66
|
+
<div style=${{ fontWeight: 600 }}>Socket.IO Active</div>
|
|
67
|
+
<div className="text-sm text-muted">Real-time events are configured in index.js</div>
|
|
68
|
+
</div>
|
|
69
|
+
<span className="badge badge-success">Configured</span>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div className="card">
|
|
73
|
+
<div style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: '8px' }}>Features</div>
|
|
74
|
+
${['Auto-reconnect on disconnect', 'Room & namespace support', 'Binary data streaming', 'Middleware pipeline'].map(f =>
|
|
75
|
+
html`<div key=${f} style=${{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
76
|
+
<${LuCheck} size=${14} color="var(--success)" /> ${f}
|
|
77
|
+
</div>`
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</div>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handleSetup() {
|
|
84
|
+
if (status?.websocketConfigured) {
|
|
85
|
+
const ok = await showConfirm('WebSocket is currently configured. Enabling Socket.IO will replace your WebSocket setup. Do you want to continue?', { confirmLabel: 'Replace with Socket.IO' });
|
|
86
|
+
if (!ok) return;
|
|
87
|
+
}
|
|
88
|
+
setLoading(true);
|
|
89
|
+
try {
|
|
90
|
+
const result = await api('/add-socket', { method: 'POST', body: { corsOrigins: cors } });
|
|
91
|
+
toast(result.message, 'success');
|
|
92
|
+
refresh().catch(() => {});
|
|
93
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
94
|
+
setLoading(false);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return html`<div className="card">
|
|
98
|
+
<div className="card-title mb-2">Socket.IO Setup</div>
|
|
99
|
+
<div className="card-subtitle mb-4">Add real-time bidirectional communication with auto-reconnect, rooms, and namespaces</div>
|
|
100
|
+
${status?.websocketConfigured && html`<div style=${{ padding: '10px 14px', borderRadius: '8px', background: 'var(--warning-glow)', border: '1px solid rgba(245,158,11,0.2)', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
101
|
+
<${LuZap} size=${14} color="var(--warning)" />
|
|
102
|
+
<span style=${{ fontSize: '12px', color: 'var(--warning)' }}>WebSocket is currently active. Setting up Socket.IO will replace it.</span>
|
|
103
|
+
</div>`}
|
|
104
|
+
<div className="form-group">
|
|
105
|
+
<label className="form-label">CORS Origins</label>
|
|
106
|
+
<input className="form-input" value=${cors} onInput=${e => setCors(e.target.value)} placeholder="* or comma-separated origins" />
|
|
107
|
+
</div>
|
|
108
|
+
<button className="btn btn-primary" onClick=${handleSetup} disabled=${loading}>
|
|
109
|
+
${loading ? html`<span className="spinner" />` : null}
|
|
110
|
+
${loading ? 'Setting up...' : 'Setup Socket.IO'}
|
|
111
|
+
</button>
|
|
112
|
+
</div>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function WebSocketSetup() {
|
|
116
|
+
const { toast, refresh, status, showConfirm } = useApp();
|
|
117
|
+
const [samePort, setSamePort] = useState(true);
|
|
118
|
+
const [wsPort, setWsPort] = useState("8080");
|
|
119
|
+
const [loading, setLoading] = useState(false);
|
|
120
|
+
|
|
121
|
+
if (status?.websocketConfigured) {
|
|
122
|
+
return html`<div>
|
|
123
|
+
<div className="card mb-4" style=${{ borderColor: 'rgba(34,197,94,0.3)' }}>
|
|
124
|
+
<div className="flex items-center gap-3">
|
|
125
|
+
<${LuRadio} size=${20} color="var(--success)" />
|
|
126
|
+
<div style=${{ flex: 1 }}>
|
|
127
|
+
<div style=${{ fontWeight: 600 }}>WebSocket Active</div>
|
|
128
|
+
<div className="text-sm text-muted">Native WebSocket is configured in index.js</div>
|
|
129
|
+
</div>
|
|
130
|
+
<span className="badge badge-success">Configured</span>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="card">
|
|
134
|
+
<div style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: '8px' }}>Features</div>
|
|
135
|
+
${['Lightweight native protocol', 'Low latency messaging', 'Binary frame support', 'Custom message handling'].map(f =>
|
|
136
|
+
html`<div key=${f} style=${{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
137
|
+
<${LuCheck} size=${14} color="var(--success)" /> ${f}
|
|
138
|
+
</div>`
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</div>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function handleSetup() {
|
|
145
|
+
if (status?.socketConfigured) {
|
|
146
|
+
const ok = await showConfirm('Socket.IO is currently configured. Enabling WebSocket will replace your Socket.IO setup. Do you want to continue?', { confirmLabel: 'Replace with WebSocket' });
|
|
147
|
+
if (!ok) return;
|
|
148
|
+
}
|
|
149
|
+
setLoading(true);
|
|
150
|
+
try {
|
|
151
|
+
const result = await api('/add-websocket', { method: 'POST', body: { samePort, wsPort: samePort ? null : parseInt(wsPort) } });
|
|
152
|
+
toast(result.message, 'success');
|
|
153
|
+
refresh().catch(() => {});
|
|
154
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
155
|
+
setLoading(false);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return html`<div className="card">
|
|
159
|
+
<div className="card-title mb-2">Native WebSocket Setup</div>
|
|
160
|
+
<div className="card-subtitle mb-4">Add lightweight WebSocket support using the ws library for low-level control</div>
|
|
161
|
+
${status?.socketConfigured && html`<div style=${{ padding: '10px 14px', borderRadius: '8px', background: 'var(--warning-glow)', border: '1px solid rgba(245,158,11,0.2)', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
162
|
+
<${LuZap} size=${14} color="var(--warning)" />
|
|
163
|
+
<span style=${{ fontSize: '12px', color: 'var(--warning)' }}>Socket.IO is currently active. Setting up WebSocket will replace it.</span>
|
|
164
|
+
</div>`}
|
|
165
|
+
<div className="form-group">
|
|
166
|
+
<label className="form-label">Server Port</label>
|
|
167
|
+
<div style=${{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
|
168
|
+
<label style=${{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
169
|
+
<input type="radio" name="wsPort" checked=${samePort} onChange=${() => setSamePort(true)} /> Same as HTTP server
|
|
170
|
+
</label>
|
|
171
|
+
<label style=${{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
172
|
+
<input type="radio" name="wsPort" checked=${!samePort} onChange=${() => setSamePort(false)} /> Separate port
|
|
173
|
+
</label>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
${!samePort && html`<div className="form-group">
|
|
177
|
+
<label className="form-label">WebSocket Port</label>
|
|
178
|
+
<input className="form-input" type="number" value=${wsPort} onInput=${e => setWsPort(e.target.value)} placeholder="8080" style=${{ maxWidth: '160px' }} />
|
|
179
|
+
</div>`}
|
|
180
|
+
<button className="btn btn-primary" onClick=${handleSetup} disabled=${loading}>
|
|
181
|
+
${loading ? html`<span className="spinner" />` : null}
|
|
182
|
+
${loading ? 'Setting up...' : 'Setup WebSocket'}
|
|
183
|
+
</button>
|
|
184
|
+
</div>`;
|
|
185
|
+
}
|
|
186
|
+
|