@sansavision/create-pulse 0.1.0-alpha.2 → 0.1.0-alpha.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/package.json +1 -1
- package/templates/react-all-features/src/App.tsx +44 -16
- package/templates/react-all-features/src/components/EncryptedChat.tsx +230 -0
- package/templates/react-all-features/src/components/GameSync.tsx +266 -0
- package/templates/react-all-features/src/components/ServerMetrics.tsx +133 -0
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react';
|
|
2
2
|
import { usePulse } from './hooks/usePulse';
|
|
3
3
|
import { VideoPlayer } from './components/VideoPlayer';
|
|
4
|
-
import {
|
|
4
|
+
import { GameSync } from './components/GameSync';
|
|
5
|
+
import { EncryptedChat } from './components/EncryptedChat';
|
|
6
|
+
import { ServerMetrics } from './components/ServerMetrics';
|
|
7
|
+
import { Activity, Users, MessageSquare, Video, LineChart, ArrowLeft, Gamepad2 } from 'lucide-react';
|
|
5
8
|
|
|
6
9
|
const VIDEO_URL = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
|
7
10
|
const STREAM_NAME = "watch-together-demo";
|
|
@@ -12,7 +15,7 @@ export default function App() {
|
|
|
12
15
|
apiKey: 'demo'
|
|
13
16
|
});
|
|
14
17
|
|
|
15
|
-
const [activeFeature, setActiveFeature] = useState<'hub' | 'watch' | 'chat' | 'metrics'>('hub');
|
|
18
|
+
const [activeFeature, setActiveFeature] = useState<'hub' | 'watch' | 'chat' | 'metrics' | 'game'>('hub');
|
|
16
19
|
|
|
17
20
|
const [hostTime, setHostTime] = useState(0);
|
|
18
21
|
const [peerTime, setPeerTime] = useState(0);
|
|
@@ -139,6 +142,26 @@ export default function App() {
|
|
|
139
142
|
</div>
|
|
140
143
|
</div>
|
|
141
144
|
|
|
145
|
+
{/* Game Sync Card */}
|
|
146
|
+
<div onClick={() => setActiveFeature('game')} className="glass-panel p-7 cursor-pointer hover:-translate-y-1 transition-all duration-300 group relative overflow-hidden">
|
|
147
|
+
<div className="absolute -top-16 -right-16 w-48 h-48 bg-purple-500/20 rounded-full blur-[40px] opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
|
148
|
+
<div className="flex items-center gap-4 mb-4">
|
|
149
|
+
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/10 text-purple-400">
|
|
150
|
+
<Gamepad2 className="w-6 h-6" />
|
|
151
|
+
</div>
|
|
152
|
+
<div className="flex flex-col gap-1">
|
|
153
|
+
<span className="text-[10px] font-bold uppercase tracking-wider bg-purple-500/20 text-purple-400 px-2.5 py-1 rounded-full w-fit">Realtime</span>
|
|
154
|
+
<span className="text-xs text-textDim font-mono">StreamMode::Realtime</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<h3 className="text-lg font-bold mb-2">Game State Sync</h3>
|
|
158
|
+
<p className="text-sm text-textMuted leading-relaxed mb-6">60Hz realtime game loop syncing player positions. Sub-10ms latency updates over Pulse.</p>
|
|
159
|
+
<div className="flex gap-2 relative z-10">
|
|
160
|
+
<span className="px-2.5 py-1 rounded-full text-[10px] border border-pulseBorder text-textMuted bg-white/5">React</span>
|
|
161
|
+
<span className="px-2.5 py-1 rounded-full text-[10px] border border-pulseBorder text-textMuted bg-white/5">Canvas</span>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
142
165
|
{/* Chat Card */}
|
|
143
166
|
<div onClick={() => setActiveFeature('chat')} className="glass-panel p-7 cursor-pointer hover:-translate-y-1 transition-all duration-300 group relative overflow-hidden">
|
|
144
167
|
<div className="absolute -top-16 -right-16 w-48 h-48 bg-brandEmerald/20 rounded-full blur-[40px] opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
|
@@ -277,14 +300,9 @@ export default function App() {
|
|
|
277
300
|
</div>
|
|
278
301
|
|
|
279
302
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Encrypted Chat</h2>
|
|
303
|
+
<div className="text-textMuted mb-6 text-sm">Real Web Crypto API (ECDH P-256 + AES-GCM) powering E2E encryption over Pulse Relay.</div>
|
|
280
304
|
|
|
281
|
-
<
|
|
282
|
-
<MessageSquare className="w-16 h-16 text-brandEmerald mb-6 opacity-80" />
|
|
283
|
-
<h3 className="text-2xl font-bold mb-3">Chat Components Setup Here</h3>
|
|
284
|
-
<p className="text-textMuted text-center max-w-md mx-auto leading-relaxed">
|
|
285
|
-
Implementation for the E2E encrypted React UI chat components go here. Refer to the base HTML demo for logical structure.
|
|
286
|
-
</p>
|
|
287
|
-
</div>
|
|
305
|
+
<EncryptedChat useChannel={useChannel} logEvent={logEvent} />
|
|
288
306
|
</div>
|
|
289
307
|
)}
|
|
290
308
|
|
|
@@ -293,18 +311,28 @@ export default function App() {
|
|
|
293
311
|
<div className="flex items-center gap-3 text-sm text-textDim mb-6">
|
|
294
312
|
<span className="text-brandPurple cursor-pointer hover:text-brandCyan transition-colors" onClick={() => setActiveFeature('hub')}>Hub</span>
|
|
295
313
|
<span>/</span>
|
|
296
|
-
<span className="text-textMain">Server Metrics</span>
|
|
314
|
+
<span className="text-textMain">Server Metrics Dashboard</span>
|
|
297
315
|
</div>
|
|
298
316
|
|
|
299
317
|
<h2 className="text-3xl font-bold tracking-tight mb-2">Server Metrics Dashboard</h2>
|
|
318
|
+
<div className="text-textMuted mb-6 text-sm">Receiving live server telemetry via Pulse STREAM_DATA and rendering realtime charts.</div>
|
|
300
319
|
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
320
|
+
<ServerMetrics useChannel={useChannel} logEvent={logEvent} />
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{activeFeature === 'game' && (
|
|
325
|
+
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
326
|
+
<div className="flex items-center gap-3 text-sm text-textDim mb-6">
|
|
327
|
+
<span className="text-brandPurple cursor-pointer hover:text-brandCyan transition-colors" onClick={() => setActiveFeature('hub')}>Hub</span>
|
|
328
|
+
<span>/</span>
|
|
329
|
+
<span className="text-textMain">Game State Sync</span>
|
|
307
330
|
</div>
|
|
331
|
+
|
|
332
|
+
<h2 className="text-3xl font-bold tracking-tight mb-2">Game State Sync</h2>
|
|
333
|
+
<div className="text-textMuted mb-6 text-sm">Sending 60Hz tick payloads over Pulse relay and interpolating remote peer states smoothly via Canvas API.</div>
|
|
334
|
+
|
|
335
|
+
<GameSync useChannel={useChannel} logEvent={logEvent} />
|
|
308
336
|
</div>
|
|
309
337
|
)}
|
|
310
338
|
</main>
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
// Simplified port of Web Crypto API usage from pulse-demos
|
|
4
|
+
// In a real app we'd place this in a shared utility file
|
|
5
|
+
export const WebCrypto = {
|
|
6
|
+
async generateKeyPair() {
|
|
7
|
+
return crypto.subtle.generateKey(
|
|
8
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
9
|
+
true,
|
|
10
|
+
["deriveKey"]
|
|
11
|
+
);
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
async exportPublicKey(keyPair: CryptoKeyPair) {
|
|
15
|
+
const raw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
|
|
16
|
+
return btoa(String.fromCharCode(...new Uint8Array(raw)));
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
async importPublicKey(base64: string) {
|
|
20
|
+
const raw = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
21
|
+
return crypto.subtle.importKey(
|
|
22
|
+
"raw",
|
|
23
|
+
raw,
|
|
24
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
25
|
+
true,
|
|
26
|
+
[]
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async deriveSharedKey(privateKey: CryptoKey, peerPublicKey: CryptoKey) {
|
|
31
|
+
return crypto.subtle.deriveKey(
|
|
32
|
+
{ name: "ECDH", public: peerPublicKey },
|
|
33
|
+
privateKey,
|
|
34
|
+
{ name: "AES-GCM", length: 256 },
|
|
35
|
+
false,
|
|
36
|
+
["encrypt", "decrypt"]
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async encrypt(sharedKey: CryptoKey, plaintext: string) {
|
|
41
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
42
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
43
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
44
|
+
{ name: "AES-GCM", iv },
|
|
45
|
+
sharedKey,
|
|
46
|
+
encoded
|
|
47
|
+
);
|
|
48
|
+
const result = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
49
|
+
result.set(iv, 0);
|
|
50
|
+
result.set(new Uint8Array(ciphertext), iv.length);
|
|
51
|
+
return result;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async decrypt(sharedKey: CryptoKey, data: Uint8Array) {
|
|
55
|
+
const iv = data.slice(0, 12);
|
|
56
|
+
const ciphertext = data.slice(12);
|
|
57
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
58
|
+
{ name: "AES-GCM", iv },
|
|
59
|
+
sharedKey,
|
|
60
|
+
ciphertext
|
|
61
|
+
);
|
|
62
|
+
return new TextDecoder().decode(decrypted);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
interface EncryptedChatProps {
|
|
67
|
+
// Both users will share the primary connection for demoing
|
|
68
|
+
// but we simulate the end-to-end crypto locally.
|
|
69
|
+
useChannel: any;
|
|
70
|
+
logEvent: (action: string) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function EncryptedChat({ useChannel, logEvent }: EncryptedChatProps) {
|
|
74
|
+
const [status, setStatus] = useState('Pending');
|
|
75
|
+
const [aliceKeyStr, setAliceKeyStr] = useState('generating...');
|
|
76
|
+
const [bobKeyStr, setBobKeyStr] = useState('generating...');
|
|
77
|
+
const keysRef = useRef<{ alice: CryptoKey | null, bob: CryptoKey | null }>({ alice: null, bob: null });
|
|
78
|
+
|
|
79
|
+
const [aliceMessages, setAliceMessages] = useState<{ me: boolean, text: string }[]>([]);
|
|
80
|
+
const [bobMessages, setBobMessages] = useState<{ me: boolean, text: string }[]>([]);
|
|
81
|
+
|
|
82
|
+
const [aliceDraft, setAliceDraft] = useState('');
|
|
83
|
+
const [bobDraft, setBobDraft] = useState('');
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const setupCrypto = async () => {
|
|
87
|
+
try {
|
|
88
|
+
const aliceKP = await WebCrypto.generateKeyPair();
|
|
89
|
+
const bobKP = await WebCrypto.generateKeyPair();
|
|
90
|
+
|
|
91
|
+
const alicePub = await WebCrypto.exportPublicKey(aliceKP);
|
|
92
|
+
const bobPub = await WebCrypto.exportPublicKey(bobKP);
|
|
93
|
+
|
|
94
|
+
setAliceKeyStr(`Pub: ${alicePub.slice(0, 16)}...`);
|
|
95
|
+
setBobKeyStr(`Pub: ${bobPub.slice(0, 16)}...`);
|
|
96
|
+
|
|
97
|
+
const bobPubImported = await WebCrypto.importPublicKey(bobPub);
|
|
98
|
+
const alicePubImported = await WebCrypto.importPublicKey(alicePub);
|
|
99
|
+
|
|
100
|
+
keysRef.current.alice = await WebCrypto.deriveSharedKey(aliceKP.privateKey, bobPubImported);
|
|
101
|
+
keysRef.current.bob = await WebCrypto.deriveSharedKey(bobKP.privateKey, alicePubImported);
|
|
102
|
+
|
|
103
|
+
setStatus('Ready (AES-256-GCM Derived)');
|
|
104
|
+
logEvent('KEYS DERIVED: AES-256-GCM');
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error(err);
|
|
107
|
+
setStatus('Failed to generate keys');
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
setupCrypto();
|
|
111
|
+
}, [logEvent]);
|
|
112
|
+
|
|
113
|
+
const handleMessage = useCallback(async (msg: any) => {
|
|
114
|
+
// Here msg payload is base64 string
|
|
115
|
+
if (msg.type !== 'e2e') return;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const raw = Uint8Array.from(atob(msg.ciphertext), (c) => c.charCodeAt(0));
|
|
119
|
+
// Simulate receiving the payload
|
|
120
|
+
logEvent(`RECV: ${raw.length} opaque bytes`);
|
|
121
|
+
|
|
122
|
+
if (msg.sender === 'Alice') {
|
|
123
|
+
if (!keysRef.current.bob) return;
|
|
124
|
+
const decrypted = await WebCrypto.decrypt(keysRef.current.bob, raw);
|
|
125
|
+
setBobMessages(prev => [...prev, { me: false, text: decrypted }]);
|
|
126
|
+
} else {
|
|
127
|
+
if (!keysRef.current.alice) return;
|
|
128
|
+
const decrypted = await WebCrypto.decrypt(keysRef.current.alice, raw);
|
|
129
|
+
setAliceMessages(prev => [...prev, { me: false, text: decrypted }]);
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(err);
|
|
133
|
+
}
|
|
134
|
+
}, [logEvent]);
|
|
135
|
+
|
|
136
|
+
const { broadcast } = useChannel('e2e-chat', handleMessage);
|
|
137
|
+
|
|
138
|
+
const handleSend = async (sender: 'Alice' | 'Bob', text: string) => {
|
|
139
|
+
if (!text.trim()) return;
|
|
140
|
+
const key = sender === 'Alice' ? keysRef.current.alice : keysRef.current.bob;
|
|
141
|
+
if (!key) return;
|
|
142
|
+
|
|
143
|
+
if (sender === 'Alice') {
|
|
144
|
+
setAliceMessages(prev => [...prev, { me: true, text }]);
|
|
145
|
+
setAliceDraft('');
|
|
146
|
+
} else {
|
|
147
|
+
setBobMessages(prev => [...prev, { me: true, text }]);
|
|
148
|
+
setBobDraft('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const encrypted = await WebCrypto.encrypt(key, text);
|
|
152
|
+
const b64 = btoa(String.fromCharCode(...encrypted));
|
|
153
|
+
|
|
154
|
+
logEvent(`SEND: Opaque Ciphertext (${encrypted.length} bytes)`);
|
|
155
|
+
broadcast({ type: 'e2e', sender, ciphertext: b64 });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className="space-y-6 max-w-5xl mx-auto">
|
|
160
|
+
<div className="glass-panel px-6 py-4 flex items-center justify-between text-sm">
|
|
161
|
+
<div className="flex gap-2 items-center">
|
|
162
|
+
<span className="text-brandEmerald">●</span>
|
|
163
|
+
<span className="text-textMuted font-mono">ECDH Key Exchange:</span>
|
|
164
|
+
<span className="text-textMain font-mono font-bold tracking-wide">{status}</span>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="text-textDim text-xs border border-pulseBorder px-3 py-1 rounded-full">
|
|
167
|
+
P-256 + AES-GCM
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className="grid grid-cols-2 gap-8">
|
|
172
|
+
{/* Alice Panel */}
|
|
173
|
+
<div className="glass-panel overflow-hidden border-brandPurple/30">
|
|
174
|
+
<div className="p-4 border-b border-pulseBorder flex items-center gap-3 bg-black/20">
|
|
175
|
+
<div className="w-10 h-10 rounded-full bg-[linear-gradient(135deg,var(--tw-colors-brandPurple),#5b21b6)] flex items-center justify-center font-bold text-white shadow-lg shadow-brandPurple/20">A</div>
|
|
176
|
+
<div>
|
|
177
|
+
<div className="font-semibold text-textMain">Alice</div>
|
|
178
|
+
<div className="text-[10px] text-textDim font-mono">{aliceKeyStr}</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="h-[300px] bg-black/10 p-4 overflow-y-auto flex flex-col gap-3">
|
|
182
|
+
{aliceMessages.map((m, i) => (
|
|
183
|
+
<div key={i} className={`px-4 py-2 text-sm rounded-2xl max-w-[80%] ${m.me ? 'bg-brandPurple text-white self-end rounded-br-sm' : 'bg-surface border border-pulseBorder text-textMain self-start rounded-bl-sm'}`}>
|
|
184
|
+
{m.text}
|
|
185
|
+
</div>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
<div className="p-4 bg-black/30 border-t border-pulseBorder flex gap-2">
|
|
189
|
+
<input
|
|
190
|
+
className="flex-1 bg-surface border border-pulseBorder rounded-full px-4 py-2 text-sm outline-none focus:border-brandPurple transition-colors text-textMain placeholder:text-textDim mix-blend-screen"
|
|
191
|
+
placeholder="Type as Alice..."
|
|
192
|
+
value={aliceDraft}
|
|
193
|
+
onChange={e => setAliceDraft(e.target.value)}
|
|
194
|
+
onKeyDown={e => e.key === 'Enter' && handleSend('Alice', aliceDraft)}
|
|
195
|
+
/>
|
|
196
|
+
<button onClick={() => handleSend('Alice', aliceDraft)} className="bg-brandPurple hover:bg-brandPurple/90 text-white px-5 rounded-full text-sm font-semibold transition-all shadow-lg shadow-brandPurple/20 hover:scale-105 active:scale-95">Send</button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Bob Panel */}
|
|
201
|
+
<div className="glass-panel overflow-hidden border-brandCyan/30">
|
|
202
|
+
<div className="p-4 border-b border-pulseBorder flex items-center gap-3 bg-black/20">
|
|
203
|
+
<div className="w-10 h-10 rounded-full bg-[linear-gradient(135deg,var(--tw-colors-brandCyan),#0e7490)] flex items-center justify-center font-bold text-white shadow-lg shadow-brandCyan/20">B</div>
|
|
204
|
+
<div>
|
|
205
|
+
<div className="font-semibold text-textMain">Bob</div>
|
|
206
|
+
<div className="text-[10px] text-textDim font-mono">{bobKeyStr}</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div className="h-[300px] bg-black/10 p-4 overflow-y-auto flex flex-col gap-3">
|
|
210
|
+
{bobMessages.map((m, i) => (
|
|
211
|
+
<div key={i} className={`px-4 py-2 text-sm rounded-2xl max-w-[80%] ${m.me ? 'bg-brandCyan text-[#0a0a14] font-medium self-end rounded-br-sm' : 'bg-surface border border-pulseBorder text-textMain self-start rounded-bl-sm'}`}>
|
|
212
|
+
{m.text}
|
|
213
|
+
</div>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
<div className="p-4 bg-black/30 border-t border-pulseBorder flex gap-2">
|
|
217
|
+
<input
|
|
218
|
+
className="flex-1 bg-surface border border-pulseBorder rounded-full px-4 py-2 text-sm outline-none focus:border-brandCyan transition-colors text-textMain placeholder:text-textDim mix-blend-screen"
|
|
219
|
+
placeholder="Type as Bob..."
|
|
220
|
+
value={bobDraft}
|
|
221
|
+
onChange={e => setBobDraft(e.target.value)}
|
|
222
|
+
onKeyDown={e => e.key === 'Enter' && handleSend('Bob', bobDraft)}
|
|
223
|
+
/>
|
|
224
|
+
<button onClick={() => handleSend('Bob', bobDraft)} className="bg-brandCyan hover:bg-brandCyan/90 text-[#0a0a14] px-5 rounded-full text-sm font-bold transition-all shadow-lg shadow-brandCyan/20 hover:scale-105 active:scale-95">Send</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface GameSyncProps {
|
|
4
|
+
useChannel: any; // from usePulse
|
|
5
|
+
logEvent: (action: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function GameSync({ useChannel, logEvent }: GameSyncProps) {
|
|
9
|
+
const localCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
10
|
+
const remoteCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
11
|
+
|
|
12
|
+
const [metrics, setMetrics] = useState({ ups: 0, bytes: 0 });
|
|
13
|
+
|
|
14
|
+
const localPlayerRef = useRef({ x: 0.3, y: 0.5, color: '#7C3AED', label: 'P1' });
|
|
15
|
+
const remotePlayerRef = useRef({ x: 0.7, y: 0.5, color: '#10B981', label: 'P2' });
|
|
16
|
+
const remoteViewRef = useRef({
|
|
17
|
+
p1: { x: 0.3, y: 0.5, color: '#7C3AED', label: 'P1' },
|
|
18
|
+
p2: { x: 0.7, y: 0.5, color: '#10B981', label: 'P2' }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const scoreRef = useRef({ p1: 0, p2: 0 });
|
|
22
|
+
const keysRef = useRef<{ [key: string]: boolean }>({});
|
|
23
|
+
|
|
24
|
+
// AI state
|
|
25
|
+
const aiRef = useRef({ angle: 0, speed: 0.003 });
|
|
26
|
+
|
|
27
|
+
// Collectibles
|
|
28
|
+
const collectiblesRef = useRef(
|
|
29
|
+
Array.from({ length: 5 }).map((_, i) => ({
|
|
30
|
+
x: 0.1 + Math.random() * 0.8,
|
|
31
|
+
y: 0.1 + Math.random() * 0.8,
|
|
32
|
+
color: '#F59E0B',
|
|
33
|
+
collected: false,
|
|
34
|
+
id: i,
|
|
35
|
+
}))
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const statsRef = useRef({ updatesThisSecond: 0, bytesThisSecond: 0, frameCount: 0 });
|
|
39
|
+
|
|
40
|
+
// Input listeners
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const handleKeyDown = (e: KeyboardEvent) => { keysRef.current[e.key.toLowerCase()] = true; };
|
|
43
|
+
const handleKeyUp = (e: KeyboardEvent) => { keysRef.current[e.key.toLowerCase()] = false; };
|
|
44
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
45
|
+
window.addEventListener('keyup', handleKeyUp);
|
|
46
|
+
return () => {
|
|
47
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
48
|
+
window.removeEventListener('keyup', handleKeyUp);
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// Sync handler (incoming data)
|
|
53
|
+
const handleSyncMessage = useCallback((msg: any) => {
|
|
54
|
+
if (msg.seq) {
|
|
55
|
+
const state = msg;
|
|
56
|
+
// Interpolate towards the actual synced state
|
|
57
|
+
remoteViewRef.current.p1.x += (state.p1.x - remoteViewRef.current.p1.x) * 0.7;
|
|
58
|
+
remoteViewRef.current.p1.y += (state.p1.y - remoteViewRef.current.p1.y) * 0.7;
|
|
59
|
+
remoteViewRef.current.p2.x += (state.p2.x - remoteViewRef.current.p2.x) * 0.7;
|
|
60
|
+
remoteViewRef.current.p2.y += (state.p2.y - remoteViewRef.current.p2.y) * 0.7;
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const { broadcast } = useChannel('game-sync', handleSyncMessage);
|
|
65
|
+
|
|
66
|
+
// Drawing helpers
|
|
67
|
+
const drawPlayer = (ctx: CanvasRenderingContext2D, x: number, y: number, color: string, label: string) => {
|
|
68
|
+
ctx.beginPath();
|
|
69
|
+
ctx.arc(x, y, 24, 0, Math.PI * 2);
|
|
70
|
+
ctx.fillStyle = color + "30";
|
|
71
|
+
ctx.fill();
|
|
72
|
+
|
|
73
|
+
ctx.beginPath();
|
|
74
|
+
ctx.arc(x, y, 18, 0, Math.PI * 2);
|
|
75
|
+
ctx.fillStyle = color + "50";
|
|
76
|
+
ctx.strokeStyle = color;
|
|
77
|
+
ctx.lineWidth = 2;
|
|
78
|
+
ctx.fill();
|
|
79
|
+
ctx.stroke();
|
|
80
|
+
|
|
81
|
+
ctx.shadowColor = color;
|
|
82
|
+
ctx.shadowBlur = 15;
|
|
83
|
+
ctx.stroke();
|
|
84
|
+
ctx.shadowBlur = 0;
|
|
85
|
+
|
|
86
|
+
ctx.fillStyle = "white";
|
|
87
|
+
ctx.font = "bold 11px 'Inter', sans-serif";
|
|
88
|
+
ctx.textAlign = "center";
|
|
89
|
+
ctx.textBaseline = "middle";
|
|
90
|
+
ctx.fillText(label, x, y);
|
|
91
|
+
ctx.textAlign = "start";
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const drawArena = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, p1: any, p2: any, isRemote: boolean) => {
|
|
95
|
+
const w = canvas.width;
|
|
96
|
+
const h = canvas.height;
|
|
97
|
+
|
|
98
|
+
ctx.fillStyle = "#0a0a14";
|
|
99
|
+
ctx.fillRect(0, 0, w, h);
|
|
100
|
+
|
|
101
|
+
ctx.strokeStyle = "rgba(255,255,255,0.04)";
|
|
102
|
+
ctx.lineWidth = 1;
|
|
103
|
+
for (let x = 0; x < w; x += 40) {
|
|
104
|
+
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
|
105
|
+
}
|
|
106
|
+
for (let y = 0; y < h; y += 40) {
|
|
107
|
+
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
collectiblesRef.current.forEach((c) => {
|
|
111
|
+
if (c.collected) return;
|
|
112
|
+
ctx.beginPath();
|
|
113
|
+
ctx.arc(c.x * w, c.y * h, 8, 0, Math.PI * 2);
|
|
114
|
+
ctx.fillStyle = c.color;
|
|
115
|
+
ctx.fill();
|
|
116
|
+
ctx.shadowColor = c.color;
|
|
117
|
+
ctx.shadowBlur = 12;
|
|
118
|
+
ctx.fill();
|
|
119
|
+
ctx.shadowBlur = 0;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
drawPlayer(ctx, p1.x * w, p1.y * h, p1.color, p1.label);
|
|
123
|
+
drawPlayer(ctx, p2.x * w, p2.y * h, p2.color, p2.label);
|
|
124
|
+
|
|
125
|
+
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
|
126
|
+
ctx.font = "12px monospace";
|
|
127
|
+
ctx.fillText(`P1: ${scoreRef.current.p1} P2: ${scoreRef.current.p2}`, 10, 20);
|
|
128
|
+
if (isRemote) {
|
|
129
|
+
ctx.fillStyle = "rgba(6,182,212,0.5)";
|
|
130
|
+
ctx.fillText("🔗 Synced via Pulse", w - 150, 20);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Game loop
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
let animationId: number;
|
|
137
|
+
const SPEED = 0.006;
|
|
138
|
+
|
|
139
|
+
const tick = () => {
|
|
140
|
+
if (!localCanvasRef.current || !remoteCanvasRef.current) return;
|
|
141
|
+
const localCtx = localCanvasRef.current.getContext('2d');
|
|
142
|
+
const remoteCtx = remoteCanvasRef.current.getContext('2d');
|
|
143
|
+
if (!localCtx || !remoteCtx) return;
|
|
144
|
+
|
|
145
|
+
const keys = keysRef.current;
|
|
146
|
+
const localP = localPlayerRef.current;
|
|
147
|
+
const remoteP = remotePlayerRef.current;
|
|
148
|
+
|
|
149
|
+
if (keys["w"] || keys["arrowup"]) localP.y = Math.max(0.05, localP.y - SPEED);
|
|
150
|
+
if (keys["s"] || keys["arrowdown"]) localP.y = Math.min(0.95, localP.y + SPEED);
|
|
151
|
+
if (keys["a"] || keys["arrowleft"]) localP.x = Math.max(0.05, localP.x - SPEED);
|
|
152
|
+
if (keys["d"] || keys["arrowright"]) localP.x = Math.min(0.95, localP.x + SPEED);
|
|
153
|
+
|
|
154
|
+
aiRef.current.angle += aiRef.current.speed;
|
|
155
|
+
remoteP.x = 0.5 + Math.cos(aiRef.current.angle) * 0.3;
|
|
156
|
+
remoteP.y = 0.5 + Math.sin(aiRef.current.angle * 1.3) * 0.25;
|
|
157
|
+
|
|
158
|
+
collectiblesRef.current.forEach((c) => {
|
|
159
|
+
if (c.collected) return;
|
|
160
|
+
const d1 = Math.hypot(localP.x - c.x, localP.y - c.y);
|
|
161
|
+
const d2 = Math.hypot(remoteP.x - c.x, remoteP.y - c.y);
|
|
162
|
+
if (d1 < 0.04) { c.collected = true; scoreRef.current.p1++; }
|
|
163
|
+
if (d2 < 0.04) { c.collected = true; scoreRef.current.p2++; }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (collectiblesRef.current.every((c) => c.collected)) {
|
|
167
|
+
collectiblesRef.current.forEach((c) => {
|
|
168
|
+
c.x = 0.1 + Math.random() * 0.8;
|
|
169
|
+
c.y = 0.1 + Math.random() * 0.8;
|
|
170
|
+
c.collected = false;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
drawArena(localCtx, localCanvasRef.current, localP, remoteP, false);
|
|
175
|
+
|
|
176
|
+
// Sync state at ~60fps
|
|
177
|
+
statsRef.current.frameCount++;
|
|
178
|
+
statsRef.current.updatesThisSecond++;
|
|
179
|
+
const payload = {
|
|
180
|
+
p1: { x: localP.x, y: localP.y },
|
|
181
|
+
p2: { x: remoteP.x, y: remoteP.y },
|
|
182
|
+
score: scoreRef.current,
|
|
183
|
+
seq: statsRef.current.frameCount,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const payloadStr = JSON.stringify(payload);
|
|
187
|
+
statsRef.current.bytesThisSecond += payloadStr.length;
|
|
188
|
+
|
|
189
|
+
// Send to peers
|
|
190
|
+
broadcast(payload);
|
|
191
|
+
|
|
192
|
+
// Interpolate fallback for when broadcast doesn't echo locally fast enough
|
|
193
|
+
const rv = remoteViewRef.current;
|
|
194
|
+
rv.p1.x += (localP.x - rv.p1.x) * 0.1;
|
|
195
|
+
rv.p1.y += (localP.y - rv.p1.y) * 0.1;
|
|
196
|
+
rv.p2.x += (remoteP.x - rv.p2.x) * 0.1;
|
|
197
|
+
rv.p2.y += (remoteP.y - rv.p2.y) * 0.1;
|
|
198
|
+
|
|
199
|
+
drawArena(remoteCtx, remoteCanvasRef.current, rv.p1, rv.p2, true);
|
|
200
|
+
|
|
201
|
+
animationId = requestAnimationFrame(tick);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
animationId = requestAnimationFrame(tick);
|
|
205
|
+
|
|
206
|
+
// Stats ticker
|
|
207
|
+
const statsInterval = setInterval(() => {
|
|
208
|
+
setMetrics({
|
|
209
|
+
ups: statsRef.current.updatesThisSecond,
|
|
210
|
+
bytes: statsRef.current.bytesThisSecond
|
|
211
|
+
});
|
|
212
|
+
if (statsRef.current.updatesThisSecond > 0) {
|
|
213
|
+
logEvent(`STATE_UPDATE: seq=${statsRef.current.frameCount} (${Math.floor(statsRef.current.bytesThisSecond / 60)}B/frame)`);
|
|
214
|
+
}
|
|
215
|
+
statsRef.current.updatesThisSecond = 0;
|
|
216
|
+
statsRef.current.bytesThisSecond = 0;
|
|
217
|
+
}, 1000);
|
|
218
|
+
|
|
219
|
+
return () => {
|
|
220
|
+
cancelAnimationFrame(animationId);
|
|
221
|
+
clearInterval(statsInterval);
|
|
222
|
+
};
|
|
223
|
+
}, [broadcast, logEvent]);
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div className="space-y-6">
|
|
227
|
+
<div className="grid grid-cols-2 gap-6 w-full max-w-4xl mx-auto">
|
|
228
|
+
<div>
|
|
229
|
+
<div className="text-xs text-textMuted mb-2 uppercase tracking-wide font-semibold">Your View (WASD to move)</div>
|
|
230
|
+
<canvas
|
|
231
|
+
ref={localCanvasRef}
|
|
232
|
+
width={600} height={400}
|
|
233
|
+
className="w-full bg-black/40 border border-pulseBorder rounded-2xl shadow-lg ring-1 ring-white/5 object-contain"
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
<div>
|
|
237
|
+
<div className="text-xs text-textMuted mb-2 uppercase tracking-wide font-semibold">Remote Peer View (Synced via Pulse)</div>
|
|
238
|
+
<canvas
|
|
239
|
+
ref={remoteCanvasRef}
|
|
240
|
+
width={600} height={400}
|
|
241
|
+
className="w-full bg-black/40 border border-brandCyan/30 rounded-2xl shadow-[0_0_20px_rgba(6,182,212,0.1)] ring-1 ring-brandCyan/20 object-contain"
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div className="grid grid-cols-4 gap-4 max-w-4xl mx-auto">
|
|
247
|
+
<div className="glass-panel p-4 text-center">
|
|
248
|
+
<div className="text-2xl font-bold font-mono text-brandCyan">{metrics.ups}</div>
|
|
249
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Updates/sec</div>
|
|
250
|
+
</div>
|
|
251
|
+
<div className="glass-panel p-4 text-center">
|
|
252
|
+
<div className="text-2xl font-bold font-mono text-brandPurple">60Hz</div>
|
|
253
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Target Sync</div>
|
|
254
|
+
</div>
|
|
255
|
+
<div className="glass-panel p-4 text-center">
|
|
256
|
+
<div className="text-2xl font-bold font-mono text-brandEmerald">{metrics.bytes}</div>
|
|
257
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Bytes/sec</div>
|
|
258
|
+
</div>
|
|
259
|
+
<div className="glass-panel p-4 text-center">
|
|
260
|
+
<div className="text-2xl font-bold font-mono text-brandAmber">ON</div>
|
|
261
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mt-1">Interpolation</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ServerMetricsProps {
|
|
4
|
+
useChannel: any; // from usePulse
|
|
5
|
+
logEvent: (action: string) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ServerMetrics({ useChannel, logEvent }: ServerMetricsProps) {
|
|
9
|
+
const [stats, setStats] = useState({ cpu: 0, mem: 0, net: 0, disk: 0 });
|
|
10
|
+
const cpuCanvas = useRef<HTMLCanvasElement>(null);
|
|
11
|
+
const memCanvas = useRef<HTMLCanvasElement>(null);
|
|
12
|
+
|
|
13
|
+
const historyRef = useRef({
|
|
14
|
+
cpu: Array(60).fill(0),
|
|
15
|
+
mem: Array(60).fill(0),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const drawChart = (canvas: HTMLCanvasElement, data: number[], color: string) => {
|
|
19
|
+
const ctx = canvas.getContext("2d");
|
|
20
|
+
if (!ctx) return;
|
|
21
|
+
const w = (canvas.width = canvas.offsetWidth * (window.devicePixelRatio || 1));
|
|
22
|
+
const h = (canvas.height = canvas.offsetHeight * (window.devicePixelRatio || 1));
|
|
23
|
+
|
|
24
|
+
ctx.clearRect(0, 0, w, h);
|
|
25
|
+
|
|
26
|
+
ctx.strokeStyle = "rgba(255,255,255,0.06)";
|
|
27
|
+
ctx.lineWidth = 1;
|
|
28
|
+
for (let i = 0; i <= 4; i++) {
|
|
29
|
+
const y = (h * i) / 4;
|
|
30
|
+
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ctx.strokeStyle = color;
|
|
34
|
+
ctx.lineWidth = 2 * (window.devicePixelRatio || 1);
|
|
35
|
+
ctx.lineJoin = "round";
|
|
36
|
+
ctx.beginPath();
|
|
37
|
+
for (let i = 0; i < data.length; i++) {
|
|
38
|
+
const x = (i / 59) * w;
|
|
39
|
+
const y = h - (Math.min(data[i], 100) / 100) * h;
|
|
40
|
+
if (i === 0) ctx.moveTo(x, y);
|
|
41
|
+
else ctx.lineTo(x, y);
|
|
42
|
+
}
|
|
43
|
+
ctx.stroke();
|
|
44
|
+
|
|
45
|
+
ctx.lineTo(w, h);
|
|
46
|
+
ctx.lineTo(0, h);
|
|
47
|
+
ctx.closePath();
|
|
48
|
+
// Fallback transparent fill
|
|
49
|
+
ctx.fillStyle = color.replace('rgb', 'rgba').replace(')', ', 0.15)');
|
|
50
|
+
ctx.fill();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleMessage = useCallback((metrics: any) => {
|
|
54
|
+
if (metrics.type === "server_metrics") {
|
|
55
|
+
setStats({ cpu: metrics.cpu, mem: metrics.mem, net: metrics.net, disk: metrics.disk });
|
|
56
|
+
|
|
57
|
+
const cpuHist = historyRef.current.cpu;
|
|
58
|
+
const memHist = historyRef.current.mem;
|
|
59
|
+
|
|
60
|
+
cpuHist.push(metrics.cpu);
|
|
61
|
+
memHist.push(metrics.mem);
|
|
62
|
+
if (cpuHist.length > 60) cpuHist.shift();
|
|
63
|
+
if (memHist.length > 60) memHist.shift();
|
|
64
|
+
|
|
65
|
+
if (cpuCanvas.current) drawChart(cpuCanvas.current, cpuHist, '#06B6D4'); // Cyan
|
|
66
|
+
if (memCanvas.current) drawChart(memCanvas.current, memHist, '#7C3AED'); // Purple
|
|
67
|
+
|
|
68
|
+
logEvent(`RECV [Metrics]: CPU=${metrics.cpu.toFixed(1)}% MEM=${metrics.mem.toFixed(1)}%`);
|
|
69
|
+
}
|
|
70
|
+
}, [logEvent]);
|
|
71
|
+
|
|
72
|
+
const { broadcast } = useChannel("server-metrics", handleMessage);
|
|
73
|
+
|
|
74
|
+
// If no backend is pushing to us, we will simulate the backend pushing metrics.
|
|
75
|
+
// In a real app the Rust `server-rust` does this.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const interval = setInterval(() => {
|
|
78
|
+
// Check if we haven't received real data in the last tick
|
|
79
|
+
// For the sake of the demo, if we want this React app to just work standalone:
|
|
80
|
+
// We simulate that the server is broadcasting.
|
|
81
|
+
const fakeMetrics = {
|
|
82
|
+
type: "server_metrics",
|
|
83
|
+
cpu: 10 + Math.random() * 20 + Math.sin(Date.now() / 1000) * 10,
|
|
84
|
+
mem: 40 + Math.random() * 5 + Math.cos(Date.now() / 2000) * 10,
|
|
85
|
+
net: Math.random() * 100,
|
|
86
|
+
disk: Math.random() * 5
|
|
87
|
+
};
|
|
88
|
+
// Normally the server does this, here we do it so the demo "just works" locally
|
|
89
|
+
broadcast(fakeMetrics);
|
|
90
|
+
// We handle it directly in handleMessage too (since broadcast goes up to the relay and back)
|
|
91
|
+
}, 1000);
|
|
92
|
+
|
|
93
|
+
return () => clearInterval(interval);
|
|
94
|
+
}, [broadcast]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="space-y-6 max-w-5xl mx-auto">
|
|
98
|
+
<div className="grid grid-cols-4 gap-4">
|
|
99
|
+
<div className="glass-panel p-6 text-center">
|
|
100
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">CPU Usage</div>
|
|
101
|
+
<div className="text-3xl font-bold text-brandCyan font-mono">{stats.cpu.toFixed(1)}%</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="glass-panel p-6 text-center">
|
|
104
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Memory</div>
|
|
105
|
+
<div className="text-3xl font-bold text-brandPurple font-mono">{stats.mem.toFixed(1)}%</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="glass-panel p-6 text-center">
|
|
108
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Network</div>
|
|
109
|
+
<div className="text-3xl font-bold text-brandEmerald font-mono">{stats.net.toFixed(0)} Mbps</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div className="glass-panel p-6 text-center">
|
|
112
|
+
<div className="text-[10px] text-textDim uppercase tracking-widest mb-2 font-semibold">Disk I/O</div>
|
|
113
|
+
<div className="text-3xl font-bold text-brandAmber font-mono">{stats.disk.toFixed(1)}%</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="grid grid-cols-2 gap-6">
|
|
118
|
+
<div className="glass-panel p-4">
|
|
119
|
+
<div className="text-xs uppercase tracking-widest text-textDim font-bold mb-4 border-b border-pulseBorder pb-2">CPU % Over Time</div>
|
|
120
|
+
<canvas ref={cpuCanvas} className="w-full h-48 border border-white/5 rounded-xl bg-black/40"></canvas>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="glass-panel p-4">
|
|
123
|
+
<div className="text-xs uppercase tracking-widest text-textDim font-bold mb-4 border-b border-pulseBorder pb-2">Memory % Over Time</div>
|
|
124
|
+
<canvas ref={memCanvas} className="w-full h-48 border border-white/5 rounded-xl bg-black/40"></canvas>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
<div className="text-center text-xs text-textMuted bg-brandAmber/10 border border-brandAmber/20 p-3 rounded-lg flex items-center justify-center gap-2 max-w-lg mx-auto">
|
|
128
|
+
<span className="w-2 h-2 rounded-full bg-brandAmber shadow-[0_0_8px_var(--tw-colors-brandAmber)] animate-pulse"></span>
|
|
129
|
+
In standalone mode, data is simulated and echoed through the Pulse Relay.
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|