@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.20

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/LICENSE CHANGED
@@ -175,7 +175,7 @@
175
175
 
176
176
  END OF TERMS AND CONDITIONS
177
177
 
178
- Copyright 2026 aaqu
178
+ Copyright 2026 Mazur Albert Fromcubes
179
179
 
180
180
  Licensed under the Apache License, Version 2.0 (the "License");
181
181
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -1,120 +1,195 @@
1
1
  # @aaqu/fromcubes-portal-react
2
2
 
3
- React portal node for Node-RED. Server-side JSX transpilation via esbuild. Tailwind CSS 4. Zero runtime compilation in browser.
3
+ > **⚠️ Alpha Module** This project is in early development. Expect breaking changes. Test on a clean Node-RED instance.
4
4
 
5
- ## How it works
5
+ A Node-RED node that turns any `/fromcubes/<sub-path>` URL into a React page. Write JSX in the editor, deploy, open the URL — your component talks to the flow over WebSocket. No build step, no browser compiler. All portal pages are served under the hardcoded `/fromcubes/` prefix so every node cleanly coexists under one URL tree.
6
6
 
7
- ```
8
- ┌─ Deploy time (Node-RED server) ─────────────────────────┐
9
- │ │
10
- │ JSX (editor) ──► esbuild transpile ──► cached JS │
11
- │ (hash-keyed cache) │
12
- │ │
13
- │ Unchanged code on redeploy = cache hit, 0ms │
14
- │ Changed code = retranspile, ~5ms │
15
- └──────────────────────────────────────────────────────────┘
16
-
17
- ┌─ Runtime (browser) ─────────────────────────────────────┐
18
- │ │
19
- │ GET /endpoint ──► HTML + pre-compiled JS │
20
- │ react-19.production.min.js │
21
- │ Tailwind CSS (CDN) │
22
- │ NO Babel, NO Sucrase, NO compiler │
23
- │ │
24
- │ WebSocket /endpoint/_ws ◄──► Node-RED msg I/O │
25
- └──────────────────────────────────────────────────────────┘
26
- ```
7
+ For internals, plugin authoring, and the deploy pipeline see [README-DEV.md](./README-DEV.md).
8
+
9
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L01UOFRG)
27
10
 
28
11
  ## Install
29
12
 
30
13
  ```bash
31
14
  cd ~/.node-red
32
- npm install /path/to/fromcubes-portal-react
15
+ npm install @aaqu/fromcubes-portal-react@alpha
33
16
  # restart Node-RED
34
17
  ```
35
18
 
36
- Dependencies install automatically. No build step needed.
19
+ That's it. No build step. Any npm packages you list in the node config (e.g. `d3, three`) are installed automatically on deploy.
20
+
21
+ ## Your first portal
22
+
23
+ 1. Drop a **portal-react** node onto a flow.
24
+ 2. Set **Sub-path** to e.g. `hello` (the node will serve at `/fromcubes/hello`; the `/fromcubes/` prefix is fixed).
25
+ 3. Open the code editor and paste:
26
+ ```jsx
27
+ function App() {
28
+ const { data, send } = useNodeRed();
29
+ return (
30
+ <div className="p-4">
31
+ <p>From flow: {JSON.stringify(data)}</p>
32
+ <button
33
+ className="px-3 py-1 bg-blue-500 text-white rounded"
34
+ onClick={() => send({ clicked: Date.now() })}
35
+ >
36
+ click me
37
+ </button>
38
+ </div>
39
+ );
40
+ }
41
+ ```
42
+ 4. Deploy. Open `http://localhost:1880/fromcubes/hello`.
43
+ 5. Wire an **inject** node into the portal-react input → see `data` update live. Wire its output → see button clicks arrive in your flow.
44
+
45
+ ## The `useNodeRed()` hook
46
+
47
+ Everything your component needs from the flow lives in one hook:
37
48
 
38
- ## npm scripts
49
+ ```jsx
50
+ const {
51
+ data, // last broadcast msg.payload from input wire (reactive)
52
+ send, // send(payload, topic?) — emit msg on output wire
53
+ user, // portal user object (when Portal Auth is enabled), or null
54
+ portalClient, // unique session/tab ID assigned by server on connect
55
+ } = useNodeRed();
56
+ ```
39
57
 
40
- | Script | Purpose |
41
- |---|---|
42
- | `npm run build` | Rebuild vendored React bundle (`nodes/vendor/react-19.production.min.js`) |
43
- | `npm start` | Start Node-RED |
58
+ ### Recovery on connect
59
+
60
+ A freshly-connected client receives the **last broadcast payload** the server has cached for this endpoint, sent as a distinct `recovery` frame. By default it's seeded straight into `data`, so the first render of a new tab shows the most recent value instead of waiting for the next broadcast — same idea as dashboard2's `lastMsg`.
61
+
62
+ Opt out per page:
63
+
64
+ ```jsx
65
+ // data stays undefined until a fresh broadcast arrives — no recovery seed
66
+ const { data } = useNodeRed({ ignoreRecovery: true });
67
+ ```
44
68
 
45
- ## Nodes
69
+ The opt-out is page-wide — the strictest call wins. If any component on the page asks to ignore recovery, recovery is dropped for all of them.
46
70
 
47
- ### portal-react
71
+ ## Node configuration
48
72
 
49
73
  | Field | Purpose |
50
74
  |---|---|
51
- | Endpoint | HTTP path, e.g. `/dashboard` |
52
- | Title | Browser tab title |
53
- | Input Schema | JSON documents expected `msg.payload` shape |
54
- | Output Schema | JSON documents emitted `msg.payload` shape |
75
+ | Sub-path | Part after `/fromcubes/`, e.g. `page1` → served at `/fromcubes/page1`. Required. Nesting allowed (`team/alpha`). Reserved: `public`, `_ws`. |
76
+ | Page Title | Browser tab title |
77
+ | npm Packages | Comma-separated, e.g. `d3, three, @react-three/fiber` |
78
+ | Portal Auth | Enable portal user header extraction (see Multi-user) |
55
79
  | Head HTML | Extra `<head>` tags (CDN, fonts, CSS) |
56
80
  | Code Editor | Monaco with JSX — must define `<App />` |
57
81
 
58
- ### fc-component-library (config node)
59
-
60
- Shared component store. Each component has name, code, input/output field definitions.
61
- Components are auto-injected into every portal-react page at transpile time.
82
+ There is also a config node, **fc-portal-component**, that lets you define reusable React components once and reference them by name from any portal-react node. Referenced components (and their transitive dependencies) are injected at transpile time, so unused ones add nothing to the bundle.
62
83
 
63
84
  ## Editor features
64
85
 
65
- - **Monaco editor** with full JSX support and `useNodeRed()` type declarations
66
- - **Tailwind CSS autocompletion** inside `className="..."` strings (~19k utility classes)
67
- - **JSX tag completion** — type tag name, Tab to expand (open+close and self-closing variants)
86
+ - **Monaco** with full JSX support and `useNodeRed()` type declarations
87
+ - **Tailwind CSS autocompletion** inside `className="..."` (~19k utility classes)
88
+ - **JSX tag completion** — type tag name, Tab to expand
68
89
  - **Self-close collapse** — type `/` inside empty `<tag></tag>` to convert to `<tag />`
69
90
  - **Component completion** — registry components + any PascalCase word
91
+ - **Portal Assets sidebar** — file manager for static assets (GLB, textures, fonts…)
70
92
 
71
- ## Hook API
93
+ ## Multi-user / Multi-tenancy
72
94
 
73
- ```jsx
74
- function App() {
75
- const { data, send } = useNodeRed();
76
- // data = last msg.payload from input wire (reactive)
77
- // send(payload, topic?) = emit msg on output wire
78
- return <div className="p-4 text-lg">{JSON.stringify(data)}</div>;
79
- }
95
+ Portal-react has three routing modes — broadcast, user-cast (every tab of one user), and unicast (one specific tab). Everything works without authentication too — user-cast just degrades gracefully when there is no user.
96
+
97
+ ### Identity
98
+
99
+ | Identifier | Scope | Persistence | Source |
100
+ |---|---|---|---|
101
+ | `portalClient` | Single WS session (one tab) | Lost on reconnect — server assigns a new UUID | Generated server-side on connect |
102
+ | `userId` / `username` | All sessions of a user | Survives reconnect and new tabs | `x-portal-user-*` headers (when **Portal Auth** is enabled) |
103
+
104
+ **Portal Auth header contract** — injected by an upstream reverse proxy such as `aaqu-portal-auth`:
105
+
106
+ | Header | Field |
107
+ |---|---|
108
+ | `x-portal-user-id` | `userId` |
109
+ | `x-portal-user-name` | `userName` |
110
+ | `x-portal-user-username` | `username` |
111
+ | `x-portal-user-email` | `email` |
112
+ | `x-portal-user-role` | `role` |
113
+ | `x-portal-user-groups` | `groups` (JSON array) |
114
+
115
+ If Portal Auth is disabled or headers are absent, `user` is `null` and user-scoped features are silently skipped — broadcast and per-session features still work.
116
+
117
+ ### Routing modes
118
+
119
+ Every inbound `msg` arrives in your flow with `msg._client` already filled in by the server. The flow then decides where the response should go by setting (or clearing) `msg._client` on the outgoing `msg`:
120
+
121
+ ```javascript
122
+ // BROADCAST — everyone connected to this endpoint
123
+ delete msg._client;
124
+ return msg;
125
+
126
+ // UNICAST — only the tab that sent the original msg (echo)
127
+ // Keep msg._client as-is; it already carries portalClient.
128
+ return msg;
129
+
130
+ // UNICAST — a specific tab whose ID you know
131
+ msg._client = { portalClient: "a1b2c3d4-..." };
132
+ return msg;
133
+
134
+ // USER-CAST — every tab of a specific user (even ones that just opened)
135
+ msg._client = { userId: "alice" };
136
+ return msg;
80
137
  ```
81
138
 
82
- ## Deploy lifecycle
139
+ **Anti-spoof guarantee.** On every inbound message the server overwrites `msg._client` from scratch using the socket's own `portalClient` and the user data captured at connect. A browser cannot forge `_client` — whatever it puts there is discarded.
83
140
 
84
- What happens on each deploy:
141
+ **User-cast uses an O(1) index** so a message to `{userId: "alice"}` reaches every tab of Alice with a single lookup, not a scan.
85
142
 
86
- 1. Node `close` fires on existing instance
87
- 2. All WebSocket clients receive close code `1001` ("node redeployed")
88
- 3. Browser auto-reconnects with exponential backoff (500ms → 1s → 2s → 4s → 8s cap)
89
- 4. Stale HTTP route and WS upgrade handler removed
90
- 5. New instance transpiles JSX:
91
- - Content hash computed
92
- - Cache hit → reuse (0ms)
93
- - Cache miss → esbuild transpile (~5ms)
94
- 6. New HTTP route and WS handler registered
95
- 7. Reconnecting clients get fresh page + current `lastPayload`
143
+ ### Without a user (anonymous mode)
96
144
 
97
- Rapid deploys (user clicking deploy repeatedly) are safe:
98
- - `isClosing` flag prevents accepting new WS connections during teardown
99
- - Upgrade handlers are tracked per node ID, old ones removed before new ones register
100
- - No orphan listeners accumulate on `RED.server`
145
+ If **Portal Auth** is off (or no proxy headers arrive), everything still works:
101
146
 
102
- Transpile errors:
103
- - Node status shows red "transpile error"
104
- - Endpoint serves an error page with the error message
105
- - Fix code, redeploy — cache invalidates because hash changes
147
+ - `broadcast` and `portalClient` unicast: unchanged
148
+ - `user`-cast: gracefully skipped (no `userId` to target)
149
+ - `useNodeRed().user` is `null`
106
150
 
107
- ## Browser payload
151
+ Same model as dashboard 2 — no auth required to use the node.
108
152
 
109
- | Asset | Size (gzip) |
110
- |---|---|
111
- | react-19.production.min.js | ~45 KB |
112
- | Your transpiled JS | ~1-5 KB |
113
- | Tailwind CSS (CDN) | cached |
114
- | WebSocket bridge | <1 KB |
153
+ ## Portal Assets
154
+
155
+ Static files (3D models, textures, fonts, etc.) can be uploaded and served from a public endpoint.
115
156
 
116
- No Babel, no Sucrase client, no Vue, no Vuetify, no Socket.IO.
157
+ - Open the **Portal Assets** tab in the Node-RED sidebar
158
+ - Upload files via button or drag & drop
159
+ - Organize in folders (create, rename, move between folders)
160
+ - Copy public path with one click — use in JSX: `/fromcubes/public/models/scene.glb`
161
+ - Download and delete files from the context menu
162
+
163
+ All uploads require Node-RED admin authentication. Files are served publicly at `/fromcubes/public/`.
164
+
165
+ Limits: 100 MB per file, 500 MB total, 1000 files max.
166
+
167
+ ## Examples
168
+
169
+ Import `001-shared-components-flow.json` first — it provides shared UI components (Page, Header, Stat, Button, ValueBadge) used by the others.
170
+
171
+ | Flow | npm packages | Description |
172
+ |---|---|---|
173
+ | `001-shared-components-flow.json` | — | Shared components: Page, Header, Stat, Button, ValueBadge |
174
+ | `002-sensor-portal-flow.json` | — | Sensor gauge with live WebSocket data |
175
+ | `003-chart-portal-flow.json` | `chart.js/auto` | Live updating Chart.js charts |
176
+ | `004-d3-poland-flow.json` | `d3` | Interactive SVG map of Poland (simulated data) |
177
+ | `005-threejs-portal-flow.json` | `three` | 3D scene with Three.js |
178
+ | `006-pixi-portal-flow.json` | `pixi.js`, `@pixi/react` | Clickable bunny sprites with PixiJS |
179
+ | `007-webgpu-tsl-flow.json` | `three` | WebGPU renderer + TSL animated shaders |
180
+
181
+ ## Troubleshooting
182
+
183
+ | Symptom | Likely cause |
184
+ |---|---|
185
+ | Red status "transpile error" + error page on the endpoint | JSX syntax error — fix code, redeploy (cache invalidates automatically) |
186
+ | Red status "legacy endpoint" on deploy | Flow was saved before the `/fromcubes/` prefix became hardcoded. Open the node, set **Sub-path**, redeploy. Automatic migration is disabled to avoid silent URL changes. |
187
+ | Red status "bad sub-path" | Sub-path is empty or violates the rules (no leading `/`, no whitespace, no `..`, segments must start alphanumerically, `public`/`_ws` reserved). |
188
+ | Page loads but `data` stays `undefined` | No input wire has fired yet — broadcast something into the node |
189
+ | `user` is `null` even with Portal Auth on | Upstream proxy is not injecting `x-portal-user-*` headers |
190
+ | New tab shows the previous broadcast value | Expected — that's the recovery frame. Use `useNodeRed({ ignoreRecovery: true })` to opt out |
191
+ | Page reloads on every deploy | Expected for code changes; clients soft-reconnect with exponential backoff |
117
192
 
118
193
  ## License
119
194
 
120
- MIT
195
+ Apache-2.0
@@ -0,0 +1,68 @@
1
+ [
2
+ {
3
+ "id": "fc-shared-flow",
4
+ "type": "tab",
5
+ "label": "Shared Components",
6
+ "disabled": false
7
+ },
8
+ {
9
+ "id": "fc-shared-comp-header",
10
+ "type": "fc-portal-component",
11
+ "z": "fc-shared-flow",
12
+ "compName": "Header",
13
+ "compCode": "function Header({ title = 'fromcubes', subtitle = '', children }) {\n return (\n <div className=\"flex items-center justify-between px-6 py-4\">\n <div>\n <h1 className=\"text-xl font-semibold text-zinc-100 tracking-tight\">{title}</h1>\n {subtitle && <p className=\"text-xs text-zinc-600 mt-0.5\">{subtitle}</p>}\n </div>\n <div className=\"flex items-center gap-2\">\n {children || (\n <>\n <span className=\"inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse\" />\n <span className=\"text-xs text-zinc-500\">streaming</span>\n </>\n )}\n </div>\n </div>\n );\n}",
14
+ "compInputs": "title,subtitle,children",
15
+ "compOutputs": "",
16
+ "x": 160,
17
+ "y": 160,
18
+ "wires": []
19
+ },
20
+ {
21
+ "id": "fc-shared-comp-stat",
22
+ "type": "fc-portal-component",
23
+ "z": "fc-shared-flow",
24
+ "compName": "Stat",
25
+ "compCode": "function Stat({ label, children }) {\n return (\n <div className=\"flex items-center gap-3\">\n <span className=\"text-xs text-zinc-500\">{label}</span>\n {children}\n </div>\n );\n}",
26
+ "compInputs": "label,children",
27
+ "compOutputs": "",
28
+ "x": 360,
29
+ "y": 160,
30
+ "wires": []
31
+ },
32
+ {
33
+ "id": "fc-shared-comp-button",
34
+ "type": "fc-portal-component",
35
+ "z": "fc-shared-flow",
36
+ "compName": "Button",
37
+ "compCode": "function Button({ onClick, children, variant = 'primary', className = '' }) {\n const base = 'px-4 py-2 rounded-lg font-medium transition-colors';\n const variants = {\n primary: 'bg-blue-600 text-white hover:bg-blue-500',\n secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',\n danger: 'bg-red-600 text-white hover:bg-red-500'\n };\n return (\n <button\n className={`${base} ${variants[variant] || variants.primary} ${className}`}\n onClick={onClick}\n >{children}</button>\n );\n}",
38
+ "compInputs": "onClick,children,variant,className",
39
+ "compOutputs": "",
40
+ "x": 560,
41
+ "y": 160,
42
+ "wires": []
43
+ },
44
+ {
45
+ "id": "fc-shared-comp-valuebadge",
46
+ "type": "fc-portal-component",
47
+ "z": "fc-shared-flow",
48
+ "compName": "ValueBadge",
49
+ "compCode": "function ValueBadge({ label, value, className = '' }) {\n if (value == null) return null;\n return (\n <span className={`text-zinc-600 text-xs ${className}`}>\n {label && <>{label}: </>}{value}\n </span>\n );\n}",
50
+ "compInputs": "label,value,className",
51
+ "compOutputs": "",
52
+ "x": 760,
53
+ "y": 160,
54
+ "wires": []
55
+ },
56
+ {
57
+ "id": "fc-shared-comp-page",
58
+ "type": "fc-portal-component",
59
+ "z": "fc-shared-flow",
60
+ "compName": "Page",
61
+ "compCode": "function Page({ children, className = '' }) {\n return (\n <div className={`min-h-screen bg-zinc-950 text-zinc-100 flex flex-col ${className}`}>\n {children}\n </div>\n );\n}",
62
+ "compInputs": "children,className",
63
+ "compOutputs": "",
64
+ "x": 160,
65
+ "y": 240,
66
+ "wires": []
67
+ }
68
+ ]
@@ -2,7 +2,7 @@
2
2
  {
3
3
  "id": "fc-demo-flow",
4
4
  "type": "tab",
5
- "label": "Portal React Demo",
5
+ "label": "Sensor Portal",
6
6
  "disabled": false
7
7
  },
8
8
  {
@@ -47,12 +47,12 @@
47
47
  "type": "portal-react",
48
48
  "z": "fc-demo-flow",
49
49
  "name": "Sensor Portal",
50
- "endpoint": "/sensors",
50
+ "subPath": "sensors",
51
51
  "pageTitle": "Sensors",
52
52
  "customHead": "",
53
53
  "inputSchema": "[{\"name\":\"temp\",\"type\":\"number\"},{\"name\":\"humidity\",\"type\":\"number\"},{\"name\":\"pressure\",\"type\":\"number\"},{\"name\":\"ts\",\"type\":\"number\"}]",
54
54
  "outputSchema": "[{\"name\":\"action\",\"type\":\"string\"}]",
55
- "componentCode": "function App() {\n const { data, send } = useNodeRed();\n const s = data || { temp: 0, humidity: 0, pressure: 0 };\n\n return (\n <div className=\"min-h-screen bg-zinc-950 p-8 max-w-3xl mx-auto\">\n <h1 className=\"text-2xl font-light text-cyan-400 mb-6\">Sensor Portal</h1>\n <div className=\"flex gap-6 flex-wrap\">\n <Gauge value={s.temp} min={-10} max={50} label=\"Temperature\" unit=\"°C\" />\n <Gauge value={s.humidity} min={0} max={100} label=\"Humidity\" unit=\"%\" />\n <Gauge value={s.pressure} min={950} max={1050} label=\"Pressure\" unit=\"hPa\" />\n </div>\n <div className=\"mt-6 flex gap-3 items-center\">\n <button\n className=\"px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors\"\n onClick={() => send({ action: 'refresh' })}\n >Refresh</button>\n {s.ts && <span className=\"text-zinc-600 text-xs\">Last: {new Date(s.ts).toLocaleTimeString()}</span>}\n </div>\n </div>\n );\n}",
55
+ "componentCode": "function App() {\n const { data, send } = useNodeRed();\n const s = data || { temp: 0, humidity: 0, pressure: 0 };\n\n return (\n <Page>\n <Header title=\"Sensor Portal\" subtitle=\"live sensor data\" />\n <div className=\"flex-1 flex flex-col items-center justify-center p-8\">\n <div className=\"flex gap-10 flex-wrap justify-center\">\n <Gauge value={s.temp} min={-10} max={50} label=\"Temperature\" unit=\"°C\" />\n <Gauge value={s.humidity} min={0} max={100} label=\"Humidity\" unit=\"%\" />\n <Gauge value={s.pressure} min={950} max={1050} label=\"Pressure\" unit=\"hPa\" />\n </div>\n <div className=\"mt-10 flex gap-3 items-center\">\n <Button onClick={() => send({ action: 'refresh' })}>Refresh</Button>\n <ValueBadge label=\"Last\" value={s.ts ? new Date(s.ts).toLocaleTimeString() : null} />\n </div>\n </div>\n </Page>\n );\n}",
56
56
  "x": 560,
57
57
  "y": 160,
58
58
  "wires": [["fc-debug"]]
@@ -0,0 +1,93 @@
1
+ [
2
+ {
3
+ "id": "fc-chart-flow",
4
+ "type": "tab",
5
+ "label": "Chart.js Demo",
6
+ "disabled": false
7
+ },
8
+ {
9
+ "id": "fc-chart-linechart-comp",
10
+ "type": "fc-portal-component",
11
+ "z": "fc-chart-flow",
12
+ "name": "LineChart",
13
+ "compName": "LineChart",
14
+ "compInputs": "data,labelKey,datasets,title,height",
15
+ "compOutputs": "",
16
+ "compCode": "import ChartJS from 'chart.js/auto';\n\nfunction LineChart({ data = [], labelKey = 'time', datasets = [], title = '', height = 'h-64' }) {\n const canvasRef = useRef(null);\n const chartRef = useRef(null);\n\n useEffect(() => {\n if (!canvasRef.current || data.length === 0) return;\n\n if (chartRef.current) {\n const chart = chartRef.current;\n chart.data.labels = data.map(p => p[labelKey]);\n datasets.forEach((ds, i) => {\n chart.data.datasets[i].data = data.map(p => p[ds.key]);\n });\n chart.update('none');\n return;\n }\n\n\n chartRef.current = new ChartJS(canvasRef.current, {\n type: 'line',\n data: {\n labels: data.map(p => p[labelKey]),\n datasets: datasets.map(ds => ({\n label: ds.label,\n data: data.map(p => p[ds.key]),\n borderColor: ds.color,\n backgroundColor: ds.color + '18',\n tension: 0.35,\n fill: true,\n pointRadius: 2,\n borderWidth: 2\n }))\n },\n options: {\n responsive: true,\n maintainAspectRatio: false,\n animation: false,\n plugins: {\n legend: { labels: { color: '#a1a1aa', usePointStyle: true, pointStyle: 'circle', padding: 16 } }\n },\n scales: {\n x: { ticks: { color: '#52525b', font: { size: 10 } }, grid: { color: '#27272a' } },\n y: { ticks: { color: '#52525b', font: { size: 10 } }, grid: { color: '#27272a' } }\n }\n }\n });\n\n return () => {\n if (chartRef.current) {\n chartRef.current.destroy();\n chartRef.current = null;\n }\n };\n }, [data, datasets]);\n\n return (\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n {title && <h3 className=\"text-sm font-medium text-zinc-300 mb-3\">{title}</h3>}\n <div className={height}>\n <canvas ref={canvasRef} />\n </div>\n </div>\n );\n}",
17
+ "x": 380,
18
+ "y": 80,
19
+ "wires": []
20
+ },
21
+ {
22
+ "id": "fc-chart-inject",
23
+ "type": "inject",
24
+ "z": "fc-chart-flow",
25
+ "name": "Sensor tick",
26
+ "props": [
27
+ {
28
+ "p": "payload"
29
+ }
30
+ ],
31
+ "repeat": "2",
32
+ "payload": "{}",
33
+ "payloadType": "json",
34
+ "x": 160,
35
+ "y": 160,
36
+ "wires": [
37
+ [
38
+ "fc-chart-func"
39
+ ]
40
+ ]
41
+ },
42
+ {
43
+ "id": "fc-chart-func",
44
+ "type": "function",
45
+ "z": "fc-chart-flow",
46
+ "name": "Random data point",
47
+ "func": "var history = flow.get('chartHistory') || [];\nvar now = new Date();\nhistory.push({\n time: now.toLocaleTimeString(),\n temp: +(18 + Math.random() * 12).toFixed(1),\n humidity: Math.round(35 + Math.random() * 40),\n cpu: Math.round(15 + Math.random() * 60),\n memory: Math.round(40 + Math.random() * 45)\n});\nif (history.length > 20) history.shift();\nflow.set('chartHistory', history);\nmsg.payload = history;\nreturn msg;",
48
+ "outputs": 1,
49
+ "x": 380,
50
+ "y": 160,
51
+ "wires": [
52
+ [
53
+ "fc-chart-portal"
54
+ ]
55
+ ]
56
+ },
57
+ {
58
+ "id": "fc-chart-portal",
59
+ "type": "portal-react",
60
+ "z": "fc-chart-flow",
61
+ "name": "Chart Portal",
62
+ "subPath": "chart",
63
+ "pageTitle": "fromcubes charts",
64
+ "customHead": "",
65
+ "portalAuth": false,
66
+ "showWsStatus": false,
67
+ "libs": [
68
+ {
69
+ "module": "chart.js/auto",
70
+ "var": "Chart"
71
+ }
72
+ ],
73
+ "componentCode": "function App() {\n const { data } = useNodeRed();\n const [tab, setTab] = useState(0);\n const points = data || [];\n\n const tabs = [\n { label: 'Overview', icon: '\\u25a6' },\n { label: 'Environment', icon: '\\u25cb' },\n { label: 'System', icon: '\\u25a1' }\n ];\n\n return (\n <Page>\n <div className=\"max-w-5xl mx-auto px-6 py-8\">\n\n <Header title=\"fromcubes charts\" subtitle={points.length + ' data points \\u2022 live'} />\n\n <div className=\"flex gap-1 mb-6 bg-zinc-900/50 rounded-lg p-1 w-fit\">\n {tabs.map((t, i) => (\n <button\n key={i}\n onClick={() => setTab(i)}\n className={`px-4 py-2 rounded-md text-sm transition-all ${\n tab === i\n ? 'bg-zinc-800 text-zinc-100 shadow-sm'\n : 'text-zinc-500 hover:text-zinc-300'\n }`}\n >{t.icon} {t.label}</button>\n ))}\n </div>\n\n {tab === 0 && (\n <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-4\">\n <LineChart\n data={points}\n title=\"Temperature\"\n datasets={[{ key: 'temp', label: 'Temp (\\u00b0C)', color: '#22d3ee' }]}\n />\n <LineChart\n data={points}\n title=\"Humidity\"\n datasets={[{ key: 'humidity', label: 'Humidity (%)', color: '#a78bfa' }]}\n />\n <LineChart\n data={points}\n title=\"CPU Usage\"\n datasets={[{ key: 'cpu', label: 'CPU (%)', color: '#f97316' }]}\n />\n <LineChart\n data={points}\n title=\"Memory\"\n datasets={[{ key: 'memory', label: 'Memory (%)', color: '#34d399' }]}\n />\n </div>\n )}\n\n {tab === 1 && (\n <div className=\"space-y-4\">\n <LineChart\n data={points}\n title=\"Temperature & Humidity\"\n height=\"h-80\"\n datasets={[\n { key: 'temp', label: 'Temp (\\u00b0C)', color: '#22d3ee' },\n { key: 'humidity', label: 'Humidity (%)', color: '#a78bfa' }\n ]}\n />\n <div className=\"grid grid-cols-2 gap-4\">\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current Temp</div>\n <div className=\"text-3xl font-bold text-cyan-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].temp : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">\\u00b0C</span>\n </div>\n </div>\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current Humidity</div>\n <div className=\"text-3xl font-bold text-violet-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].humidity : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">%</span>\n </div>\n </div>\n </div>\n </div>\n )}\n\n {tab === 2 && (\n <div className=\"space-y-4\">\n <LineChart\n data={points}\n title=\"CPU & Memory\"\n height=\"h-80\"\n datasets={[\n { key: 'cpu', label: 'CPU (%)', color: '#f97316' },\n { key: 'memory', label: 'Memory (%)', color: '#34d399' }\n ]}\n />\n <div className=\"grid grid-cols-2 gap-4\">\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current CPU</div>\n <div className=\"text-3xl font-bold text-orange-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].cpu : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">%</span>\n </div>\n </div>\n <div className=\"bg-zinc-900/50 rounded-xl border border-zinc-800/50 p-5\">\n <div className=\"text-xs text-zinc-500 uppercase tracking-wider\">Current Memory</div>\n <div className=\"text-3xl font-bold text-emerald-400 mt-2\">\n {points.length > 0 ? points[points.length - 1].memory : '--'}\n <span className=\"text-sm font-normal text-zinc-500 ml-1\">%</span>\n </div>\n </div>\n </div>\n </div>\n )}\n\n </div>\n </Page>\n );\n}",
74
+ "x": 600,
75
+ "y": 160,
76
+ "wires": [
77
+ [
78
+ "fc-chart-debug"
79
+ ]
80
+ ]
81
+ },
82
+ {
83
+ "id": "fc-chart-debug",
84
+ "type": "debug",
85
+ "z": "fc-chart-flow",
86
+ "name": "Chart output",
87
+ "active": true,
88
+ "tosidebar": true,
89
+ "x": 790,
90
+ "y": 160,
91
+ "wires": []
92
+ }
93
+ ]