@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +1 -1
- package/README.md +154 -79
- package/examples/001-shared-components-flow.json +68 -0
- package/examples/{sensor-portal-flow.json → 002-sensor-portal-flow.json} +3 -3
- package/examples/003-chart-portal-flow.json +93 -0
- package/examples/004-d3-poland-flow.json +80 -0
- package/examples/005-threejs-portal-flow.json +87 -0
- package/examples/006-pixi-portal-flow.json +86 -0
- package/examples/007-webgpu-tsl-flow.json +85 -0
- package/nodes/lib/assets.js +212 -0
- package/nodes/lib/helpers.js +314 -0
- package/nodes/lib/hooks.js +82 -0
- package/nodes/lib/page-builder.js +347 -0
- package/nodes/lib/router.js +56 -0
- package/nodes/portal-react.html +1143 -196
- package/nodes/portal-react.js +911 -353
- package/package.json +21 -11
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,120 +1,195 @@
|
|
|
1
1
|
# @aaqu/fromcubes-portal-react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **⚠️ Alpha Module** — This project is in early development. Expect breaking changes. Test on a clean Node-RED instance.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+
[](https://ko-fi.com/L4L01UOFRG)
|
|
27
10
|
|
|
28
11
|
## Install
|
|
29
12
|
|
|
30
13
|
```bash
|
|
31
14
|
cd ~/.node-red
|
|
32
|
-
npm install /
|
|
15
|
+
npm install @aaqu/fromcubes-portal-react@alpha
|
|
33
16
|
# restart Node-RED
|
|
34
17
|
```
|
|
35
18
|
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
## Node configuration
|
|
48
72
|
|
|
49
73
|
| Field | Purpose |
|
|
50
74
|
|---|---|
|
|
51
|
-
|
|
|
52
|
-
| Title | Browser tab title |
|
|
53
|
-
|
|
|
54
|
-
|
|
|
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
|
-
|
|
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
|
|
66
|
-
- **Tailwind CSS autocompletion** inside `className="..."`
|
|
67
|
-
- **JSX tag completion** — type tag name, Tab to expand
|
|
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
|
-
##
|
|
93
|
+
## Multi-user / Multi-tenancy
|
|
72
94
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
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
|
-
|
|
151
|
+
Same model as dashboard 2 — no auth required to use the node.
|
|
108
152
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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 <
|
|
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
|
+
]
|