Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6fbcf871 | ||
|
|
5a81ff7716 | ||
|
|
470e95b0f9 | ||
|
|
9ce6550f13 | ||
|
|
ef9baf6063 | ||
|
|
43274e7371 | ||
|
|
e7d7f2a270 | ||
|
|
1c34849bd0 | ||
|
|
edc1b825f2 | ||
|
|
c56a8b1000 | ||
|
|
91e0cc3886 | ||
|
|
881583ee0a | ||
|
|
8ff3c39765 | ||
|
|
9b5c187aac | ||
|
|
0266bd9568 | ||
|
|
6edb6f6dd0 | ||
|
|
ec8eb1c002 | ||
|
|
3c385126ae | ||
|
|
4635e1b40c | ||
|
|
3d2e75572f | ||
|
|
81994f8a56 | ||
|
|
bfdb6555d8 | ||
|
|
17b1404157 | ||
|
|
b199f45eb2 | ||
|
|
0f08fb3f0d | ||
|
|
0f5a33c1de | ||
|
|
8e96c3cdae | ||
|
|
ba5c1c5d25 | ||
|
|
6a914faffb | ||
|
|
3b99590b3b | ||
|
|
5acbf6e0bf | ||
|
|
3ec45de4b4 | ||
|
|
3b7b2ac21e | ||
|
|
02a32e2524 | ||
|
|
eb1fab50e4 | ||
|
|
f85e258c29 | ||
|
|
72b8db8a31 | ||
|
|
fb31ff52bd | ||
|
|
99d2a7689f | ||
|
|
124369dfd7 | ||
|
|
4e24b5437d | ||
|
|
a3c13f3dd3 | ||
|
|
b0389b5ddc | ||
|
|
9ca039229a | ||
|
|
8895ed6bd8 | ||
|
|
6ca7cc0cf0 | ||
|
|
d3fd3946e6 | ||
|
|
038ae0f45a | ||
|
|
3859806fca | ||
|
|
f654394636 | ||
|
|
c8836f7d65 | ||
|
|
f77a969098 | ||
|
|
5ab62c3633 | ||
|
|
fab0673d9e | ||
|
|
a5ec1dcb2d | ||
|
|
6ff60f7d0e | ||
|
|
ea16e2c67a | ||
|
|
9615423e06 | ||
|
|
93232e2a54 | ||
|
|
271197f33c | ||
|
|
8e7d31dcdd | ||
|
|
4f87a0eec2 | ||
|
|
cc2e85d174 | ||
|
|
737760ff56 | ||
|
|
0f0f82e5f6 | ||
|
|
3807b8bb13 | ||
|
|
9f2f819bbd | ||
|
|
60329e1036 | ||
|
|
4430517af5 | ||
|
|
b24d453ad1 | ||
|
|
b5e8d775a2 | ||
|
|
e5830533ba | ||
|
|
dfd674a447 | ||
|
|
bd65f93756 | ||
|
|
0083c887f4 | ||
|
|
70f71b2d1e | ||
|
|
c8742dbbd6 | ||
|
|
6a6dec5540 | ||
|
|
6c69c583c5 | ||
|
|
9702cf0f82 | ||
|
|
a55257dd51 | ||
|
|
8d1761afe2 | ||
|
|
c7c52eb39c | ||
|
|
28fcbad902 | ||
|
|
208b89fd7f | ||
|
|
15a60a58e6 | ||
|
|
3ebd1c7f9c | ||
|
|
7fdcb53041 | ||
|
|
a2dde4fd3d | ||
|
|
8c5d98bed6 | ||
|
|
1aecab66e7 | ||
|
|
ef18ef89e0 | ||
|
|
46bbe5c531 | ||
|
|
dc8267073b | ||
|
|
eb0db3508f | ||
|
|
6aee49bfdb | ||
|
|
a9428d3d72 | ||
|
|
30e961d727 | ||
|
|
a2fbbc100e | ||
|
|
42a7cefeba | ||
|
|
7272c8b4fa | ||
|
|
0e1ad8c6a0 | ||
|
|
09c1257578 | ||
|
|
f3b349bb4a | ||
|
|
81f52c29d8 | ||
|
|
d000b8f4f8 | ||
|
|
0f9bed9c99 | ||
|
|
8642fbac3c | ||
|
|
3a26b4871a | ||
|
|
b26daed93f | ||
|
|
fb9565faf9 | ||
|
|
358aa0b1d6 | ||
|
|
0ecee03f64 | ||
|
|
90d4f778a5 | ||
|
|
b261a4bfa2 | ||
|
|
dd1fd1e167 | ||
|
|
eba4b1dc1d | ||
|
|
cacd125fcb | ||
|
|
8a4162683f | ||
|
|
387a5f47d7 | ||
|
|
5b6947d92c | ||
|
|
cd8f8816c9 | ||
|
|
b86eec88f8 | ||
|
|
512adfc1e0 | ||
|
|
fe33f27bd0 | ||
|
|
afe8ffc62e | ||
|
|
4e6e9e3021 | ||
|
|
f9461433af | ||
|
|
b7d421c489 | ||
|
|
c6b2843150 | ||
|
|
b451849c02 | ||
|
|
955f6f213d | ||
|
|
c9842d7ba5 | ||
|
|
d7fb15d891 | ||
|
|
1cd0e05b4a | ||
|
|
7927768731 | ||
|
|
d9cb0c76b6 | ||
|
|
351cb24458 | ||
|
|
679563862d | ||
|
|
8655cde0f1 | ||
|
|
12090f23be | ||
|
|
557b4b81a2 | ||
|
|
0add90714b | ||
|
|
fe64ec7758 | ||
|
|
10f71edaf1 | ||
|
|
e97d4056fa | ||
|
|
c228bb107f | ||
|
|
06aeca660b | ||
|
|
4bd228547a | ||
|
|
35561495b4 | ||
|
|
8a9205b5b7 | ||
|
|
a6c569ada4 | ||
|
|
0d352d326d | ||
|
|
7bc08af160 | ||
|
|
9177e53fbf | ||
|
|
5be5ad3b17 | ||
|
|
99bc00e96b | ||
|
|
002b174259 | ||
|
|
e28ccc5e66 | ||
|
|
efae430d65 | ||
|
|
0c9df78070 | ||
|
|
9d8e22ad1e | ||
|
|
385e7c25fa | ||
|
|
3a8a2ebb13 | ||
|
|
96d06091ee | ||
|
|
15e03e62ed | ||
|
|
3aa97baa4f | ||
|
|
017b3c69d5 | ||
|
|
ad7de34d6d | ||
|
|
51fbee400c | ||
|
|
7fe5730953 | ||
|
|
0d6b4d68de | ||
|
|
1c401ff90a | ||
|
|
f6b9fa798e | ||
|
|
f667bbce53 | ||
|
|
f480e01bf7 | ||
|
|
e2e94da656 | ||
|
|
8ee680ce9f | ||
|
|
be37a39291 | ||
|
|
af484f5099 | ||
|
|
5d8cb48fd0 | ||
|
|
c742fa1c81 | ||
|
|
6dc067580d | ||
|
|
d9e6317220 | ||
|
|
f969e50772 | ||
|
|
7d3f702e32 | ||
|
|
5f3206a065 | ||
|
|
411313b21b | ||
|
|
fa92726e9c | ||
|
|
30b8867b3a | ||
|
|
e254eea9e4 | ||
|
|
60eacf982c | ||
|
|
23108241f1 | ||
|
|
cc525afe20 | ||
|
|
a5884d9eac | ||
|
|
0fc4973cb7 | ||
|
|
7dfdf62c94 | ||
|
|
7286c5b36c | ||
|
|
4360e9ca03 | ||
|
|
dd0ba60d49 | ||
|
|
b5cb9bc8e4 | ||
|
|
fd1b3e103d | ||
|
|
9732f6c080 | ||
|
|
2064c4196c | ||
|
|
2cef95e6e3 | ||
|
|
76318e6b36 | ||
|
|
09ce66e7a4 | ||
|
|
fd1caf2df9 | ||
|
|
58410cd66b | ||
|
|
6d4000bed7 | ||
|
|
6e63f0e19c | ||
|
|
ed24d9ea89 | ||
|
|
b3141e9870 | ||
|
|
64d6d5a601 | ||
|
|
db44d23947 | ||
|
|
924a3c3d55 | ||
|
|
4d66defae0 | ||
|
|
0bb3f89472 | ||
|
|
e4ada54cf4 | ||
|
|
b393c2b2b4 | ||
|
|
18649deeae | ||
|
|
6747658106 | ||
|
|
033a2bae43 | ||
|
|
27dbb068be | ||
|
|
71706fb1b9 | ||
|
|
65b54f01d2 | ||
|
|
026e9de476 | ||
|
|
91bbeb6d8a | ||
|
|
43e4527ad5 | ||
|
|
a07d532043 | ||
|
|
2bd6215be2 | ||
|
|
e02b7a2eb7 | ||
|
|
10a586e5d0 | ||
|
|
775dd7b830 | ||
|
|
f2d3567978 | ||
|
|
10121650de | ||
|
|
9b875aedcb | ||
|
|
6d685c26ba | ||
|
|
dd8e87fda7 | ||
|
|
bf3ce9a28a | ||
|
|
0c0b55fe88 | ||
|
|
c06ac6bc20 | ||
|
|
8ec9ecc8d4 | ||
|
|
02599a44e4 | ||
|
|
4b8aa67c32 | ||
|
|
0e5f76fb2f | ||
|
|
3af74870a5 | ||
|
|
aee1a9d563 | ||
|
|
b3001cf0a3 | ||
|
|
d777e3a48e | ||
|
|
ee393adc8e | ||
|
|
c406ace339 | ||
|
|
6a31d8bc35 | ||
|
|
a6f6ccf079 | ||
|
|
5d60544904 | ||
|
|
3a40a65d46 | ||
|
|
f15ace85f6 | ||
|
|
656bf90e7f | ||
|
|
2d75935e65 | ||
|
|
42c248e298 | ||
|
|
55e8235b02 | ||
|
|
77de628c0b | ||
|
|
f31b21559c | ||
|
|
f7514e71cc | ||
|
|
f4683cd47d | ||
|
|
2beb1498fa | ||
|
|
d349df2432 | ||
|
|
11340bf3f6 | ||
|
|
e6c4931210 | ||
|
|
8f0ac358d6 | ||
|
|
4204a66549 | ||
|
|
a8bff428c2 | ||
|
|
69b49ae1d6 | ||
|
|
07ad43f42f | ||
|
|
f9f87c00d3 | ||
|
|
6f6325f477 | ||
|
|
0b3c7869af | ||
|
|
b526beb712 | ||
|
|
df22ac6d81 | ||
|
|
6c06275c6d | ||
|
|
3aea6d4766 | ||
|
|
3e632ac607 | ||
|
|
d77ec54bc9 | ||
|
|
40e8adf15a | ||
|
|
29bf4cc5d2 | ||
|
|
b083938338 | ||
|
|
5cade7a941 | ||
|
|
8aaffacc2a | ||
|
|
00277667d6 | ||
|
|
83a6910c25 | ||
|
|
ac8be3c4b5 | ||
|
|
295d01f7cc | ||
|
|
6ad7701b8f | ||
|
|
282cb3e135 | ||
|
|
d8e63bdf2e | ||
|
|
fa4852b93c | ||
|
|
37b8f71944 | ||
|
|
31eacea7bc | ||
|
|
2eb6c00f9c | ||
|
|
d10a321759 | ||
|
|
0cc40b7608 | ||
|
|
79be3e1583 | ||
|
|
02df826eb4 | ||
|
|
368ccf363d | ||
|
|
58a10371b0 | ||
|
|
77f2b03741 | ||
|
|
f118f6456a | ||
|
|
8843bd6acf | ||
|
|
c5c3d5b8f5 | ||
|
|
8f2a4dbb8e | ||
|
|
78fc891cb9 | ||
|
|
5fca14bef5 | ||
|
|
622724224a | ||
|
|
4ee4f7d06e | ||
|
|
0cfc16528b | ||
|
|
8e322ed57b | ||
|
|
3c1b1dab6e | ||
|
|
0172ea5150 | ||
|
|
d2007976b0 | ||
|
|
b50e8bba5f | ||
|
|
274fe279d8 | ||
|
|
c2224d00e3 | ||
|
|
e664402528 | ||
|
|
5634004ebd | ||
|
|
0a351b9a46 | ||
|
|
988c387196 | ||
|
|
9c5468ed09 | ||
|
|
a769974626 | ||
|
|
49bd8acf7b | ||
|
|
9cf71e8df3 | ||
|
|
e95eeeb526 | ||
|
|
e6c16c3af9 | ||
|
|
9cf44b316d | ||
|
|
f0a7d72f94 | ||
|
|
bd857e6387 | ||
|
|
51b53cc2d0 | ||
|
|
8353d253ce | ||
|
|
da1d4bf0d5 | ||
|
|
2ead99fb70 | ||
|
|
36bf5826b9 | ||
|
|
eec009c485 | ||
|
|
a5e54bfbf2 | ||
|
|
36ff14824e | ||
|
|
3bad454e0d | ||
|
|
39bb53104e | ||
|
|
eb633c0659 | ||
|
|
302079eadf | ||
|
|
2f5f18fa6d | ||
|
|
56227d6500 | ||
|
|
db8dc04b8c | ||
|
|
c7f5cb68f4 | ||
|
|
ea8e027be9 | ||
|
|
c7e811a957 | ||
|
|
61cb92d72b | ||
|
|
8a556a3634 | ||
|
|
80f1f82d6e | ||
|
|
e3d66cc5f6 | ||
|
|
c598e25019 | ||
|
|
d3765b7d8f | ||
|
|
df16c84755 | ||
|
|
b7ff447fd4 | ||
|
|
3bb31522c3 | ||
|
|
7e30a856a8 | ||
|
|
0af9d560e4 | ||
|
|
0c16e5cd11 | ||
|
|
82eff49d83 | ||
|
|
678f968b7c | ||
|
|
5d178e745c | ||
|
|
7248af47d2 | ||
|
|
e7a2abeb9b | ||
|
|
5d2eda10c7 | ||
|
|
73928c60d6 | ||
|
|
a047e88646 | ||
|
|
95e9ebff0c | ||
|
|
8e0439392f | ||
|
|
594d081137 | ||
|
|
279b52724d | ||
|
|
19049cdd1c | ||
|
|
e73cdbe955 | ||
|
|
fd890f4c4f | ||
|
|
b8e9307858 | ||
|
|
9ae9352eca | ||
|
|
bb7d2a4e03 | ||
|
|
d8f617493a | ||
|
|
f130bba8a8 | ||
|
|
57c128ab4d | ||
|
|
6118033abc | ||
|
|
404e648d3a | ||
|
|
7666c3c41b | ||
|
|
beb43164a4 | ||
|
|
5fa0051bd3 | ||
|
|
ae28bac0ac | ||
|
|
7799eafb4c | ||
|
|
ae72e359bd | ||
|
|
3caf3fd362 | ||
|
|
e8fa7b39eb | ||
|
|
985e46524d | ||
|
|
2c2d3d7756 | ||
|
|
824e1b1441 | ||
|
|
0e2784859e | ||
|
|
a77b221c11 | ||
|
|
027f7cfc93 | ||
|
|
4a6fa9b701 | ||
|
|
29455c8913 | ||
|
|
344e9cdf47 | ||
|
|
65d0b6bbd9 | ||
|
|
62cbcddd58 | ||
|
|
5a3004add1 | ||
|
|
55787f2629 | ||
|
|
959541a2e4 | ||
|
|
948397a8e1 | ||
|
|
883678f942 | ||
|
|
c8e63bcae1 | ||
|
|
f11fc5dc60 | ||
|
|
84378f915e | ||
|
|
37701e3bba | ||
|
|
630234fab4 | ||
|
|
74810289c2 | ||
|
|
34692dc974 | ||
|
|
b9b3328105 | ||
|
|
619d334758 | ||
|
|
61ace6e7c4 | ||
|
|
bddff1e67f | ||
|
|
d28d37575c | ||
|
|
880ebfefd2 | ||
|
|
3fec97a638 | ||
|
|
764befa57d | ||
|
|
9775ee06f4 | ||
|
|
87dc70d11d | ||
|
|
033bafeead | ||
|
|
3efb31caae | ||
|
|
01a0b112a1 | ||
|
|
0a6228cb83 | ||
|
|
bcd54c28e1 | ||
|
|
cc06709674 | ||
|
|
dc193d09db | ||
|
|
c0050498b2 | ||
|
|
be8c3d7b29 | ||
|
|
ac1ffa1cd5 | ||
|
|
d4d27cd9d3 | ||
|
|
6d813179a4 | ||
|
|
3fa66c4537 | ||
|
|
6656e6271c | ||
|
|
6bec6aee1a | ||
|
|
4a213add69 | ||
|
|
ce133b2aa1 | ||
|
|
cb5b58666c | ||
|
|
4fd44d6ad6 | ||
|
|
426fd3e7c4 | ||
|
|
bcbd5d03c7 | ||
|
|
8d90a29345 | ||
|
|
5770dc8849 | ||
|
|
717a71487c | ||
|
|
9826d4245f | ||
|
|
2ce2f23b0a | ||
|
|
2d0e883c97 | ||
|
|
de34f1b0a2 | ||
|
|
a702db3fab | ||
|
|
5b6c47b917 | ||
|
|
4c9219ddd6 | ||
|
|
0932ea5db7 | ||
|
|
5f00d8c715 | ||
|
|
5dca976f97 | ||
|
|
9d36efb847 | ||
|
|
8c121c0925 | ||
|
|
d2565eaf40 | ||
|
|
58029fd3a1 | ||
|
|
7d41e0ae11 | ||
|
|
031e23103f | ||
|
|
45f7efa907 | ||
|
|
48119dffdf | ||
|
|
d659cbb522 | ||
|
|
503394f1f0 | ||
|
|
a994759eb5 | ||
|
|
87473fc424 | ||
|
|
dde4ac45c7 | ||
|
|
5fe7197c0b | ||
|
|
e4a818b490 | ||
|
|
b2bd3a5e41 | ||
|
|
3cff250ba0 | ||
|
|
a15f0697d5 | ||
|
|
124cdc0c64 | ||
|
|
097d23dc66 | ||
|
|
a7246120cb | ||
|
|
661a4e0f7c | ||
|
|
5ac7bd4370 | ||
|
|
8356d37364 | ||
|
|
adedc945f0 | ||
|
|
0e133e08a6 | ||
|
|
7b693e259f | ||
|
|
06f4531bbf | ||
|
|
4efc2be646 | ||
|
|
c636b41cbc | ||
|
|
b21acc7f19 | ||
|
|
889f660f3e | ||
|
|
4bca6273b4 | ||
|
|
46c9b8aa43 | ||
|
|
353481040b | ||
|
|
908776a365 | ||
|
|
229fe5411a | ||
|
|
c097d94ba8 | ||
|
|
870de4f135 | ||
|
|
41b59058bc | ||
|
|
0fe060cd22 | ||
|
|
094220303f | ||
|
|
bc4088bb2c | ||
|
|
47586591af | ||
|
|
9a8604ea82 | ||
|
|
e9f41e6d62 | ||
|
|
ef9c52b2bf | ||
|
|
8e9fbb3ecb | ||
|
|
d545a4abcf | ||
|
|
9f3cd5b8d0 | ||
|
|
d6a2c3e0fc | ||
|
|
ffa6679cfe | ||
|
|
19680b2ae4 | ||
|
|
d87030b46e | ||
|
|
22382c3f14 | ||
|
|
51863c4d11 | ||
|
|
dd7e685934 | ||
|
|
290985bf47 | ||
|
|
d1244d587f | ||
|
|
98156676ee | ||
|
|
e1f8000cce | ||
|
|
33cba41abb | ||
|
|
56607f41ec | ||
|
|
2e83a142ec | ||
|
|
b96ae5f553 | ||
|
|
5e3a6a2851 | ||
|
|
af5ddfb11d | ||
|
|
d205d3a05d | ||
|
|
12cd90a3b0 | ||
|
|
292325d85f | ||
|
|
ac93d3227b | ||
|
|
e108eaa4eb | ||
|
|
9daab1203f | ||
|
|
a3d709236c | ||
|
|
94ef97f5bb | ||
|
|
955b613527 | ||
|
|
6dfc30d1f6 | ||
|
|
c84724c1b6 | ||
|
|
c1a2a3465f | ||
|
|
48b87b1127 | ||
|
|
796613876b | ||
|
|
742232b882 | ||
|
|
f56d865c8e | ||
|
|
7e7b07083a | ||
|
|
d72bc0db03 | ||
|
|
e9989da71a | ||
|
|
331302da01 | ||
|
|
bfd1a94030 | ||
|
|
8727555788 | ||
|
|
b62af19418 | ||
|
|
4af93fe05a | ||
|
|
f9f2e1e2ed | ||
|
|
78c7e44d63 | ||
|
|
ddb03bc809 | ||
|
|
2da58920c4 | ||
|
|
1e5f282a6a | ||
|
|
064aaee76c | ||
|
|
3a2c1bee35 | ||
|
|
e11361b5d5 | ||
|
|
03929822e6 | ||
|
|
3ce9ea2cdc | ||
|
|
daa8942a29 | ||
|
|
8c2162d918 | ||
|
|
ab32b0416c | ||
|
|
3ce2bdc541 | ||
|
|
bac957dfd6 | ||
|
|
2ad6f5e6d4 | ||
|
|
18c70b01c5 | ||
|
|
51d8c72ae8 | ||
|
|
084dee4df9 | ||
|
|
fb65926760 | ||
|
|
6c3ac6d22d | ||
|
|
4d179b4372 | ||
|
|
b8d00e063f | ||
|
|
a109c49a5b | ||
|
|
faac11e3fc | ||
|
|
133ea3f6d2 | ||
|
|
f9e472c832 | ||
|
|
e432e3de79 | ||
|
|
8a7be00bf6 | ||
|
|
372c5dee70 | ||
|
|
135ee78a5d | ||
|
|
84e5cbc787 | ||
|
|
f9c4249086 | ||
|
|
fa4f1f6889 | ||
|
|
c42a16362c | ||
|
|
bf7d40be65 | ||
|
|
3428fbd1cf | ||
|
|
290e0efeca | ||
|
|
24f428622a | ||
|
|
5593c1f129 | ||
|
|
e05f1fb4a7 | ||
|
|
03bf356de2 | ||
|
|
f6f985d740 | ||
|
|
27f31531ac | ||
|
|
31f98a8e32 | ||
|
|
287d6291dd | ||
|
|
3b02a97643 | ||
|
|
7dc5ca1835 | ||
|
|
553990b375 | ||
|
|
ceb96a74bf | ||
|
|
f7178eb4b6 | ||
|
|
13723fe8c6 | ||
|
|
394c45000d | ||
|
|
17f516ed0b | ||
|
|
ba5865c397 | ||
|
|
d67c4dd815 | ||
|
|
74e5187bad | ||
|
|
d326ab755a | ||
|
|
0b56de0c81 | ||
|
|
384f97cdcf | ||
|
|
95c2b89875 | ||
|
|
71494da1a8 | ||
|
|
7e3bc8a4f4 | ||
|
|
5123e41ff5 | ||
|
|
fe91d825d6 | ||
|
|
e1a48a985a | ||
|
|
788d34801a | ||
|
|
6a01378fd8 | ||
|
|
df2adcc89a | ||
|
|
c445671016 | ||
|
|
61d88c0871 | ||
|
|
c487eb134d | ||
|
|
0f27370650 | ||
|
|
19b9f70e91 | ||
|
|
83bd63a1cc | ||
|
|
766e36ca6a | ||
|
|
3c8f706046 | ||
|
|
8f96a93545 | ||
|
|
bc8a13dd65 | ||
|
|
fa506b0970 | ||
|
|
f92cedccb9 | ||
|
|
75cece6a59 | ||
|
|
1d8972aad8 | ||
|
|
c7aab0c21b | ||
|
|
f1edd0b1a4 | ||
|
|
579fab8334 | ||
|
|
52d00d18e8 | ||
|
|
805b41905f | ||
|
|
8a05787a1d | ||
|
|
9436cc79f1 | ||
|
|
54a035417a | ||
|
|
187871edc5 | ||
|
|
3c9400f488 | ||
|
|
36a6ee012c | ||
|
|
b38a81d002 | ||
|
|
26185435b6 | ||
|
|
e3aa907486 | ||
|
|
794dbc9a96 | ||
|
|
387534e6bb | ||
|
|
1b296d5010 | ||
|
|
fb996469d8 | ||
|
|
8ef9f7a1dc | ||
|
|
1d19d3474f | ||
|
|
29e74d37b1 | ||
|
|
48d8d573c3 | ||
|
|
95c715c9eb | ||
|
|
a1a75a1cda | ||
|
|
326208ed1d | ||
|
|
13c5ce3157 | ||
|
|
c4f470b329 | ||
|
|
53ca7aca0a | ||
|
|
dfe85723e1 | ||
|
|
1779f47148 | ||
|
|
03608f9b23 | ||
|
|
b35a403d70 | ||
|
|
58ff1e68b4 | ||
|
|
2fb01324ed | ||
|
|
51b153aa2b | ||
|
|
e1eac7544a | ||
|
|
603762a8aa | ||
|
|
1f5426f110 | ||
|
|
3de42412dd | ||
|
|
7148e41828 | ||
|
|
e58e5503e8 | ||
|
|
a497c8a630 | ||
|
|
0141af2ed6 | ||
|
|
05cfe42860 | ||
|
|
4971edbb59 | ||
|
|
b2cae9bfa4 | ||
|
|
def38ad5a9 | ||
|
|
e8c4ffc543 | ||
|
|
babecb0847 | ||
|
|
33f1e66b62 | ||
|
|
13885fff4c | ||
|
|
21215260ce | ||
|
|
a99102dc2a | ||
|
|
257e4d037d | ||
|
|
85e42851f3 | ||
|
|
49987c445f | ||
|
|
f4eab8776f | ||
|
|
eb73ff225c | ||
|
|
42770fbfe6 | ||
|
|
3a9015753c | ||
|
|
0ea96613b0 | ||
|
|
a66a68d7eb | ||
|
|
c8685e9e0e | ||
|
|
8e49a106fb | ||
|
|
1175ea7deb | ||
|
|
37ab5ed278 | ||
|
|
8a059a4642 | ||
|
|
41aff63ae6 | ||
|
|
52addabeed | ||
|
|
a979e8c905 | ||
|
|
26d59b6650 | ||
|
|
d887997a5a | ||
|
|
cf7bb5c337 | ||
|
|
9ebf86cdfa | ||
|
|
9504306af6 | ||
|
|
3487fd200c | ||
|
|
15fedc7c77 | ||
|
|
5e2c836cf2 | ||
|
|
7eb33c9af2 | ||
|
|
5b48b10971 | ||
|
|
e97b623b43 | ||
|
|
906368d45e | ||
|
|
850f89c8ed | ||
|
|
32e4afdbe8 |
@@ -0,0 +1,16 @@
|
||||
# Docker Build Context
|
||||
# Purpose: Keeps local state, cached dependencies, and host-built artifacts out
|
||||
# of the production image build context. Every required artifact is recreated
|
||||
# by the Dockerfile from tracked source and lockfiles.
|
||||
.git
|
||||
.gitignore
|
||||
**/node_modules
|
||||
# Neither the legacy state directory nor the Compose-mounted replacement may
|
||||
# enter an image build. This prevents credentials and backups from becoming
|
||||
# image layers after an operator has started using either deployment layout.
|
||||
/server/data
|
||||
/data
|
||||
server/public
|
||||
server/src/services/kinectService/native/kinect_worker
|
||||
server/src/services/balanceBoardService/native/balance_board_worker
|
||||
**/*.log
|
||||
@@ -0,0 +1,67 @@
|
||||
# Build the exact production image on pull requests, publish branch-named images
|
||||
# for development, and replace `latest` only after a successful main-branch
|
||||
# push. Keeping every channel in one job prevents a second build path from
|
||||
# drifting away from what servers actually download.
|
||||
name: Container image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Every repository branch gets one moving development image. GitHub does not
|
||||
# grant package-write access to untrusted fork pull requests, which remain
|
||||
# build-only through the separate pull_request event above.
|
||||
push:
|
||||
|
||||
# Repository contents are read to build the image. Package write access is used
|
||||
# only by the conditional GHCR login and push steps on repository branch pushes.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Buildx supplies the cache and the explicit amd64 build used both for
|
||||
# pull-request verification and publication. QEMU is intentionally absent
|
||||
# because the central server image supports only linux/amd64.
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# GitHub's built-in token can publish to this repository's package. Pull
|
||||
# requests never authenticate to GHCR and therefore cannot publish.
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Docker's maintained metadata action converts branch names into valid
|
||||
# container tags, including replacing separators such as `/`. Main gets
|
||||
# only `latest`; every other pushed branch gets only its branch tag.
|
||||
- name: Select image tag
|
||||
id: image-metadata
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/legop3/multiroombarover
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch,enable={{is_not_default_branch}}
|
||||
|
||||
# The push switch keeps pull requests build-only. Branch images are
|
||||
# replaced on each successful push, just as main replaces `latest`.
|
||||
- name: Build and optionally publish
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: ${{ steps.image-metadata.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -5,15 +5,39 @@ create_2_Open_Interface_Spec.txt
|
||||
|
||||
logs
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pio
|
||||
.vscode/
|
||||
config.h
|
||||
robots.json
|
||||
roverd-dummy
|
||||
server/config.yaml
|
||||
server/package-lock.json
|
||||
package-lock.json
|
||||
server/data/discord-guilds.json
|
||||
server/data/global-objective.json
|
||||
server/data/admin-reason.json
|
||||
server/data
|
||||
server/data/buttonbox-state.json
|
||||
server/data/barcode-tts-cache/
|
||||
server/data/rover-odometers.json
|
||||
server/data/mediamtx.yml
|
||||
# npm lockfiles are deliberately tracked. The production image uses `npm ci`,
|
||||
# so a clean GitHub checkout must contain the exact dependency resolution used
|
||||
# by local builds rather than resolving a different dependency tree.
|
||||
!server/data/
|
||||
!server/data/barcode-registry.json
|
||||
webui/src/config/analytics.jsx
|
||||
webui/src/config/driverAnalytics.json
|
||||
server/data/analytics.html
|
||||
plans/barcodegames.txt
|
||||
.gitignore
|
||||
server/data/identity.sqlite
|
||||
server/data/barcode-games.json
|
||||
server/data/identity.sqlite-shm
|
||||
server/data/identity.sqlite-wal
|
||||
server/src/services/balanceBoardService/native/balance_board_worker
|
||||
server/data/fleet-reports.sqlite
|
||||
server/data/fleet-reports.sqlite-shm
|
||||
server/data/fleet-reports.sqlite-wal
|
||||
server/data/configuration.sqlite
|
||||
server/data/configuration.sqlite-shm
|
||||
server/data/configuration.sqlite-wal
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# MultiRover Production Application Image
|
||||
# Purpose: Builds the web application, Node dependencies, native hardware
|
||||
# workers, and pinned runtime tools into one amd64 server image.
|
||||
# Scope: Packages the main application only. Compose, host hardware access, and
|
||||
# lifecycle/update control remain separate deployment concerns.
|
||||
|
||||
ARG FEDORA_VERSION=43
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS architecture-check
|
||||
ARG TARGETARCH
|
||||
# The central server is currently deployed and verified only on amd64. Failing
|
||||
# here avoids publishing an ARM image whose native workers and hardware paths
|
||||
# have never been exercised on a real ARM server.
|
||||
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
||||
|
||||
FROM architecture-check AS webui-build
|
||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \
|
||||
&& dnf clean all
|
||||
WORKDIR /build
|
||||
# Copy dependency manifests first so ordinary source edits retain the expensive
|
||||
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency
|
||||
# source rather than resolving a new tree during image publication.
|
||||
COPY webui/package.json webui/package-lock.json ./webui/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
cd webui && npm ci
|
||||
COPY webui ./webui
|
||||
# Vite deliberately emits into ../server/public. Create that destination in the
|
||||
# isolated builder and copy only its finished files into the runtime stage.
|
||||
RUN mkdir -p server/public && cd webui && npm run build
|
||||
|
||||
FROM architecture-check AS server-dependencies
|
||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \
|
||||
&& dnf clean all
|
||||
WORKDIR /build/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
# Native Node modules compile here when a matching prebuild is unavailable;
|
||||
# neither the compiler nor npm's download cache is copied into the final image.
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --omit=dev
|
||||
|
||||
FROM architecture-check AS native-workers
|
||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
bluez-libs-devel \
|
||||
gcc-c++ \
|
||||
libcap \
|
||||
libfreenect-devel \
|
||||
libusb1-devel \
|
||||
make \
|
||||
pkgconf-pkg-config \
|
||||
wiiuse-devel \
|
||||
&& dnf clean all
|
||||
WORKDIR /build
|
||||
COPY server/src/services/kinectService/native ./kinect
|
||||
COPY server/src/services/balanceBoardService/native ./balance-board
|
||||
# Build both hardware workers from source for the image's Fedora ABI instead of
|
||||
# copying workstation binaries whose linked libraries may not match.
|
||||
RUN make -C kinect \
|
||||
&& make -C balance-board
|
||||
|
||||
FROM architecture-check AS packaged-tools
|
||||
ARG MEDIAMTX_VERSION=1.15.3
|
||||
ARG MEDIAMTX_SHA256=cddc98d17f23689848d5a935151264e117cfb27cee2db87b5079b19572e4b48d
|
||||
ARG NEOLINK_VERSION=0.6.2
|
||||
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
|
||||
ARG GOOGLE_TTS_VERSION=26.5
|
||||
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
|
||||
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \
|
||||
&& dnf clean all
|
||||
WORKDIR /build/tools
|
||||
# Each external artifact is pinned and checked before extraction. A changed or
|
||||
# truncated upstream download therefore fails the image build instead of being
|
||||
# silently promoted as the new production server.
|
||||
RUN curl --fail --location --retry 3 \
|
||||
"https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" \
|
||||
--output mediamtx.tar.gz \
|
||||
&& echo "${MEDIAMTX_SHA256} mediamtx.tar.gz" | sha256sum --check --strict \
|
||||
&& tar -xzf mediamtx.tar.gz mediamtx \
|
||||
&& install -D -m 0755 mediamtx /output/usr/local/bin/mediamtx
|
||||
RUN curl --fail --location --retry 3 \
|
||||
"https://github.com/QuantumEntangledAndy/neolink/releases/download/v${NEOLINK_VERSION}/neolink_linux_x86_64_ubuntu.zip" \
|
||||
--output neolink.zip \
|
||||
&& echo "${NEOLINK_SHA256} neolink.zip" | sha256sum --check --strict \
|
||||
&& unzip -q neolink.zip -d neolink \
|
||||
&& neolink_binary="$(find neolink -type f -name neolink -print -quit)" \
|
||||
&& test -n "${neolink_binary}" \
|
||||
&& install -D -m 0755 "${neolink_binary}" /output/usr/local/bin/neolink
|
||||
RUN curl --fail --location --retry 3 \
|
||||
"https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-${GOOGLE_TTS_VERSION}.tar.xz" \
|
||||
--output googletts.tar.xz \
|
||||
&& echo "${GOOGLE_TTS_SHA256} googletts.tar.xz" | sha256sum --check --strict \
|
||||
&& tar -xf googletts.tar.xz en-us-x-multi.zvoice libchrometts_x86_64.so \
|
||||
&& install -D -m 0644 libchrometts_x86_64.so /output/opt/roverd/googletts/libchrometts.so \
|
||||
&& mkdir -p /output/opt/roverd/googletts/en-us-x-multi-r30 \
|
||||
&& unzip -q en-us-x-multi.zvoice -d /output/opt/roverd/googletts/en-us-x-multi-r30 \
|
||||
&& find /output/opt/roverd/googletts -type d -exec chmod 0755 {} + \
|
||||
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||
ARG TARGETARCH
|
||||
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
||||
# This is the complete runtime package set. Build headers and compilers live in
|
||||
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
||||
# because enabled services invoke them after startup. Weak dependencies are
|
||||
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
|
||||
# themes, and GPU drivers that a headless server neither starts nor uses.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
bluez \
|
||||
bluez-libs \
|
||||
espeak \
|
||||
ffmpeg-free \
|
||||
flite \
|
||||
gstreamer1 \
|
||||
gstreamer1-plugins-bad-free \
|
||||
gstreamer1-plugins-base \
|
||||
gstreamer1-plugins-good \
|
||||
gstreamer1-rtsp-server \
|
||||
libcxx \
|
||||
libcxxabi \
|
||||
libcap \
|
||||
libfreenect \
|
||||
libusb1 \
|
||||
nodejs \
|
||||
python3 \
|
||||
shadow-utils \
|
||||
tini \
|
||||
wiiuse \
|
||||
&& dnf clean all \
|
||||
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
|
||||
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
|
||||
|
||||
WORKDIR /opt/multirover/server
|
||||
COPY server/index.js server/package.json server/package-lock.json ./
|
||||
COPY server/src ./src
|
||||
COPY server/assets ./assets
|
||||
COPY server/prompts ./prompts
|
||||
COPY --from=server-dependencies /build/server/node_modules ./node_modules
|
||||
COPY --from=webui-build /build/server/public ./public
|
||||
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
|
||||
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||
COPY --from=packaged-tools /output/ /
|
||||
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
|
||||
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
|
||||
|
||||
# Only the audited Balance Board bridge receives its two required socket
|
||||
# capabilities. Node and the rest of the application continue to run without
|
||||
# ambient capabilities; Compose must also allow these capabilities when the
|
||||
# optional Balance Board feature is used.
|
||||
RUN setcap cap_net_admin,cap_net_bind_service+ep \
|
||||
./src/services/balanceBoardService/native/balance_board_worker \
|
||||
&& /usr/local/bin/chromegtts-wav \
|
||||
--text "test" \
|
||||
--voice tpf \
|
||||
--pitch 1 \
|
||||
--speed 1 \
|
||||
--output /tmp/chromegtts-smoke.wav \
|
||||
&& test -s /tmp/chromegtts-smoke.wav \
|
||||
&& rm /tmp/chromegtts-smoke.wav
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
SERVER_DATA_DIR=/data \
|
||||
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
|
||||
|
||||
USER multirover
|
||||
# Declaring the persistence boundary also protects direct `docker run` users:
|
||||
# when no explicit host path or named volume is supplied, Docker still places
|
||||
# `/data` on an anonymous volume rather than the replaceable image layer.
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080/tcp 8554/tcp 8189/tcp 8189/udp
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
# OCI metadata links the unversioned latest image to its source without
|
||||
# introducing release numbers or additional image tags.
|
||||
LABEL org.opencontainers.image.source="https://github.com/legop3/MultiRoombaRover" \
|
||||
org.opencontainers.image.title="MultiRoombaRover"
|
||||
@@ -1,7 +1,74 @@
|
||||
# Multi Roomba Rover
|
||||
A system for controlling create 2 compatible roombas through a webpage.
|
||||
|
||||
Docs coming "soon"
|
||||
You can explore my basement through this project here:
|
||||
https://rover.otter.land
|
||||
|
||||
## Basic installation
|
||||
-
|
||||
*some of this code was created with help from large language models, and some of it was written by me. This project would not have been possible for me to create without it.*
|
||||
|
||||
## This guide is a work in progress, it will cover:
|
||||
- Building rovers
|
||||
- Installing roverd on a rover's raspberry pi
|
||||
- Setting up the server
|
||||
- Configuration
|
||||
|
||||
## Building a rover
|
||||
You WILL need some common hobby electronics knowledge to build one of these, I will not be making a super detailed wiring or assembly guide, some of building your rover will be up to you, things such as a protective cage, camera and electronics mounting will not be covered in any great detail.
|
||||
|
||||
### Parts per rover
|
||||
|
||||
| Part | Notes |
|
||||
| --- | --- |
|
||||
| Create 2 compatible Roomba with the mini DIN port | Tested only with 600 series Roombas, your mileage may vary with newer models |
|
||||
| 4S li-ion -> 5v Voltage regulator | I use cheapo 12-24v USB car chargers, tear them apart, and solder wires onto them |
|
||||
| 7 pin mini DIN cable | Adafruit sells these labeled as a cable specifically for the iRobot Create 2 |
|
||||
| Logic level shifter | I use ones from adafruit, the Roomba is 5v logic and the pi is 3.3v logic |
|
||||
| Google voice AIY hat v1 | This serves three purposes: speaker, microphone, and a high current LED driver. None of these things are required to make a rover with a camera that can drive around, but it makes it much more fun. The design of this hat also makes it very easy to connect all of the various wires to all the IO on the pi that they need to go to. |
|
||||
| Raspberry pi zero 2 W | This will run roverd to control the rover and manage some Roomba safety things locally. You can probably use any pi you want, but these are usually the cheapest. |
|
||||
| A good wifi adapter | Has to work with Linux 6.12 (I use ones with the MT7921AU chipset and they work great), ideally wifi 6 with MU-MIMO if you are running multiple rovers on one AP. The built in wifi on the pi zero 2 W is NOT good enough for this. |
|
||||
| A USB hub hat (if using a pi zero) | If you are using a raspberry pi zero, you will want a USB hub hat for your wifi adapter, I would not recommend using an OTG adapter as it may not provide enough power. |
|
||||
| ----CAMERA STUFF---- | ----Below here is all camera related things that youll need---- |
|
||||
| A pi camera v2 (ov5647) | The video publisher in this project is only designed for the ov5647, though it would be totally possible to make your own publisher for other sensors. |
|
||||
| Cheap 5v servo | Unless you have a VERY wide camera lens, you will want to have a servo plugged into the pi to be able to tilt the camera up and down. |
|
||||
| LED for toggling the headlight | I use white LEDs attached to one of the "driver" pins on the AIY hat to trick one of those generic pi camera LED floodlights into turning on / off on demand.|
|
||||
| Optional laser pointer | A laser pointer can be wired to another high-current-capable GPIO driver, with GPIO 27 used by default in the rover config. |
|
||||
| Pi zero camera cable | The pi zero has a smaller version of the camera ribbon connector, you will need the right cable for your camera |
|
||||
|
||||
I do not have any exact numbers, but with cheap used Roombas it seems like on average one of my rovers takes about $100 USD to build from scratch.
|
||||
|
||||
### Physical assembly of the rover
|
||||
This is going to be the very loose section of this guide, the way yout build your rover is up to you. I have mine built out with durable protective metal cages as they are open to the internet and people like breaking things. The electronics on top of the roomba can be as simple as a pi, level shifter, and a camera if you just want to run it around yourself, things like a speaker, microphone, servo, headlight, laser pointer, are all totally optional and can be disabled in the configuration on the raspberry pi.
|
||||
|
||||
These are the basics of how evertything is connected on my rovers, this is hard to organize and illustrate through text but hopefully it will give you a good idea:
|
||||
1. 7 pin mini DIN cable
|
||||
1. Voltage regulator on the two pairs of VBAT wires
|
||||
1. 5v output of voltage regulator connected to the power input on the AIY hat
|
||||
2. level shifter's high voltage side attached to 5v from regulator, ground, TX, RX, and BRC from the DIN cable
|
||||
2. Level shifter low voltage side connected to 3.3v from the pi, with serial TX and RX, and the BRC pin on their respective low side level shifter lines. BRC pin goes to GPIO 4 on the pi.
|
||||
3. Google voice AIY hat:
|
||||
1. speaker
|
||||
2. microphone
|
||||
3. servo motor on GPIO pin 26
|
||||
4. Camera stuff:
|
||||
1. ov5647 camera
|
||||
2. full size to pi zero style camera ribbon cable, plug camera into pi
|
||||
3. an LED connected to a high current driver output on the AIY hat, GPIO 22 by default. This is meant to be poked into the LDR sensor on a pi camera IR floodlight to make it toggleable by the driver.
|
||||
4. optionally, a laser pointer connected to another high current driver output, GPIO 27 by default.
|
||||
|
||||
### Software on your rover
|
||||
Raspberry pi OS installation:
|
||||
|
||||
Everything that runs on the pi is designed for debian Bookworm, because I could not get the AIY voice hat working in Trixie.
|
||||
|
||||
To install roverd on your pi:
|
||||
1. Install `Raspberry Pi OS (Legacy) Lite` (exactly what its called in the imager) on your SD card
|
||||
2. Boot the pi up, plug in your good wifi adapter, use `nmtui` to set it up
|
||||
3. Install `git`, clone this repo into your home folder
|
||||
4. Move into the repo folder, run the roverd installer using `sudo pi/install_roverd.sh`
|
||||
5. Reboot your raspberry pi
|
||||
6. Roverd, along with the audio and video publishers should now be running. Roverd should have created it's default config file at `/etc/roverd.yaml`
|
||||
|
||||
Edit the `/etc/roverd.yaml` config file to change settings like your rover's name, color, and set the server IP.
|
||||
|
||||
|
||||
## Setting up the server
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# MultiRover production deployment
|
||||
#
|
||||
# This intentionally contains only the application container. The later update
|
||||
# controller will be added as a second service when it exists; keeping it out
|
||||
# now means this first deployment pass has no placeholder process or unused
|
||||
# privilege.
|
||||
name: multirover
|
||||
|
||||
services:
|
||||
server:
|
||||
image: ghcr.io/legop3/multiroombarover:latest
|
||||
container_name: multirover
|
||||
|
||||
# Host networking preserves the server's existing LAN behavior and avoids
|
||||
# maintaining a second list of TCP, UDP, RTSP, and WebRTC port mappings.
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
|
||||
# SELinux cannot safely relabel the host's system D-Bus socket, and the
|
||||
# hardware services also need host-owned USB device nodes. Disable Docker's
|
||||
# per-container SELinux label while retaining its namespace, capability,
|
||||
# non-root-user, and seccomp isolation. This is narrower than privileged
|
||||
# mode and avoids changing labels on shared host resources.
|
||||
security_opt:
|
||||
- label=disable
|
||||
|
||||
volumes:
|
||||
# This is the application's only persistent storage. No SELinux relabel
|
||||
# is needed because this hardware-integrated container runs label-free.
|
||||
- ./data:/data
|
||||
|
||||
# bluetoothctl talks to the host Bluetooth daemon over this socket. The
|
||||
# socket is read-only as a filesystem mount; D-Bus method calls still flow
|
||||
# through it normally without exposing the rest of the host D-Bus tree.
|
||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
|
||||
|
||||
# Kinect USB node numbers change when it reconnects, so expose the bus
|
||||
# directory rather than one temporary device path. The existing host udev
|
||||
# rule remains responsible for granting the non-root container user access.
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
|
||||
# Only the Balance Board worker receives this capability through its file
|
||||
# capabilities. The Node process remains non-root and the container is not
|
||||
# privileged.
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -18,5 +18,7 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: false
|
||||
laser:
|
||||
enabled: false
|
||||
|
||||
@@ -18,5 +18,7 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: false
|
||||
laser:
|
||||
enabled: false
|
||||
|
||||
@@ -18,5 +18,7 @@ media:
|
||||
service: mediamtx.service
|
||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
||||
healthInterval: 30s
|
||||
nightVision:
|
||||
headlight:
|
||||
enabled: false
|
||||
laser:
|
||||
enabled: false
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
New Drive corner-pod HUD sketch, second design pass
|
||||
Purpose: Shows true edge-mounted pods, physically attached expansions, and circular controls.
|
||||
Scope: Static design communication only; production geometry remains an implementation decision.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900" role="img" aria-labelledby="title description">
|
||||
<title id="title">Edge-mounted New Drive HUD pods</title>
|
||||
<desc id="description">Four pods flow directly into the corners of a four by three rover video. Each has one inward rounded corner, and expansions attach directly along video edges.</desc>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="video" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#26343d" />
|
||||
<stop offset="0.55" stop-color="#111827" />
|
||||
<stop offset="1" stop-color="#1f2937" />
|
||||
</linearGradient>
|
||||
<linearGradient id="battery" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#22c55e" />
|
||||
<stop offset="0.72" stop-color="#84cc16" />
|
||||
<stop offset="1" stop-color="#eab308" />
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#000000" flood-opacity="0.5" />
|
||||
</filter>
|
||||
<style>
|
||||
.pod { fill: #080a0f; fill-opacity: 0.86; stroke: #d1d5db; stroke-opacity: 0.26; stroke-width: 2; }
|
||||
.expansion { fill: #080a0f; fill-opacity: 0.82; stroke: #d1d5db; stroke-opacity: 0.2; stroke-width: 2; }
|
||||
.label { fill: #f8fafc; font-family: Inter, system-ui, sans-serif; font-weight: 700; }
|
||||
.small { fill: #cbd5e1; font-family: Inter, system-ui, sans-serif; font-size: 17px; }
|
||||
.tiny { fill: #94a3b8; font-family: Inter, system-ui, sans-serif; font-size: 14px; }
|
||||
.arrow-button { fill: #1f2937; stroke: #e5e7eb; stroke-opacity: 0.55; stroke-width: 1.5; }
|
||||
.arrow { fill: none; stroke: #f8fafc; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.track { fill: none; stroke: #334155; stroke-linecap: round; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- The entire canvas is the shared 4:3 video/HUD coordinate space. -->
|
||||
<rect width="1200" height="900" fill="url(#video)" />
|
||||
<path d="M0 610 C235 510 390 590 600 515 C820 438 1000 520 1200 430 L1200 900 L0 900 Z" fill="#0b1516" opacity="0.76" />
|
||||
<path d="M0 655 C240 555 425 635 630 560 C840 483 1020 560 1200 475" fill="none" stroke="#334155" stroke-width="5" opacity="0.42" />
|
||||
<text x="600" y="450" text-anchor="middle" class="small" opacity="0.28">Rover video</text>
|
||||
|
||||
<!-- Top-left pod flows into the top and left edges; only its inward bottom-right corner rounds. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M0 0 H190 V124 Q190 190 124 190 H0 Z" />
|
||||
<circle class="track" cx="91" cy="91" r="55" stroke-width="14" />
|
||||
<circle cx="91" cy="91" r="55" fill="none" stroke="#38bdf8" stroke-width="14" stroke-linecap="round" stroke-dasharray="255 346" transform="rotate(-90 91 91)" />
|
||||
<text x="91" y="84" text-anchor="middle" class="tiny">Turn</text>
|
||||
<text x="91" y="116" text-anchor="middle" class="label" font-size="30">0:42</text>
|
||||
<circle class="arrow-button" cx="22" cy="22" r="16" />
|
||||
<path class="arrow" d="M29 29 L16 16 M16 16 L25 16 M16 16 L16 25" />
|
||||
|
||||
<!-- This expansion begins exactly where the pod ends and continues directly into the top edge. -->
|
||||
<path class="expansion" d="M190 0 H486 V50 Q486 78 458 78 H190 Z" />
|
||||
<rect x="210" y="20" width="200" height="38" rx="8" fill="#7c3aed" opacity="0.72" />
|
||||
<text x="310" y="45" text-anchor="middle" class="label" font-size="18">Rover name</text>
|
||||
<circle class="arrow-button" cx="458" cy="39" r="14" />
|
||||
<path class="arrow" d="M458 46 L458 32 M458 32 L452 38 M458 32 L464 38" />
|
||||
</g>
|
||||
|
||||
<!-- Top-right pod and both expansions form one continuous edge-mounted cluster. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M1010 0 H1200 V190 H1076 Q1010 190 1010 124 Z" />
|
||||
<circle cx="1105" cy="91" r="59" fill="none" stroke="#334155" stroke-width="13" />
|
||||
<circle cx="1105" cy="91" r="59" fill="none" stroke="url(#battery)" stroke-width="13" stroke-linecap="round" stroke-dasharray="300 371" transform="rotate(-90 1105 91)" />
|
||||
<circle cx="1105" cy="91" r="42" fill="none" stroke="#334155" stroke-width="7" />
|
||||
<circle cx="1105" cy="91" r="42" fill="none" stroke="#f59e0b" stroke-width="7" stroke-linecap="round" stroke-dasharray="112 264" transform="rotate(-90 1105 91)" />
|
||||
<text x="1105" y="101" text-anchor="middle" class="label" font-size="30">81%</text>
|
||||
<circle class="arrow-button" cx="1178" cy="22" r="16" />
|
||||
<path class="arrow" d="M1171 29 L1184 16 M1184 16 L1175 16 M1184 16 L1184 25" />
|
||||
|
||||
<!-- Left expansion is attached to the pod at x=1010 and touches the top video edge. -->
|
||||
<path class="expansion" d="M690 0 H1010 V78 H718 Q690 78 690 50 Z" />
|
||||
<text x="718" y="31" class="label" font-size="18">Dock assist</text>
|
||||
<text x="718" y="57" class="small">Dock rover</text>
|
||||
<rect x="912" y="24" width="38" height="30" rx="6" fill="#312e81" stroke="#a5b4fc" />
|
||||
<text x="931" y="45" text-anchor="middle" class="label" font-size="14">G</text>
|
||||
<circle class="arrow-button" cx="980" cy="39" r="14" />
|
||||
<path class="arrow" d="M980 46 L980 32 M980 32 L974 38 M980 32 L986 38" />
|
||||
|
||||
<!-- Lower expansion shares the pod's bottom edge and flows directly into the right edge. -->
|
||||
<path class="expansion" d="M930 190 H1200 V420 H996 Q930 420 930 354 Z" />
|
||||
<text x="958" y="225" class="label" font-size="18">Advanced power</text>
|
||||
<text x="958" y="255" class="small">Voltage</text>
|
||||
<rect x="958" y="266" width="214" height="8" rx="2" fill="#334155" />
|
||||
<rect x="958" y="266" width="160" height="8" rx="2" fill="#38bdf8" />
|
||||
<text x="958" y="306" class="small">Current</text>
|
||||
<rect x="958" y="317" width="214" height="8" rx="2" fill="#334155" />
|
||||
<rect x="958" y="317" width="90" height="8" rx="2" fill="#f59e0b" />
|
||||
<text x="958" y="359" class="tiny">Computer 54 C</text>
|
||||
<text x="958" y="383" class="tiny">Wi-Fi -58 dBm</text>
|
||||
<circle class="arrow-button" cx="1174" cy="216" r="14" />
|
||||
<path class="arrow" d="M1167 216 L1181 216 M1181 216 L1175 210 M1181 216 L1175 222" />
|
||||
</g>
|
||||
|
||||
<!-- Bottom-left pod flows into the left and bottom edges with a compact triangular control group. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M0 680 H220 Q300 680 300 760 V900 H0 Z" />
|
||||
<circle cx="68" cy="758" r="38" fill="#172554" stroke="#60a5fa" stroke-width="2" />
|
||||
<text x="68" y="754" text-anchor="middle" class="label" font-size="24">☀</text>
|
||||
<text x="68" y="779" text-anchor="middle" class="tiny">E</text>
|
||||
<circle cx="102" cy="850" r="38" fill="#3b2f0b" stroke="#facc15" stroke-width="2" />
|
||||
<text x="102" y="846" text-anchor="middle" class="label" font-size="24">⌁</text>
|
||||
<text x="102" y="871" text-anchor="middle" class="tiny">R</text>
|
||||
<circle cx="208" cy="798" r="57" fill="#3f1d2e" stroke="#fb7185" stroke-width="3" />
|
||||
<text x="208" y="794" text-anchor="middle" class="label" font-size="25">Horn</text>
|
||||
<text x="208" y="823" text-anchor="middle" class="tiny">H</text>
|
||||
<circle class="arrow-button" cx="252" cy="754" r="14" />
|
||||
<path class="arrow" d="M246 754 L258 754 M258 754 L253 749 M258 754 L253 759" />
|
||||
<circle class="arrow-button" cx="22" cy="878" r="16" />
|
||||
<path class="arrow" d="M29 871 L16 884 M16 884 L25 884 M16 884 L16 875" />
|
||||
</g>
|
||||
|
||||
<!-- Bottom-right pod contains a circular tilt slider rather than a horizontal or pill track. -->
|
||||
<g filter="url(#shadow)">
|
||||
<path class="pod" d="M840 900 V760 Q840 680 920 680 H1200 V900 Z" />
|
||||
<text x="1168" y="714" text-anchor="end" class="label" font-size="18">Camera tilt</text>
|
||||
<circle class="track" cx="1030" cy="800" r="76" stroke-width="13" />
|
||||
<circle cx="1030" cy="800" r="76" fill="none" stroke="#38bdf8" stroke-width="13" stroke-linecap="round" stroke-dasharray="285 478" transform="rotate(140 1030 800)" />
|
||||
<circle cx="976" cy="746" r="13" fill="#e0f2fe" stroke="#0284c7" stroke-width="4" />
|
||||
<text x="1030" y="808" text-anchor="middle" class="label" font-size="25">-12.5°</text>
|
||||
<text x="1030" y="832" text-anchor="middle" class="tiny">Click for zero</text>
|
||||
<circle cx="948" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
|
||||
<text x="948" y="844" text-anchor="middle" class="label" font-size="14">J</text>
|
||||
<circle cx="1112" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
|
||||
<text x="1112" y="844" text-anchor="middle" class="label" font-size="14">U</text>
|
||||
<circle class="arrow-button" cx="1178" cy="878" r="16" />
|
||||
<path class="arrow" d="M1171 871 L1184 884 M1184 884 L1175 884 M1184 884 L1184 875" />
|
||||
</g>
|
||||
|
||||
<!-- Immediate sensor overlays remain separate and are shown only as faint context here. -->
|
||||
<path d="M360 900 Q600 808 840 900" fill="none" stroke="#ef4444" stroke-width="13" stroke-linecap="round" opacity="0.25" />
|
||||
<path d="M410 886 Q600 820 790 886" fill="none" stroke="#22c55e" stroke-width="5" stroke-dasharray="12 10" opacity="0.4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,75 @@
|
||||
- start work on new better ui layout, using components that already exist when possible
|
||||
- centered rover video, full screen height
|
||||
- rover HUD contains small but expandable rover telemetry UI and vis
|
||||
- make newgen folder for new HUD elements. reuse old elements where possible
|
||||
- make all new hud elements small and clean
|
||||
- every hud element:
|
||||
- is a nice small translucent thing with text icons or both
|
||||
- can be expanded to show more relavent information
|
||||
- is consistent. maybe make a reusable thing for this
|
||||
- some specific hud elements:
|
||||
- top bar:
|
||||
- battery percentage that goes red and flashes and such
|
||||
- turns hud that shows people in queue
|
||||
- big in the middle
|
||||
- left and right sides
|
||||
- wheel drop indicators that show up when wheel drop is happening
|
||||
- overcurrent and battery warnings
|
||||
- bottom section:
|
||||
- sensor elements that show up only when the sensor is "happening"
|
||||
- bumpers
|
||||
- front IR proximity sensors
|
||||
- two sidebars
|
||||
- sidebars contain all the stuff that isnt the rover
|
||||
- left
|
||||
- idk
|
||||
- right
|
||||
- chat, users, rovers list, and replay sources
|
||||
- everything involving the rover is a video HUD, everything external is in the sidebars
|
||||
|
||||
|
||||
|
||||
|
||||
## section 2
|
||||
|
||||
There will be corner mounted (one pod in each corner of the video), rounded pods in the HUD, which will contain gauges and controls for the rover
|
||||
|
||||
These pods will be collapsible, with a corner mounted arrow. the arrow points towards the corner when the pod is out, and points out of the corner when the pod is hidden.
|
||||
|
||||
There can also be "pod expansions" that will be in the corner of the pod and the side of the video. These are also collapsible, but they collapse into the side of the video that they are touching, instead of collapsing into the corner, with the same style arrow button as the pods.
|
||||
|
||||
For example, a pod in the top left is open. This pod has an expansion to it's right that is also open. I can collapse the pod into the corner, the expansion stays, it gets moved into the top left corner where the pod was.
|
||||
|
||||
- corner pods:
|
||||
- top left
|
||||
- pod
|
||||
- turns timer
|
||||
- round gauge circle that ticks down with time
|
||||
- inside it, is the turn countdown
|
||||
- this pod goes away when theres nothing to count
|
||||
- right of pod expansion
|
||||
- rover name with colored background
|
||||
- expanded by default
|
||||
- top right
|
||||
- pod
|
||||
- round rover battery bar gauge, based off how battery bar looks
|
||||
- concentric to this bar is an unlabeled current gauge, styled after the current bar that the top down map contains
|
||||
- inside the circle, is the battery percentage.
|
||||
- left of pod expansion
|
||||
- dock assist button and keybind
|
||||
- expanded by default
|
||||
- below pod expansion
|
||||
- combined advanced power view for the roomba with other info from rover host stats
|
||||
- bottom left
|
||||
- pod
|
||||
- has circular buttons for laser, horn, and headlight
|
||||
- each button is a related icon and the keybind label for the feature
|
||||
- arranged nicely
|
||||
- horn button is larger, and contains an arrow to open the horn settings menu
|
||||
- this pod disappears when none of these things are enabled
|
||||
- if one of the button's features is not enabled, that button should go away
|
||||
- bottom right
|
||||
- pod
|
||||
- rounded camera tilt slider, with keybind label on each end for up / down
|
||||
- in the area inside the slider, show the tilt degrees
|
||||
- clicking the degrees label should set camera tilt to 0
|
||||
@@ -0,0 +1,756 @@
|
||||
# Server administration and container migration
|
||||
|
||||
## Status
|
||||
|
||||
This document is the live implementation tracker for the migration.
|
||||
|
||||
- [x] Phase 1, step 1: Establish the single data-directory contract
|
||||
- [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
|
||||
- [x] Phase 1, steps 6-8: Restart, backup/restore, and internal video proxy
|
||||
- [x] Phase 1, step 9: Complete the remaining legacy-deployment integration and hardware verification
|
||||
- [x] Phase 2, step 10: Build and locally verify the production application image
|
||||
- [x] Phase 2, steps 11-12: Add the single-container Compose deployment and locally verify its host-access contract
|
||||
- [x] Phase 2, step 15: Build pull requests and publish the main branch to the single GHCR `latest` channel
|
||||
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
|
||||
|
||||
Phase 1 is complete. The current application has run successfully on the production server with the new configuration, administration, persistence, backup/restore, and internal video-proxy contracts.
|
||||
|
||||
The work is deliberately split into two phases:
|
||||
|
||||
1. Finish the server-side configuration, administration, persistence, backup, restore, and media-routing changes while the server still uses its current systemd deployment.
|
||||
2. Containerize the already-finished application, publish images through GHCR, and add container-aware update and restart controls.
|
||||
|
||||
Phase 1 must be complete and verified before Phase 2 begins. Containerization must not become a second configuration migration or a reason to maintain two persistence layouts.
|
||||
|
||||
## Decision log
|
||||
|
||||
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
|
||||
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
|
||||
- 2026-09-14: Apply every committed configuration revision immediately. The configuration coordinator atomically replaces the process-wide snapshot, compares top-level service sections, serially reloads only affected service runtimes, and then refreshes all sessions. Long-lived HTTP/socket handlers remain registered once and delegate to the current runtime; integrations may reconnect or replace their own child process, worker, client, timers, and subscriptions without restarting Node.
|
||||
- 2026-09-14: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet.
|
||||
- 2026-09-14: Restart only the application process, never the host. A lockdown administrator with recent password confirmation requests one audited restart, Node acknowledges and announces it, then sends itself SIGTERM. Existing service signal handlers clean up their owned children, while systemd `Restart=always` and the later container restart policy start the application again.
|
||||
- 2026-09-14: Keep backup and restore together in one server service after application restart exists. Neither operation stops running services or writers, and no command-line interface is maintained. Backup uses online SQLite snapshots and stable copies of non-database files; restore validates and stages an uploaded archive, records a marker, and uses the normal application restart to replace the data directory during earliest startup.
|
||||
- 2026-09-14: Define this server's canonical `publicUrl` once at the top of configuration beside `timezone`. Discord links, inter-instance identity, page metadata, and MediaMTX's primary public ICE hostname derive from it. Browser WHEP and WHIP signaling always uses the same-origin `/video` path; `media.additionalHosts` remains only for genuinely additional ICE names or addresses.
|
||||
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file paths retained are operator-selected YAML uploads on `/setup` and the protected Configuration page. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
|
||||
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
|
||||
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
|
||||
- 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries.
|
||||
- 2026-09-13: Use an internal Node `/video` proxy. The public reverse proxy will send every site path to Node, Node will strip `/video` and stream WHEP signaling to MediaMTX on loopback, and MediaMTX port 8889 will not be exposed publicly. MediaMTX cannot independently add a WHEP base-path prefix; making `video` part of every stream name would still leave two HTTP servers competing for the public HTTPS listener.
|
||||
- 2026-09-13: Preserve the existing flat `server/data` layout instead of moving established stores into decorative `state`, `cache`, or `generated` parents. Packaged application assets remain with the application.
|
||||
- 2026-09-13: "The server" in the filesystem rule specifically means the main Node.js application. Every file it intentionally creates or modifies, including disposable scratch work, must be beneath `SERVER_DATA_DIR`. Installers, systemd, Docker, BlueZ, and unavoidable internal behavior of external libraries are outside that application boundary.
|
||||
|
||||
## Final goals
|
||||
|
||||
- `config.yaml` and `config.example.yaml` no longer exist.
|
||||
- All operator-controlled server configuration is stored in a validated database and managed through the web UI.
|
||||
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
|
||||
- A complete backup can capture that one data directory consistently, and a restore can safely replace it.
|
||||
- `/setup` may initialize the database from a YAML file explicitly selected by the operator, and the protected Configuration page may explicitly replace configuration from one later; no automatic host migration or persistent YAML source exists.
|
||||
- A dedicated `/admin` application contains all server administration.
|
||||
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
|
||||
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
|
||||
- The latest successful main-branch image is built automatically and published to GHCR as `ghcr.io/legop3/multiroombarover:latest`.
|
||||
- The admin UI can restart, update, health-check, and roll back the application container without giving the main application direct Docker access.
|
||||
- The final host installation contains as little project-specific material as possible: a Compose file, a data directory, and unavoidable hardware preparation.
|
||||
|
||||
## Important boundary: application files versus server data
|
||||
|
||||
The data-directory rule applies to everything mutable or instance-specific that the server reads, writes, generates, or persists at runtime. It does not mean copying the application itself into the data directory.
|
||||
|
||||
Packaged, read-only application material remains with the application and later inside the image:
|
||||
|
||||
- Server source and production dependencies
|
||||
- Built web UI assets
|
||||
- Static sound and image assets shipped by the repository
|
||||
- MediaMTX, ffmpeg, ffprobe, neolink, and TTS tools
|
||||
- Kinect and Balance Board workers
|
||||
- Helper scripts shipped as part of the application
|
||||
|
||||
The single data directory owns:
|
||||
|
||||
- Server configuration and secrets
|
||||
- Administrator accounts
|
||||
- Identity and permission records
|
||||
- Fleet reports
|
||||
- Persistent service state
|
||||
- Audit history
|
||||
- Generated MediaMTX configuration
|
||||
- Snapshots and replay segments
|
||||
- Finished replays
|
||||
- PTZ-generated audio
|
||||
- Barcode/TTS caches
|
||||
- Disposable audio-forward and replay-build work under `runtime/`
|
||||
- Backup and restore coordination state
|
||||
- Any future file deliberately created or modified by the Node application
|
||||
|
||||
Application-owned scratch work must use `SERVER_DATA_DIR/runtime`, even when it is safe to lose on restart. Tests may use the operating system temporary directory because they are not the running server application. Packaged programs and host services can manage their own internal temporary state, but any output path explicitly selected by Node must follow the single-root rule.
|
||||
|
||||
# Phase 1: complete the application before containerization
|
||||
|
||||
Phase 1 is server, web UI, installer, and migration work only. The current systemd deployment remains the runtime while these contracts are changed and verified.
|
||||
|
||||
## 1. Establish the single data-directory contract
|
||||
|
||||
The normal development and legacy-install location remains `server/data`. The path continues to be overridable through `SERVER_DATA_DIR`, which will later be set to `/data` in the container.
|
||||
|
||||
A representative final layout is:
|
||||
|
||||
```text
|
||||
server/data/
|
||||
├── configuration.sqlite
|
||||
├── identity.sqlite
|
||||
├── fleet-reports.sqlite
|
||||
├── mediamtx.yml
|
||||
├── existing service JSON stores
|
||||
├── barcode-tts-cache/
|
||||
├── rover-snapshots/
|
||||
├── replay-segments/
|
||||
├── replays/
|
||||
├── ptz-camera-audio/
|
||||
├── runtime/
|
||||
│ ├── audio-forward/
|
||||
│ ├── replay-builds/
|
||||
│ └── room-camera-replay-builds/
|
||||
└── system/
|
||||
├── backup-staging/
|
||||
└── restore/
|
||||
```
|
||||
|
||||
The exact number of databases is not important. A centralized admin UI does not require unrelated services to share one SQLite connection. Keeping identity and high-volume fleet reporting in their existing databases may remain simpler, provided every database is under the same data directory.
|
||||
|
||||
Required work:
|
||||
|
||||
- Audit every server filesystem read and write.
|
||||
- Make every persistent path resolve from the shared data-path helper.
|
||||
- Move rover and PTZ snapshots out of `/var/lib/rover-snapshots` and into the data directory.
|
||||
- Remove separate persistent replay path configuration and keep replay segments and completed replays under the data directory.
|
||||
- Keep generated MediaMTX configuration at its established `data/mediamtx.yml` path.
|
||||
- Keep barcode speech, PTZ speech, and similar caches under the data directory.
|
||||
- Check native workers and child-process scripts for hidden working-directory assumptions.
|
||||
- Update health reporting to inspect the new paths.
|
||||
- Update the legacy installer so the service receives one `SERVER_DATA_DIR` rather than several unrelated persistent paths.
|
||||
- Add a focused test that runs services against a temporary data directory and proves that no test artifact escapes it.
|
||||
- Document which files are durable and which cache directories may be discarded.
|
||||
|
||||
The audit must search direct filesystem calls as well as environment-variable defaults. Existing calls that default to `/var/lib`, the repository directory, or an implicit current working directory must be corrected.
|
||||
|
||||
## 2. Replace YAML with a configuration database
|
||||
|
||||
Implementation architecture:
|
||||
|
||||
- Each configurable service owns a side-effect-free fragment containing its key, safe default, and strict schema. One short composition list assembles those fragments into the ordered hierarchical document.
|
||||
- The database validates and commits that complete document as one coherent immutable revision.
|
||||
- The admin UI presents one continuous configuration page in the same top-to-bottom order as the former YAML file.
|
||||
- Nested cards make object relationships readable, but do not create separate categories, navigation destinations, persistence boundaries, or registries.
|
||||
- Shared editor infrastructure owns loading, dirty state, validation errors, revision conflicts, secret operations, and live-application status for the whole document.
|
||||
- The browser receives this same schema from the protected admin endpoint and renders it with a maintained JSON Schema form library.
|
||||
- Standard JSON Schema types drive ordinary fields, nested objects, enums, and arrays. One field-agnostic widget handles every `writeOnly` secret; there are no feature-specific configuration components in React.
|
||||
|
||||
Create a synchronous configuration service backed by `better-sqlite3`. Synchronous reads preserve the server's current startup model, in which many services load their configuration while modules are required.
|
||||
|
||||
The configuration database should own at least:
|
||||
|
||||
- The current complete configuration document
|
||||
- A monotonically increasing configuration revision
|
||||
- Previous configuration revisions
|
||||
- The administrator account catalog and password hashes
|
||||
- Persistent administrative audit events
|
||||
- Database/schema migration state
|
||||
|
||||
The configuration schema must explicitly describe every supported field. A validation library should be used rather than assembling an ad hoc validator by hand.
|
||||
|
||||
Current configuration areas to migrate include:
|
||||
|
||||
- Server timezone and public instance identity
|
||||
- Administrator accounts and Discord identities
|
||||
- Inter-instance directories and profile
|
||||
- LLM commentary and Overseer Control
|
||||
- Barcode games
|
||||
- Media and WebRTC ICE candidates
|
||||
- Bandwidth-saving policy
|
||||
- Audio forwarding and global audio levels
|
||||
- Home Assistant, Neato, lift, entities, and button mappings
|
||||
- Room cameras and PTZ camera
|
||||
- Kinect and Balance Board
|
||||
- Button box and barcode scanner
|
||||
- Command names
|
||||
- Discord bot, channels, and roles
|
||||
- Social links and driver content
|
||||
- Fleet-report collection, retention, privacy, and delivery
|
||||
|
||||
Required behavior:
|
||||
|
||||
- A missing value receives a documented safe default.
|
||||
- Optional integrations default to disabled.
|
||||
- Unknown fields are rejected rather than silently ignored.
|
||||
- Invalid configuration never becomes the active revision.
|
||||
- A complete revision is written atomically.
|
||||
- Updates include the acting administrator and timestamp.
|
||||
- Concurrent editors use revision checking so an older browser cannot overwrite a newer change silently.
|
||||
- Secrets are never included in ordinary configuration responses, logs, diffs, or audit metadata.
|
||||
- Secret inputs support replace and clear operations without returning the current value to the browser.
|
||||
- At least one lockdown administrator must always remain.
|
||||
- An administrator cannot accidentally remove the only account capable of repairing administration.
|
||||
|
||||
Configuration changes use one intentionally simple application rule:
|
||||
|
||||
1. Validate the complete proposed document.
|
||||
2. Commit it as a new database revision.
|
||||
3. Atomically replace the process-wide configuration snapshot.
|
||||
4. Compare the old and new top-level sections.
|
||||
5. Reload every service that owns a changed section, replacing its complete internal runtime when necessary.
|
||||
6. Report per-service application failures without preventing unrelated services from applying the revision.
|
||||
7. Refresh sessions only after all affected service reloads finish.
|
||||
|
||||
HTTP routes, Socket.IO connection handlers, and process signal handlers are registered once. They consult live state or delegate to the current service runtime, preventing duplicate listeners after repeated saves. Service reloads may reconnect an integration or restart an application-owned child such as MediaMTX, ffmpeg, Kinect, or the Balance Board worker, but never restart the Node application.
|
||||
|
||||
Operational actions such as changing server mode, locking a rover, or issuing a rover command remain direct live actions rather than configuration edits.
|
||||
|
||||
After migration is complete:
|
||||
|
||||
- Remove the YAML configuration loader.
|
||||
- Remove `SERVER_CONFIG`.
|
||||
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
|
||||
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
|
||||
|
||||
## 3. Add explicit configuration-file upload to setup and administration
|
||||
|
||||
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. The protected Configuration page may later replace only the configuration from another explicitly selected legacy file. Neither path is a startup loader, installer migration, command-line workflow, or permanent second source of truth.
|
||||
|
||||
Every upload must:
|
||||
|
||||
- Accept only an explicitly selected YAML file from `/setup` or the protected Configuration page.
|
||||
- Parse the complete document.
|
||||
- Map every recognized field into the new configuration schema.
|
||||
- Preserve secrets without printing them.
|
||||
- Apply current defaults for absent fields.
|
||||
- Ignore fields that do not exist in the current schema, while reporting invalid values supplied for current fields.
|
||||
- Validate the entire result before writing anything.
|
||||
- Record the uploaded filename without storing secret values in the audit event.
|
||||
|
||||
The setup upload additionally must:
|
||||
|
||||
- Require the one-time setup code before processing it.
|
||||
- Preserve existing bcrypt administrator password hashes, lockdown roles, and Discord IDs.
|
||||
- Refuse to replace an already-configured database.
|
||||
- Write the configuration, administrators, and audit event atomically.
|
||||
|
||||
The initialized-server upload additionally must:
|
||||
|
||||
- Require a lockdown administrator with recent password confirmation.
|
||||
- Use optimistic revision checking so it cannot overwrite an intervening edit.
|
||||
- Ignore the entire legacy `admins` collection and leave all current accounts unchanged.
|
||||
- Preserve stored secrets omitted from the file, replace supplied secrets, and clear explicitly empty secrets.
|
||||
- Commit through the normal revision path and immediately reload affected services.
|
||||
|
||||
The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes.
|
||||
|
||||
## 4. Add first-run setup
|
||||
|
||||
The server must boot safely with an empty data directory and without any YAML file.
|
||||
|
||||
Required flow:
|
||||
|
||||
1. Initialize the databases and safe default configuration.
|
||||
2. Keep all optional external integrations disabled.
|
||||
3. Generate a one-time setup code in `data/setup-code.txt` with owner-only permissions. Logs report the file location but never the credential.
|
||||
4. Serve a restricted `/setup` application.
|
||||
5. Require the setup code before creating the first lockdown administrator.
|
||||
6. Offer manual YAML configuration-file upload as an alternative to creating the first administrator from scratch.
|
||||
7. Otherwise collect only the minimum information needed to establish the instance.
|
||||
8. Permanently disable setup after the first lockdown administrator exists.
|
||||
|
||||
A recovery command must be available for resetting or creating a lockdown administrator from the server console. Environment variables must not act as a recurring authentication bypass on every boot.
|
||||
|
||||
## 5. Build the centralized admin application
|
||||
|
||||
Create a dedicated `/admin` route instead of continuing to expand the existing driver-page admin panel.
|
||||
|
||||
The application should provide these top-level destinations:
|
||||
|
||||
- Overview and service health
|
||||
- Fleet and rover operations
|
||||
- Users, administrators, verification, and permissions
|
||||
- Configuration, presented as one hierarchical page
|
||||
|
||||
Overview may include application logs, persistent audit history, configuration revisions, backup and restore, and system restart or later container-update state. These operational views do not divide the configuration document into categories.
|
||||
|
||||
Existing components and server operations should be moved or reused rather than duplicated. The identity database page and other isolated administrative pages should become destinations within this centralized application where doing so preserves their existing behavior.
|
||||
|
||||
Authorization rules:
|
||||
|
||||
- Normal administrators may perform routine fleet operations.
|
||||
- Lockdown administrators manage accounts, secrets, server configuration, backup restoration, and other destructive operations.
|
||||
- Sensitive changes require recent password confirmation.
|
||||
- Server-side authorization remains authoritative for every operation; hiding a control in React is not an access check.
|
||||
|
||||
Configuration uses one schema-generated typed form rather than a raw YAML or JSON text editor. Repeatable values such as cameras, entities, links, and buttons receive the form library's generic add, remove, and reorder workflow.
|
||||
|
||||
## 6. Standardize application restart
|
||||
|
||||
Replace the current host reboot operation with one deployment-neutral **Restart application** operation.
|
||||
|
||||
The restart operation must:
|
||||
|
||||
1. Require a lockdown administrator and recent password confirmation.
|
||||
2. Reject a second request while one is already pending.
|
||||
3. Persist an audit event, acknowledge the requester, and notify connected browsers.
|
||||
4. Stop accepting new HTTP connections and send SIGTERM to the Node process after a short acknowledgement delay.
|
||||
5. Reuse the cleanup hooks already owned by MediaMTX, ffmpeg, Kinect, Balance Board, and other child-process services.
|
||||
6. Exit normally and rely on the process supervisor to start the application again.
|
||||
|
||||
During Phase 1, systemd uses `Restart=always`. During Phase 2, the container uses a restart policy such as `unless-stopped`. An explicit operator `systemctl stop` or container stop remains stopped; only a process exit is restarted. The browser shows the announced reconnect state and reloads the active administration snapshot after Socket.IO reconnects.
|
||||
|
||||
Host rebooting is a separate privilege and is not part of this application contract. The server never invokes `systemctl reboot`.
|
||||
|
||||
## 7. Implement complete backup and restore
|
||||
|
||||
Everything durable living under one data directory makes the backup boundary simple. Backup and restore remain together under one `backupRestoreService`; there is no generic maintenance framework and no command-line workflow.
|
||||
|
||||
### Full backup
|
||||
|
||||
The primary admin action is **Download full backup**. A full backup includes the entire durable data payload:
|
||||
|
||||
- Configuration and secrets
|
||||
- Administrator accounts
|
||||
- Identity and permissions
|
||||
- Fleet history
|
||||
- Persistent service state
|
||||
- Snapshots and replay media
|
||||
- Generated and cached files that are part of the current server state
|
||||
- A manifest describing the application and schema versions
|
||||
|
||||
The backup operation must:
|
||||
|
||||
1. Require a lockdown administrator and recent password confirmation.
|
||||
2. Leave every service and writer running.
|
||||
3. Create consistent SQLite snapshots using SQLite's online backup support rather than copying active WAL files.
|
||||
4. Copy non-database durable files and verify their size and modification time before and after each copy, retrying a file that changed during the copy.
|
||||
5. Exclude `runtime/`, backup/restore staging, SQLite WAL/SHM files, and incomplete files that never become stable during bounded retries.
|
||||
6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums.
|
||||
7. Stream the completed archive to the authorized browser and remove temporary staging afterward.
|
||||
|
||||
The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory.
|
||||
|
||||
`runtime/` is inside the filesystem boundary but is not durable backup content. Audio FIFOs, incomplete uploads, and in-progress replay builds have no restore value and are excluded without stopping their owners. The initial implementation provides only the authoritative full backup.
|
||||
|
||||
### Restore
|
||||
|
||||
Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation.
|
||||
|
||||
The restore operation must:
|
||||
|
||||
1. Require a lockdown administrator and recent password confirmation.
|
||||
2. Upload the archive into bounded staging controlled by the data directory.
|
||||
3. Enforce an upload-size limit that is appropriate for full media-inclusive backups.
|
||||
4. Reject absolute paths, `..` traversal, symlinks, device files, and unexpected archive structures.
|
||||
5. Validate the manifest and every checksum before altering active data.
|
||||
6. Check that the backup version has a supported forward migration path.
|
||||
7. Display exactly what will be replaced.
|
||||
8. Require a final explicit confirmation.
|
||||
9. Record a pending-restore marker.
|
||||
10. Request the normal application restart.
|
||||
11. Apply the restore before ordinary services open their databases on the next start.
|
||||
12. Run database migrations against the restored data when necessary.
|
||||
13. Start the application and verify its health.
|
||||
|
||||
The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation.
|
||||
|
||||
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally. Backup and restore exist only in the protected admin application.
|
||||
|
||||
## 8. Internalize MediaMTX WHEP signaling
|
||||
|
||||
The current external proxy maps public `/video/<path>` requests to MediaMTX after stripping `/video`. Node should own that mapping directly.
|
||||
|
||||
The final request path is:
|
||||
|
||||
```text
|
||||
Browser: /video/<stream>/whep
|
||||
Node: strips /video and streams the request internally
|
||||
MediaMTX: /<stream>/whep on 127.0.0.1:8889
|
||||
```
|
||||
|
||||
Required work:
|
||||
|
||||
- Add a maintained HTTP proxy library rather than manually reproducing proxy semantics.
|
||||
- Register the media proxy early enough that request bodies remain unmodified.
|
||||
- Stream request and response bodies without buffering.
|
||||
- Forward `POST`, `PATCH`, `DELETE`, authorization, content type, forwarded protocol, and relevant WHEP response headers.
|
||||
- Apply bounded but media-appropriate proxy timeouts.
|
||||
- Bind MediaMTX's WHEP listener to loopback.
|
||||
- Generate browser WHEP URLs relative to the current site origin.
|
||||
- Remove `media.whepBaseUrl` from configuration.
|
||||
- Keep public and LAN ICE candidate hostnames as validated admin configuration.
|
||||
- Test the exact `/video` prefix removal.
|
||||
- Confirm that MediaMTX's internal HTTP authorization callback still reaches Node.
|
||||
|
||||
The actual WebRTC media does not pass through this HTTP proxy. MediaMTX's ICE TCP/UDP port must remain reachable by browsers.
|
||||
|
||||
Afterward, the public TLS proxy sends all paths for the site to Node and no longer needs a separate MediaMTX `/video` upstream.
|
||||
|
||||
## 9. Phase 1 verification and completion gate
|
||||
|
||||
Phase 1 is complete only when all of the following are true:
|
||||
|
||||
- The current systemd installation runs without `config.yaml`.
|
||||
- A completely empty data directory can be initialized through `/setup`.
|
||||
- An explicitly selected YAML file can initialize the empty database exactly once.
|
||||
- The setup upload ignores nonexistent fields and reports invalid values supplied for current fields.
|
||||
- Startup and installation do not search for or modify an old `config.yaml`.
|
||||
- All mutable server state is contained by the configured data directory.
|
||||
- A complete backup can be downloaded and validated.
|
||||
- A restore replaces the server state only after validation and survives restart.
|
||||
- Failed restore validation leaves the current server unchanged.
|
||||
- Configuration, administrator accounts, and secrets survive restart.
|
||||
- The final lockdown administrator cannot be removed accidentally.
|
||||
- All administrative surfaces are available through `/admin` with server-side authorization.
|
||||
- Configuration changes create auditable revisions and apply to the running services without an application restart.
|
||||
- `/video` works through Node without a special public proxy rule for MediaMTX.
|
||||
- Rover sockets, RTSP publishing, WHEP playback, snapshots, replays, PTZ, Discord, Home Assistant, Kinect, Balance Board, and reporting retain their intended behavior when enabled.
|
||||
|
||||
### Filesystem boundary implementation notes
|
||||
|
||||
Implemented on 2026-09-13:
|
||||
|
||||
- Removed the obsolete `server/src/data` fallback so there is one default data root.
|
||||
- Added a shared rover-snapshot directory resolver beneath `SERVER_DATA_DIR`.
|
||||
- Converted rover snapshot polling, PTZ snapshot reads, and health reporting to that resolver.
|
||||
- Made the MediaMTX supervisor pass its resolved `SERVER_DATA_DIR` to runOnReady hooks.
|
||||
- Converted the snapshot writer to require that data root and write to `rover-snapshots` beneath it.
|
||||
- Removed the separate snapshot and replay-segment locations from the systemd unit generated by the installer.
|
||||
- Made the installer create and own the canonical data and snapshot directories.
|
||||
- Preserved the existing flat data layout; established SQLite, JSON, replay, cache, and generated MediaMTX paths were already within the boundary.
|
||||
- Moved audio-forward FIFOs/uploads and both replay-rendering workspaces from the host temporary directory to `data/runtime`.
|
||||
- Removed the configurable audio-forward runtime path so configuration cannot direct application writes outside `SERVER_DATA_DIR`.
|
||||
- Kept prompts, public assets, helper binaries, native workers, and TTS assets with the packaged application because they are read-only application material.
|
||||
- Left any old `/var/lib/rover-snapshots` and `/var/lib/replay-segments` directories untouched but reported during installation. Nothing reads or writes them after the upgraded service starts, and the operator can remove them after verifying the new paths on the actual server.
|
||||
|
||||
Local verification completed:
|
||||
|
||||
- Data-path helper tests passed for the default root and an overridden temporary root.
|
||||
- MediaMTX supervisor testing confirmed that the resolved root reaches child hooks.
|
||||
- The snapshot writer created `rover-snapshots` beneath a temporary data root and failed closed when no data root was supplied.
|
||||
- All 91 server tests passed, including the new data-path and MediaMTX supervisor coverage.
|
||||
- Installer and snapshot-writer shell syntax checks passed.
|
||||
- Source inventory found no remaining application runtime use of the operating system temporary directory, the old snapshot/replay environment variables, or `/var/lib` paths; only tests use OS temporary directories and the installer retains a deliberate legacy-directory notice.
|
||||
- Real snapshot generation and legacy-directory cleanup still require verification on the actual server during deployment.
|
||||
|
||||
### Configuration and administration implementation notes
|
||||
|
||||
Implemented on 2026-09-14:
|
||||
|
||||
- Added one ordered, strictly validated hierarchical configuration assembled from side-effect-free definitions owned by the services that consume each value.
|
||||
- Added immutable SQLite configuration revisions, active-revision tracking, administrator accounts, schema migrations, and persistent administrative audit events under the shared data directory.
|
||||
- Added full-document saves with optimistic revision checking. A stale browser cannot overwrite a newer revision, and invalid or unknown fields cannot become active.
|
||||
- Redacted secrets from browser responses and audit data. The one complete save operation preserves stored secrets unless the administrator explicitly replaces or clears them.
|
||||
- Converted every runtime configuration consumer to the synchronous database-backed configuration service and removed the YAML loader, `SERVER_CONFIG`, and the tracked example YAML.
|
||||
- Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer.
|
||||
- Added the same explicit legacy YAML picker to the protected Configuration page for replacing an initialized server's configuration. It requires recent lockdown-password confirmation, ignores every YAML administrator entry, filters nonexistent settings, validates current fields, preserves omitted secrets, applies explicitly supplied or empty secrets, uses optimistic revision checking, records the selected filename in audit history, and reloads affected services immediately.
|
||||
- The one-time setup upload now passes its committed configuration through the same live-application coordinator, so a fresh installation does not need an immediate restart after importing YAML.
|
||||
- Made setup-file import recursively retain only fields present in the current schema. Stale keys from the permissive YAML era are ignored without aliases or historical translations, while invalid values for real current settings still fail validation; stream-only and snapshot-only room-camera entries remain accepted as they were by the runtime.
|
||||
- Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes.
|
||||
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
|
||||
- Replaced every feature-specific configuration form with `@rjsf/core`; the protected admin snapshot supplies the server's assembled schema, and one generic widget handles all schema-declared secrets.
|
||||
- Replaced RJSF's unthemed Bootstrap markup with a generic MultiRover tree renderer. The complete document now follows schema order as indented object, array, item, and key/value rows; array controls remain readable text beside each item, and the route-specific styling lives outside the global stylesheet.
|
||||
- Replaced the editor's custom section borders, header backgrounds, and indentation guides with the application's shared `CardFrame` at every object, array, and array-item layer. Scalar settings remain compact key/value rows, descriptions use the wider value column, and collection actions stay beside their content instead of moving to the far edge.
|
||||
- Disabled RJSF's internal checkbox label and description generically, leaving the shared field row as the single owner of each boolean setting's name, required marker, and description.
|
||||
- Restored the former example YAML's installation-specific values as both schema-owned input examples and the actual initial values for non-secret settings and collection shapes. The only intentionally empty defaults are the three credentials and active driver HTML; their placeholders still explain the expected input without falsely marking credentials as configured or publishing sample content.
|
||||
- Strengthened top-level hierarchy with a 1.5-rem sibling gap while retaining compact spacing within each configuration section.
|
||||
- Extended `CardFrame` with an optional explicit accent while preserving its assigned-rover default, then gave every configuration nesting level its own complete header-and-border accent. Nested CardFrames themselves now carry the YAML-like indentation, scalar contents remain aligned with their owning card, and descriptions use a larger, higher-contrast treatment.
|
||||
- Added an opt-in sticky-header behavior to the shared `CardFrame`. Top-level configuration titles use it beneath the independently sticky action toolbar while nested titles remain in normal flow to prevent overlap, and scalar key labels are now visually stronger than their descriptions.
|
||||
- Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect.
|
||||
- Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules.
|
||||
- Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads.
|
||||
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
|
||||
- Added configuration revision history, rollback, audit history, and immediate application reporting.
|
||||
- Added a serialized live-configuration coordinator and converted configurable service runtimes to apply changed sections without restarting Node. Passive policies read the current immutable snapshot; network, hardware, timer, and child-process services replace or retune their owned runtime while stable HTTP/socket handlers continue delegating to it. The admin editor reports any service-specific reload failure after the revision is safely committed.
|
||||
- Replaced the privileged host-reboot action with one lockdown-only, recently confirmed, audited application restart on the admin Overview. Node announces the restart, stops accepting new HTTP connections, and signals itself after acknowledging the browser; the existing service signal hooks clean up owned child processes, and systemd now restarts clean application exits without making `systemctl stop` ineffective.
|
||||
- Added one protected backup-and-restore service and admin page. Backups keep the application online, use SQLite's online snapshot API for all three databases, make verified stable copies of the remaining durable files, and produce a checksummed archive through a short-lived one-use download. Restore uploads are size-limited, reject unsafe archive entries, verify the complete manifest, checksums, SQLite integrity, and supported schema versions, then remain staged until explicit recent-password confirmation.
|
||||
- Restore now uses the normal application restart rather than stopping services itself. The earliest server startup swaps the validated replacement into the data directory, retains one rollback copy, and removes that copy only after the restored application reaches a stabilization point; an interrupted or failed first startup automatically puts the previous data back on the following start. Backup/restore control files and all staging remain inside `data/backup-restore`.
|
||||
- Fixed production WAL-mode snapshots creating unmanifested SQLite `-wal` and `-shm` files during schema inspection. Backup and restore validation now remove only those temporary staged sidecars before archiving or applying data, and the regression fixture uses WAL mode to match the real databases.
|
||||
- Added the early streaming `/video` middleware with `http-proxy-middleware`. Express removes the public prefix before forwarding WHEP/WHIP requests to `127.0.0.1:8889`, while root-relative MediaMTX session locations receive the prefix again so subsequent browser `PATCH` and `DELETE` requests follow the same path. MediaMTX signaling now binds to loopback; its ICE UDP/TCP listener remains directly reachable on port 8189.
|
||||
- Replaced Discord's `siteUrl`, the inter-instance profile's `publicUrl`, and media `whepBaseUrl` with one top-level `publicUrl`. A numbered internal database migration transforms every saved configuration revision before current validation, and the media section now contains only optional additional ICE hosts. WHEP and microphone WHIP URLs are fixed relative paths, so they work through the current origin without knowing its hostname.
|
||||
- Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response.
|
||||
|
||||
Local verification completed:
|
||||
|
||||
- All 119 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, setup and initialized-server YAML import safety, recursive removal of nonexistent fields, inter-instance payload generation with social links enabled, and the earlier filesystem coverage.
|
||||
- All 27 server test files passed after live application, backup/restore, the internal media proxy, and the inter-instance regression coverage were added. The media tests stream exact SDP and trickle-ICE bodies through `POST`, `PATCH`, and `DELETE`, preserve headers, verify prefix and session-location rewriting, confirm loopback-only signaling, and derive the public ICE hostname from the canonical URL. The database migration and production-style WAL backup/restore paths are also covered. Application restart was not signaled on the development machine.
|
||||
- Focused admin, route, and identity UI lint passed.
|
||||
- All 20 existing focused web UI tests passed.
|
||||
- The production web UI build completed successfully and regenerated the checked-in server assets.
|
||||
- Installer syntax and repository whitespace checks passed.
|
||||
- A local startup smoke test reached listener initialization. MediaMTX then exited because `/usr/local/bin/mediamtx` is intentionally absent on this development machine; actual enabled integrations and media remain deployment checks for the real server.
|
||||
- A second empty-data startup smoke test loaded every reloadable service and reached the HTTP listener without listener-limit warnings. A deliberately substituted failing MediaMTX executable then ended the process as expected; enabled hardware and external integrations still require verification on the actual server.
|
||||
|
||||
Testing-server verification completed:
|
||||
|
||||
- A full backup created from the running application successfully validated and restored through the admin UI after the WAL-sidecar fix.
|
||||
- WHEP video playback works when the testing server is published through an ordinary whole-application reverse proxy. No special `/video` upstream, prefix rewrite, buffering rule, or direct public MediaMTX signaling route is present, confirming that Node now owns the complete public signaling path.
|
||||
|
||||
# Phase 2: containerization and image delivery
|
||||
|
||||
Phase 2 packages the completed Phase 1 application. It must not introduce a second configuration source or a second persistent-data layout.
|
||||
|
||||
## 10. Build the production application image
|
||||
|
||||
Use a Fedora-based multi-stage build to remain close to the dependencies already installed by the server installer and to avoid Alpine/musl compatibility problems with native modules and the ChromeOS TTS library.
|
||||
|
||||
### Web UI build stage
|
||||
|
||||
- Install locked web UI dependencies with `npm ci`.
|
||||
- Run the production Vite build.
|
||||
- Copy only the built assets into the final server tree.
|
||||
|
||||
### Server dependency stage
|
||||
|
||||
- Install locked server dependencies with `npm ci --omit=dev`.
|
||||
- Supply compiler tooling only in the build stage for native Node modules.
|
||||
- Copy production dependencies into the final image.
|
||||
|
||||
### Native worker stage
|
||||
|
||||
- Build the Kinect worker against libfreenect/libusb.
|
||||
- Build the Balance Board worker against wiiuse/BlueZ.
|
||||
- Build for `linux/amd64` rather than copying checked-in workstation binaries.
|
||||
|
||||
### Packaged runtime tools
|
||||
|
||||
At image-build time:
|
||||
|
||||
- Download pinned MediaMTX and neolink releases.
|
||||
- Verify checksums.
|
||||
- Install ffmpeg, ffprobe, TTS engines, required GStreamer libraries, and runtime native libraries.
|
||||
- Install the ChromeOS TTS library and voice data.
|
||||
- Install the TTS and snapshot helper scripts.
|
||||
- Run reasonable build-time smoke checks.
|
||||
|
||||
The actual server must never download or compile these dependencies during container startup.
|
||||
|
||||
### Final image
|
||||
|
||||
The final image should:
|
||||
|
||||
- Contain no compiler toolchain, Git checkout, development dependencies, or build cache.
|
||||
- Run Node as a dedicated non-root user.
|
||||
- Use a minimal init process to reap child processes.
|
||||
- Treat `/data` as its only persistent writable location.
|
||||
- Use `/tmp` only for disposable work.
|
||||
- Include ordinary OCI source metadata linking the image to this repository, without introducing an application version number.
|
||||
- Handle `SIGTERM` through the Phase 1 graceful shutdown coordinator.
|
||||
|
||||
### Production image implementation notes
|
||||
|
||||
Implemented and locally verified on 2026-09-15:
|
||||
|
||||
- Added one root multi-stage `Dockerfile` that builds the Vite application, locked production Node dependencies, Kinect worker, and Balance Board worker, then copies only their runtime outputs into a Fedora 43 image.
|
||||
- Downloaded pinned amd64 MediaMTX 1.15.3, Neolink 0.6.2, and ChromeOS Google TTS 26.5 artifacts during the build and rejected downloads that did not match their recorded SHA-256 checksums.
|
||||
- Installed the media, TTS, USB, Bluetooth, and native-worker runtime libraries without Fedora weak dependencies. This avoids pulling unrelated desktop recommendations into the headless image while retaining the libraries explicitly required by the current server installer.
|
||||
- Added a root `.dockerignore` so local dependencies, mutable server data, generated public assets, compiled host workers, logs, and Git metadata cannot leak into the image build context.
|
||||
- Configured `/data` as `SERVER_DATA_DIR`, ran Node as the dedicated uid 1000 `multirover` user, retained only the Balance Board worker's required capabilities, and used `tini` as the container init process.
|
||||
- Successfully built and loaded `multiroombarover:local` for `linux/amd64`. Its registry-style compressed content size is approximately 828 MB; Docker reports approximately 2.87 GB of local unpacked disk usage because the complete GStreamer, ffmpeg, Kinect, Node, and offline TTS runtime is intentionally included.
|
||||
- Confirmed at build time that Chrome TTS loads its packaged voice model and produces a nonempty WAV file.
|
||||
- Started the image with host networking and a temporary SELinux-relabeled `/data` bind mount. The application reached its HTTP listener, generated first-run state only inside the mount, and started the packaged MediaMTX with its generated configuration under `/data`.
|
||||
- Confirmed `/`, `/setup`, and `/admin` return the production UI; `/video/` reaches the loopback MediaMTX proxy; MediaMTX and Neolink execute; both native workers link against the runtime image; and the Balance Board worker retains only `cap_net_admin` and `cap_net_bind_service`.
|
||||
- Restarted the same container and confirmed the setup credential and configuration database were byte-for-byte unchanged, then confirmed `/admin` returned successfully again.
|
||||
- Stopped and removed the smoke-test container and deleted its temporary data. No test server process was left running on the development machine.
|
||||
|
||||
## 11. Compose deployment
|
||||
|
||||
The host-visible installation should be only:
|
||||
|
||||
```text
|
||||
multirover/
|
||||
├── compose.yaml
|
||||
└── data/
|
||||
```
|
||||
|
||||
The Compose project contains:
|
||||
|
||||
- The main Multirover application container
|
||||
- A small lifecycle container used for application update and restart
|
||||
|
||||
The application mounts:
|
||||
|
||||
```text
|
||||
./data:/data
|
||||
```
|
||||
|
||||
Host networking is the initial preferred design because it most closely preserves current rover RTSP, WebRTC ICE, UDP media, camera, and LAN integration behavior. The exact listeners must be audited before finalizing the Compose file.
|
||||
|
||||
Expected externally relevant listeners are:
|
||||
|
||||
- Node HTTP, Socket.IO, and proxied WHEP signaling on TCP 8080
|
||||
- Rover RTSP publishing on TCP 8554
|
||||
- WebRTC media on TCP and UDP 8189
|
||||
|
||||
MediaMTX WHEP on 8889, API/metrics listeners, and server-local SRT should stay on loopback unless an identified remote consumer requires otherwise.
|
||||
|
||||
### Compose implementation notes
|
||||
|
||||
Implemented and locally verified on 2026-09-15:
|
||||
|
||||
- Added one root `compose.yaml` containing only the main application. It uses `ghcr.io/legop3/multiroombarover:latest`, host networking, `restart: unless-stopped`, and the single `./data:/data` persistent bind mount. The lifecycle service remains a later, separate step rather than a placeholder in the initial deployment.
|
||||
- Added the Compose-mounted root `data` directory to `.dockerignore`, alongside the legacy `server/data`, so credentials, databases, recordings, backups, and generated state cannot enter later image builds.
|
||||
- Started the exact Compose definition from an empty root data directory using the locally built image tagged with the final GHCR name. The application created its configuration database, setup credential, and generated MediaMTX configuration only under that mount.
|
||||
- Confirmed the production UI responds on `/`, `/setup`, and `/admin`. A request to `/video/` reached the internal MediaMTX proxy and received MediaMTX's expected not-found response because the empty configuration had no requested stream.
|
||||
- Restarted through Compose and confirmed the setup credential and configuration database remained byte-for-byte unchanged. A separate marker created through `/data` also remained present after restart.
|
||||
|
||||
## 12. Hardware access with minimal host setup
|
||||
|
||||
The host must still provide the kernel and system services that containers cannot safely configure for themselves.
|
||||
|
||||
Kinect requirements:
|
||||
|
||||
- One host udev rule granting the intended device access
|
||||
- The necessary USB device mount, likely `/dev/bus/usb` because Kinect device numbering can change
|
||||
- libfreenect and the worker inside the image
|
||||
|
||||
Balance Board requirements:
|
||||
|
||||
- Host Bluetooth daemon configuration required by the existing raw HID design
|
||||
- Access to the host BlueZ D-Bus socket
|
||||
- Only the network capabilities required by the native worker
|
||||
- No fully privileged main application container
|
||||
|
||||
The exact capabilities and device permissions must be proven on the real server hardware. This development machine is not the actual server and cannot complete that validation.
|
||||
|
||||
### Hardware-access implementation notes
|
||||
|
||||
Implemented and locally verified as far as this development host permits on 2026-09-15:
|
||||
|
||||
- Added the BlueZ command-line package to the runtime image because the Balance Board service commissions devices through `bluetoothctl`; the rebuilt image reports BlueZ 5.87.
|
||||
- Exposed `/dev/bus/usb` so reconnecting Kinect devices do not depend on a temporary bus/device number, and granted only `NET_ADMIN` for the Balance Board worker rather than using privileged mode.
|
||||
- Mounted only the host system D-Bus socket for BlueZ access. Docker's per-container SELinux label is disabled because Fedora blocks access to the shared host socket and USB device nodes otherwise, while relabeling the system socket would affect the host; the process remains non-root and Docker's namespace, capability, and seccomp isolation remain active.
|
||||
- Confirmed the container can open the mounted system D-Bus socket and that its native Balance Board worker retains only its existing file capabilities. The development host's Bluetooth daemon is inactive and no production Kinect or Balance Board is attached, so real discovery, reconnect, and streaming remain part of the actual-server validation.
|
||||
|
||||
The host should not need Node, npm, MediaMTX, ffmpeg, neolink, application source, or a Multirover systemd unit after cutover.
|
||||
|
||||
## 13. Health checks
|
||||
|
||||
Add an internal health endpoint that verifies:
|
||||
|
||||
- Node is accepting requests.
|
||||
- Configuration initialization and migrations succeeded.
|
||||
- The data directory is readable and writable.
|
||||
- Required SQLite databases are usable.
|
||||
- MediaMTX is running and its internal endpoint responds.
|
||||
|
||||
Optional remote integrations should report degraded status to administrators without forcing a container restart loop. Home Assistant, Discord, a camera, or an LLM server being offline does not mean the application process itself is unhealthy.
|
||||
|
||||
Compose should use the health endpoint and a restart policy suitable for unattended operation.
|
||||
|
||||
## 14. Restricted lifecycle container
|
||||
|
||||
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
|
||||
|
||||
A small lifecycle container should be the only component with Docker control. It should:
|
||||
|
||||
- Have no public network port.
|
||||
- Accept requests only through a shared Unix socket under `data/system` or another private Compose-only channel.
|
||||
- Operate only on the fixed Multirover application service.
|
||||
- Reject arbitrary command lines, service names, image names, and Compose arguments.
|
||||
- Persist update job state so it survives replacement of the application container.
|
||||
- Compare the running image's internal digest with the current `latest` digest.
|
||||
- Pull the fixed `ghcr.io/legop3/multiroombarover:latest` image.
|
||||
- Restart or recreate the application container.
|
||||
- Wait for the application health check.
|
||||
- Retain and restore the previous image when the replacement fails.
|
||||
|
||||
The `/admin` System section should expose:
|
||||
|
||||
- Whether the running application is current or an update is available
|
||||
- Check for update
|
||||
- Update and restart
|
||||
- Restart application
|
||||
- Update progress and recent output
|
||||
- Last update result
|
||||
- Rollback result
|
||||
|
||||
These operations require a lockdown administrator and recent password confirmation. The browser must expect its socket to disappear, show a reconnect state, and retrieve the persistent job result after the new application becomes healthy.
|
||||
|
||||
The Compose contract should remain stable so ordinary main-branch image updates replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
|
||||
|
||||
## 15. GHCR publishing automation
|
||||
|
||||
Add repository automation that:
|
||||
|
||||
- On pull requests, runs all required verification and proves that the production image builds without publishing it.
|
||||
- On each repository branch push, builds the production image from a clean checkout.
|
||||
- Uses the production Dockerfile as the single verification path. Its locked dependency installs, web UI production build, native worker builds, external-artifact checksum checks, and TTS smoke test must all pass before publication.
|
||||
- Builds the supported `linux/amd64` image without QEMU or a multi-architecture manifest.
|
||||
- Publishes `ghcr.io/legop3/multiroombarover:latest` from main and one sanitized branch-name tag from every other repository branch; there are no numbered, commit, stable, edge, or release tags.
|
||||
- Leaves the previously published image for that branch untouched when any required verification or build step fails.
|
||||
- Uses registry-generated digests only inside the lifecycle implementation for update comparison and rollback.
|
||||
|
||||
The deployed server pulls the prebuilt `latest` image. It does not run `git pull`, `npm install`, native compilation, or web UI compilation.
|
||||
|
||||
### GHCR automation implementation notes
|
||||
|
||||
Implemented on 2026-09-15:
|
||||
|
||||
- Added one `Container image` GitHub Actions workflow. Pull requests build the complete production Dockerfile without logging in or publishing. Main-branch pushes publish `ghcr.io/legop3/multiroombarover:latest`, while every other repository branch publishes one moving image using its sanitized branch name.
|
||||
- Used GitHub's repository-scoped token with only contents-read and packages-write permissions. No separate registry secret, release process, version calculation, QEMU setup, or custom tag-generation code is required.
|
||||
- Kept one Buildx job for all event types so pull-request verification, development branches, and main-branch publication cannot drift into different image recipes. Docker's maintained metadata action owns branch-name sanitization, and GitHub Actions layer caching avoids repeatedly downloading and rebuilding the image's large pinned media and TTS dependencies.
|
||||
- Removed the legacy package-lock ignore rules and added the server lockfile required by `npm ci` to the migration change set. Local Docker builds and clean GitHub checkouts now receive the same locked server and web UI dependency inputs instead of allowing an ignored workstation file to mask a missing build input.
|
||||
- The workflow file was parsed locally and its event, permission, architecture, tag-selection, and conditional-publish contract were checked. The first actual GHCR publication necessarily remains a GitHub-hosted verification after these changes are pushed.
|
||||
|
||||
## 16. Container cutover
|
||||
|
||||
The actual deployment migration should:
|
||||
|
||||
1. Download and validate a full Phase 1 backup.
|
||||
2. Stop and disable the legacy Multirover systemd service.
|
||||
3. Ensure no legacy MediaMTX service remains active.
|
||||
4. Place the Compose file beside the existing data directory or move that directory once while the service is stopped.
|
||||
5. Start the application and lifecycle containers.
|
||||
6. Confirm that database migrations complete.
|
||||
7. Confirm the active configuration revision and administrator access.
|
||||
8. Verify rover connectivity, media publishing, WHEP playback, replay, cameras, and enabled hardware/integrations.
|
||||
9. Exercise application restart through `/admin`.
|
||||
10. Exercise an image update and health-check result.
|
||||
11. Retain the Phase 1 backup until the container deployment has been accepted.
|
||||
|
||||
The old systemd application and the Compose application must never run concurrently because they would compete for HTTP and media ports.
|
||||
|
||||
## 17. Phase 2 completion gate
|
||||
|
||||
Containerization is complete when:
|
||||
|
||||
- A new host can start from one Compose file and an empty data directory.
|
||||
- Existing state can be restored from a Phase 1 full backup.
|
||||
- `./data:/data` is the only persistent application mount.
|
||||
- Replacing the application container preserves all state.
|
||||
- The special external `/video` MediaMTX route is unnecessary.
|
||||
- The main container has no Docker socket access and is not fully privileged.
|
||||
- Admin-triggered restart works.
|
||||
- Admin-triggered update works and persists progress across reconnection.
|
||||
- A failed image health check rolls back to the prior image.
|
||||
- The GHCR `latest` image is reproducibly built from the newest successful main-branch commit.
|
||||
- Kinect and Balance Board behavior has been verified on the actual host.
|
||||
- Node, npm, application source, and media binaries are no longer installed directly on the host.
|
||||
|
||||
# Recommended implementation order
|
||||
|
||||
Within the two hard phase boundaries, the safest order is:
|
||||
|
||||
- [x] Complete the filesystem audit and single data-directory migration.
|
||||
- [x] Add the configuration schema/database and administrator storage.
|
||||
- [x] Add first-run setup and explicit YAML configuration-file upload.
|
||||
- [x] Convert every configuration consumer and remove YAML runtime loading.
|
||||
- [x] Converge optional feature control into service-owned `enabled` switches and derive the public feature map from those definitions.
|
||||
- [x] Build the centralized admin configuration UI.
|
||||
- [x] Add persistent audit history.
|
||||
- [x] Apply every configuration revision to running services without restarting the application.
|
||||
- [x] Standardize graceful application restart.
|
||||
- [x] Implement online backup and restart-bound staged restore in one service.
|
||||
- [x] Add the internal `/video` proxy and make the special external route unnecessary.
|
||||
- [x] Run the full Phase 1 completion gate on the legacy deployment.
|
||||
- [x] Build and verify the production application image.
|
||||
- [x] Add Compose, data mounting, networking, and hardware access.
|
||||
- [x] Add GHCR build and publication automation.
|
||||
- [ ] Add the restricted lifecycle container and connect the System UI.
|
||||
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
|
||||
- [ ] Perform the final systemd-to-Compose cutover.
|
||||
|
||||
This order gives each invasive change one clear source of failures and leaves the container phase responsible for packaging and supervision rather than unfinished application architecture.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Simple spectator bot
|
||||
|
||||
A spectator bot connects to the rover server with Socket.IO. It can receive the current session, read chat, and send messages that are visually tagged as bot messages.
|
||||
|
||||
## Install
|
||||
|
||||
Create a small Node.js project and install the Socket.IO client:
|
||||
|
||||
```bash
|
||||
npm install socket.io-client
|
||||
```
|
||||
|
||||
## Example bot
|
||||
|
||||
Create `bot.js`:
|
||||
|
||||
```js
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// Replace this with the public URL of the MultiRoombaRover server.
|
||||
const socket = io('https://your-rover-server.example', {
|
||||
// Match the transports supported by the server while retaining polling as a
|
||||
// fallback for networks or proxies that do not allow WebSocket connections.
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// Socket.IO acknowledgements use callbacks. This small wrapper turns them into
|
||||
// promises so setup failures and rejected chat messages are easy to handle.
|
||||
function emitWithAck(event, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.emit(event, payload, (response = {}) => {
|
||||
if (response.error) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('connect', async () => {
|
||||
console.log('Connected:', socket.id);
|
||||
|
||||
try {
|
||||
// Set the name that will appear beside this connection and its messages.
|
||||
await emitWithAck('nickname:set', {
|
||||
nickname: 'My spectator bot',
|
||||
});
|
||||
|
||||
// Ask the server to make this passive connection a spectator. Performing
|
||||
// this after every connection also restores the role after a reconnect.
|
||||
await emitWithAck('session:setRole', {
|
||||
role: 'spectator',
|
||||
});
|
||||
|
||||
console.log('Connected as a spectator');
|
||||
} catch (error) {
|
||||
console.error('Spectator setup failed:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Each session:sync event is a complete current session snapshot. Replace any
|
||||
// previously stored session with this object instead of merging snapshots.
|
||||
socket.on('session:sync', (session) => {
|
||||
console.log('Session:', session);
|
||||
});
|
||||
|
||||
// chat:init contains the recent chat history available when the bot connects.
|
||||
socket.on('chat:init', (messages) => {
|
||||
console.log('Recent chat:', messages);
|
||||
});
|
||||
|
||||
// chat:message fires whenever a new message is broadcast, including messages
|
||||
// sent by this bot itself.
|
||||
socket.on('chat:message', (message) => {
|
||||
console.log(`${message.nickname || 'Unknown'}: ${message.text}`);
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('Disconnected:', reason);
|
||||
});
|
||||
|
||||
// Setting bot to true adds the normal bot tag to the displayed chat message.
|
||||
// It does not grant the connection any additional permissions.
|
||||
function sendBotMessage(text) {
|
||||
return emitWithAck('chat:send', {
|
||||
text,
|
||||
bot: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Send one example message after the connection has had time to finish setup.
|
||||
// A real bot would call sendBotMessage from its own message-handling logic.
|
||||
setTimeout(() => {
|
||||
sendBotMessage('Hello from my spectator bot!').catch((error) => {
|
||||
console.error('Message failed:', error.message);
|
||||
});
|
||||
}, 5000);
|
||||
```
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
node bot.js
|
||||
```
|
||||
|
||||
## Events used
|
||||
|
||||
- `nickname:set` sets the bot's visible nickname.
|
||||
- `session:setRole` changes the connection to a spectator.
|
||||
- `session:sync` provides the latest complete session state.
|
||||
- `chat:init` provides recent chat history after connecting.
|
||||
- `chat:message` provides new chat messages.
|
||||
- `chat:send` sends a chat message. Include `bot: true` to give it the bot tag.
|
||||
|
||||
The server can reject spectator access or a chat message. Always check the acknowledgement callback, as the example does, so those errors are not silently ignored.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Daniel Roberts
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,218 @@
|
||||
# RoverPeripheral
|
||||
|
||||
RoverPeripheral is an ESP32 Arduino library for MultiRoombaRover peripherals.
|
||||
The ESP32 reports its built-in rover roles and accessory controls to `roverd`
|
||||
over USB serial.
|
||||
|
||||
## PlatformIO installation
|
||||
|
||||
Classic ESP32 DevKitC-style board:
|
||||
|
||||
```ini
|
||||
[env:esp32dev]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
|
||||
lib_deps =
|
||||
legop3/RoverPeripheral @ ^2.0.0
|
||||
```
|
||||
|
||||
Native-USB ESP32-S3 DevKitC:
|
||||
|
||||
```ini
|
||||
[env:esp32-s3-devkitc-1]
|
||||
platform = espressif32
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
build_flags =
|
||||
-D ARDUINO_USB_MODE=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
|
||||
lib_deps =
|
||||
legop3/RoverPeripheral @ ^2.0.0
|
||||
```
|
||||
|
||||
## Program structure
|
||||
|
||||
Include `RoverPeripheral.h` and define `configureRoverPeripheral()`:
|
||||
|
||||
```cpp
|
||||
#include <RoverPeripheral.h>
|
||||
|
||||
void configureRoverPeripheral(RoverPeripheral& peripheral) {
|
||||
peripheral.name("Headlight controller");
|
||||
|
||||
RoverDigitalOutputConfig headlight;
|
||||
headlight.pin = 18;
|
||||
headlight.polarity = OutputPolarity::ActiveHigh;
|
||||
headlight.initiallyOn = false;
|
||||
peripheral.addHeadlight(headlight);
|
||||
}
|
||||
```
|
||||
|
||||
The library provides `setup()` and `loop()`. Do not define them in the
|
||||
peripheral program.
|
||||
|
||||
## Built-in rover roles
|
||||
|
||||
Camera tilt:
|
||||
|
||||
```cpp
|
||||
RoverCameraServoConfig cameraServo;
|
||||
cameraServo.pin = 14;
|
||||
cameraServo.minimumAngleDegrees = -15;
|
||||
cameraServo.maximumAngleDegrees = 30;
|
||||
cameraServo.homeAngleDegrees = 0;
|
||||
cameraServo.nudgeDegrees = 2;
|
||||
cameraServo.minimumPulseMicroseconds = 900;
|
||||
cameraServo.maximumPulseMicroseconds = 2100;
|
||||
cameraServo.allowRawPulse = false;
|
||||
cameraServo.inverted = false;
|
||||
peripheral.addCameraServo(cameraServo);
|
||||
```
|
||||
|
||||
Headlight or laser:
|
||||
|
||||
```cpp
|
||||
RoverDigitalOutputConfig headlight;
|
||||
headlight.pin = 18;
|
||||
headlight.polarity = OutputPolarity::ActiveHigh;
|
||||
headlight.initiallyOn = false;
|
||||
peripheral.addHeadlight(headlight);
|
||||
|
||||
RoverDigitalOutputConfig laser;
|
||||
laser.pin = 16;
|
||||
laser.polarity = OutputPolarity::ActiveHigh;
|
||||
laser.initiallyOn = false;
|
||||
peripheral.addLaser(laser);
|
||||
```
|
||||
|
||||
These registrations use the existing camera, headlight, and laser controls in
|
||||
the rover UI. They do not create accessory controls.
|
||||
|
||||
## Accessory controls
|
||||
|
||||
Controls appear in registration order. Each control name must be unique within
|
||||
the peripheral. The name is also used as the control identifier.
|
||||
|
||||
### Servo slider
|
||||
|
||||
```cpp
|
||||
SliderControlConfig position;
|
||||
position.name = "Arm position";
|
||||
position.minimum = 0;
|
||||
position.maximum = 180;
|
||||
|
||||
ServoOutput servo;
|
||||
servo.pin = 13;
|
||||
|
||||
peripheral.addSlider(position, servo);
|
||||
```
|
||||
|
||||
### PWM slider
|
||||
|
||||
```cpp
|
||||
SliderControlConfig brightness;
|
||||
brightness.name = "Light brightness";
|
||||
brightness.minimum = 0;
|
||||
brightness.maximum = 255;
|
||||
|
||||
PwmOutput light;
|
||||
light.pin = 17;
|
||||
|
||||
peripheral.addSlider(brightness, light);
|
||||
```
|
||||
|
||||
### Digital button
|
||||
|
||||
```cpp
|
||||
ButtonControlConfig workLight;
|
||||
workLight.name = "Work light";
|
||||
workLight.mode = ButtonMode::Toggle;
|
||||
|
||||
DigitalOutput light;
|
||||
light.pin = 21;
|
||||
light.polarity = OutputPolarity::ActiveHigh;
|
||||
|
||||
peripheral.addButton(workLight, light);
|
||||
```
|
||||
|
||||
### Custom slider
|
||||
|
||||
```cpp
|
||||
void setMotorSpeed(int value) {
|
||||
// Apply value to the device.
|
||||
}
|
||||
|
||||
SliderControlConfig speed;
|
||||
speed.name = "Motor speed";
|
||||
speed.minimum = 0;
|
||||
speed.maximum = 100;
|
||||
|
||||
peripheral.addSlider(speed, setMotorSpeed);
|
||||
```
|
||||
|
||||
### Custom button
|
||||
|
||||
```cpp
|
||||
void setMotorRunning(bool running) {
|
||||
// Start or stop the device.
|
||||
}
|
||||
|
||||
ButtonControlConfig motor;
|
||||
motor.name = "Motor";
|
||||
motor.mode = ButtonMode::Momentary;
|
||||
|
||||
peripheral.addButton(motor, setMotorRunning);
|
||||
```
|
||||
|
||||
A momentary bool callback receives `true` on press and `false` on release. A
|
||||
zero-argument callback can be used for a one-shot momentary action.
|
||||
|
||||
### Number input
|
||||
|
||||
```cpp
|
||||
void setRepeatCount(int value) {
|
||||
// Store or apply value.
|
||||
}
|
||||
|
||||
NumberControlConfig repeats;
|
||||
repeats.name = "Repeat count";
|
||||
repeats.minimum = 1;
|
||||
repeats.maximum = 20;
|
||||
|
||||
peripheral.addNumber(repeats, setRepeatCount);
|
||||
```
|
||||
|
||||
### Text input
|
||||
|
||||
```cpp
|
||||
void setDisplayMessage(const String& value) {
|
||||
// Store or display value.
|
||||
}
|
||||
|
||||
TextControlConfig message;
|
||||
message.name = "Display message";
|
||||
message.maximumLength = 64;
|
||||
|
||||
peripheral.addText(message, setDisplayMessage);
|
||||
```
|
||||
|
||||
## Recurring work
|
||||
|
||||
Define `updateRoverPeripheral()` when the program needs recurring non-blocking
|
||||
work:
|
||||
|
||||
```cpp
|
||||
void updateRoverPeripheral() {
|
||||
// Update a state machine or device.
|
||||
}
|
||||
```
|
||||
|
||||
Callbacks and `updateRoverPeripheral()` must not block serial processing.
|
||||
`Serial` is reserved for Firmata and must not be used for debug output.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,76 @@
|
||||
#include <RoverPeripheral.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t kActionPin = 21;
|
||||
|
||||
int repeatCount = 1;
|
||||
String displayMessage;
|
||||
|
||||
void setActionActive(bool pressed) {
|
||||
digitalWrite(kActionPin, pressed ? HIGH : LOW);
|
||||
}
|
||||
|
||||
void setRepeatCount(int value) {
|
||||
repeatCount = value;
|
||||
}
|
||||
|
||||
void setDisplayMessage(const String& value) {
|
||||
displayMessage = value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void configureRoverPeripheral(RoverPeripheral& io) {
|
||||
io.name("Complete rover peripheral");
|
||||
|
||||
RoverCameraServoConfig cameraServo;
|
||||
cameraServo.pin = 14;
|
||||
cameraServo.minimumAngleDegrees = -15;
|
||||
cameraServo.maximumAngleDegrees = 30;
|
||||
cameraServo.homeAngleDegrees = 0;
|
||||
cameraServo.nudgeDegrees = 2;
|
||||
cameraServo.minimumPulseMicroseconds = 900;
|
||||
cameraServo.maximumPulseMicroseconds = 2100;
|
||||
cameraServo.allowRawPulse = false;
|
||||
cameraServo.inverted = false;
|
||||
io.addCameraServo(cameraServo);
|
||||
|
||||
RoverDigitalOutputConfig headlight;
|
||||
headlight.pin = 18;
|
||||
headlight.polarity = OutputPolarity::ActiveHigh;
|
||||
headlight.initiallyOn = false;
|
||||
io.addHeadlight(headlight);
|
||||
|
||||
RoverDigitalOutputConfig laser;
|
||||
laser.pin = 16;
|
||||
laser.polarity = OutputPolarity::ActiveHigh;
|
||||
laser.initiallyOn = false;
|
||||
io.addLaser(laser);
|
||||
|
||||
pinMode(kActionPin, OUTPUT);
|
||||
digitalWrite(kActionPin, LOW);
|
||||
|
||||
SliderControlConfig brightness;
|
||||
brightness.name = "Light brightness";
|
||||
brightness.minimum = 0;
|
||||
brightness.maximum = 255;
|
||||
|
||||
PwmOutput brightnessOutput;
|
||||
brightnessOutput.pin = 17;
|
||||
io.addSlider(brightness, brightnessOutput);
|
||||
|
||||
ButtonControlConfig action;
|
||||
action.name = "Special action";
|
||||
action.mode = ButtonMode::Momentary;
|
||||
io.addButton(action, setActionActive);
|
||||
|
||||
NumberControlConfig repeats;
|
||||
repeats.name = "Repeat count";
|
||||
repeats.minimum = 1;
|
||||
repeats.maximum = 20;
|
||||
io.addNumber(repeats, setRepeatCount);
|
||||
|
||||
TextControlConfig message;
|
||||
message.name = "Display message";
|
||||
message.maximumLength = 64;
|
||||
io.addText(message, setDisplayMessage);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#include <RoverPeripheral.h>
|
||||
|
||||
void configureRoverPeripheral(RoverPeripheral& io) {
|
||||
io.name("Headlight controller");
|
||||
|
||||
RoverDigitalOutputConfig headlight;
|
||||
headlight.pin = 18;
|
||||
headlight.polarity = OutputPolarity::ActiveHigh;
|
||||
headlight.initiallyOn = false;
|
||||
io.addHeadlight(headlight);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json",
|
||||
"name": "RoverPeripheral",
|
||||
"version": "2.0.1",
|
||||
"description": "Create self-describing ESP32 hardware controls for MultiRoombaRover",
|
||||
"keywords": [
|
||||
"esp32",
|
||||
"firmata",
|
||||
"robotics",
|
||||
"rover"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/legop3/MultiRoombaRover.git"
|
||||
},
|
||||
"homepage": "https://github.com/legop3/MultiRoombaRover/tree/main/esp32/libraries/RoverPeripheralFirmata",
|
||||
"license": "MIT",
|
||||
"frameworks": "arduino",
|
||||
"platforms": "espressif32",
|
||||
"headers": "RoverPeripheral.h",
|
||||
"dependencies": {
|
||||
"ConfigurableFirmata": "https://github.com/firmata/ConfigurableFirmata.git#3.2.0",
|
||||
"bblanchon/ArduinoJson": "^7.4.2",
|
||||
"madhephaestus/ESP32Servo": "^3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#include "RoverPeripheral.h"
|
||||
|
||||
#include "internal/RoverPeripheralFirmata.h"
|
||||
|
||||
RoverPeripheral::RoverPeripheral()
|
||||
: implementation_(new RoverPeripheralFirmata("Rover peripheral")) {}
|
||||
|
||||
RoverPeripheral::~RoverPeripheral() {
|
||||
delete implementation_;
|
||||
}
|
||||
|
||||
void RoverPeripheral::name(const String& peripheralName) {
|
||||
implementation_->setName(peripheralName);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addCameraServo(const RoverCameraServoConfig& config) {
|
||||
implementation_->addRoverCameraServo(config);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addHeadlight(const RoverDigitalOutputConfig& config) {
|
||||
implementation_->addRoverHeadlight(config);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addLaser(const RoverDigitalOutputConfig& config) {
|
||||
implementation_->addRoverLaser(config);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addSlider(const SliderControlConfig& config, const ServoOutput& output) {
|
||||
implementation_->addServoSlider(config, output);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addSlider(const SliderControlConfig& config, const PwmOutput& output) {
|
||||
implementation_->addPwmSlider(config, output);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addButton(const ButtonControlConfig& config, const DigitalOutput& output) {
|
||||
implementation_->addDigitalButton(config, output);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addSlider(const SliderControlConfig& config, SliderCallback callback) {
|
||||
implementation_->addSlider(config, callback);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
|
||||
implementation_->addButton(config, callback);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addButton(const ButtonControlConfig& config, ActionCallback callback) {
|
||||
// One-shot callbacks apply only to momentary buttons. A toggle requires the
|
||||
// bool callback overload because application code must receive its new state.
|
||||
if (config.mode != ButtonMode::Momentary) {
|
||||
abort();
|
||||
}
|
||||
implementation_->addButton(
|
||||
config,
|
||||
[callback](bool pressed) {
|
||||
if (pressed && callback) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addNumber(const NumberControlConfig& config, NumberCallback callback) {
|
||||
implementation_->addNumber(config, callback);
|
||||
}
|
||||
|
||||
void RoverPeripheral::addText(const TextControlConfig& config, TextCallback callback) {
|
||||
implementation_->addText(config, callback);
|
||||
}
|
||||
|
||||
void RoverPeripheral::begin(FirmataExt& extension) {
|
||||
implementation_->begin(extension);
|
||||
}
|
||||
|
||||
void RoverPeripheral::update() {
|
||||
implementation_->update();
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
/** Describes whether a logical on value drives an output pin high or low. */
|
||||
enum class OutputPolarity {
|
||||
ActiveHigh,
|
||||
ActiveLow,
|
||||
};
|
||||
|
||||
/** Selects whether a button retains its state or is active only while held. */
|
||||
enum class ButtonMode {
|
||||
Toggle,
|
||||
Momentary,
|
||||
};
|
||||
|
||||
/** Configuration for the rover's existing camera-tilt control. */
|
||||
struct RoverCameraServoConfig {
|
||||
uint8_t pin = 0;
|
||||
float minimumAngleDegrees = -15;
|
||||
float maximumAngleDegrees = 30;
|
||||
float homeAngleDegrees = 0;
|
||||
float nudgeDegrees = 2;
|
||||
uint16_t minimumPulseMicroseconds = 900;
|
||||
uint16_t maximumPulseMicroseconds = 2100;
|
||||
bool allowRawPulse = false;
|
||||
bool inverted = false;
|
||||
};
|
||||
|
||||
/** Configuration for the rover's existing headlight or laser control. */
|
||||
struct RoverDigitalOutputConfig {
|
||||
uint8_t pin = 0;
|
||||
OutputPolarity polarity = OutputPolarity::ActiveHigh;
|
||||
bool initiallyOn = false;
|
||||
};
|
||||
|
||||
/** Shared display and range settings for a slider control. */
|
||||
struct SliderControlConfig {
|
||||
String name;
|
||||
int minimum = 0;
|
||||
int maximum = 100;
|
||||
};
|
||||
|
||||
/** Shared display and interaction settings for a button control. */
|
||||
struct ButtonControlConfig {
|
||||
String name;
|
||||
ButtonMode mode = ButtonMode::Momentary;
|
||||
};
|
||||
|
||||
/** Shared display and range settings for a number input. */
|
||||
struct NumberControlConfig {
|
||||
String name;
|
||||
int minimum = 0;
|
||||
int maximum = 100;
|
||||
};
|
||||
|
||||
/** Shared display and length settings for a text input. */
|
||||
struct TextControlConfig {
|
||||
String name;
|
||||
size_t maximumLength = 32;
|
||||
};
|
||||
|
||||
/** Selects a standard Firmata servo as the destination for a slider. */
|
||||
struct ServoOutput {
|
||||
uint8_t pin = 0;
|
||||
};
|
||||
|
||||
/** Selects an ESP32 PWM pin as the destination for a slider. */
|
||||
struct PwmOutput {
|
||||
uint8_t pin = 0;
|
||||
};
|
||||
|
||||
/** Selects an ESP32 digital pin as the destination for a button. */
|
||||
struct DigitalOutput {
|
||||
uint8_t pin = 0;
|
||||
OutputPolarity polarity = OutputPolarity::ActiveHigh;
|
||||
};
|
||||
|
||||
using SliderCallback = std::function<void(int)>;
|
||||
using ButtonCallback = std::function<void(bool)>;
|
||||
using ActionCallback = std::function<void()>;
|
||||
using NumberCallback = std::function<void(int)>;
|
||||
using TextCallback = std::function<void(const String&)>;
|
||||
|
||||
class FirmataExt;
|
||||
class RoverPeripheralFirmata;
|
||||
|
||||
/**
|
||||
* Registration API for a self-describing rover peripheral.
|
||||
*
|
||||
* A sketch constructs each configuration one field at a time and registers it
|
||||
* in configureRoverPeripheral(). Serial and protocol setup stay in the library.
|
||||
*/
|
||||
class RoverPeripheral {
|
||||
public:
|
||||
RoverPeripheral();
|
||||
~RoverPeripheral();
|
||||
|
||||
RoverPeripheral(const RoverPeripheral&) = delete;
|
||||
RoverPeripheral& operator=(const RoverPeripheral&) = delete;
|
||||
|
||||
/** Sets the peripheral name shown above its accessory controls. */
|
||||
void name(const String& peripheralName);
|
||||
|
||||
/** Registers the rover's existing camera-tilt control. */
|
||||
void addCameraServo(const RoverCameraServoConfig& config);
|
||||
|
||||
/** Registers the rover's existing headlight control. */
|
||||
void addHeadlight(const RoverDigitalOutputConfig& config);
|
||||
|
||||
/** Registers the rover's existing laser control. */
|
||||
void addLaser(const RoverDigitalOutputConfig& config);
|
||||
|
||||
/** Registers a slider backed by a standard Firmata servo output. */
|
||||
void addSlider(const SliderControlConfig& config, const ServoOutput& output);
|
||||
|
||||
/** Registers a slider backed by an ESP32 PWM output. */
|
||||
void addSlider(const SliderControlConfig& config, const PwmOutput& output);
|
||||
|
||||
/** Registers a button backed by an ESP32 digital output. */
|
||||
void addButton(const ButtonControlConfig& config, const DigitalOutput& output);
|
||||
|
||||
/** Registers a slider handled by application code. */
|
||||
void addSlider(const SliderControlConfig& config, SliderCallback callback);
|
||||
|
||||
/** Registers a button whose callback receives its logical state. */
|
||||
void addButton(const ButtonControlConfig& config, ButtonCallback callback);
|
||||
|
||||
/** Registers a momentary button whose callback runs only on press. */
|
||||
void addButton(const ButtonControlConfig& config, ActionCallback callback);
|
||||
|
||||
/** Registers a number input handled by application code. */
|
||||
void addNumber(const NumberControlConfig& config, NumberCallback callback);
|
||||
|
||||
/** Registers a text input handled by application code. */
|
||||
void addText(const TextControlConfig& config, TextCallback callback);
|
||||
|
||||
private:
|
||||
// The implementation is opaque so importing this header does not expose any
|
||||
// Firmata types or require firmware authors to understand the wire protocol.
|
||||
RoverPeripheralFirmata* implementation_;
|
||||
|
||||
void begin(FirmataExt& extension);
|
||||
void update();
|
||||
|
||||
friend void setup();
|
||||
friend void loop();
|
||||
};
|
||||
|
||||
/** Called once by the library after Arduino and Serial initialization. */
|
||||
void configureRoverPeripheral(RoverPeripheral& peripheral);
|
||||
|
||||
/** Optional non-blocking hook for recurring application work. */
|
||||
void updateRoverPeripheral();
|
||||
@@ -0,0 +1,46 @@
|
||||
#include "RoverPeripheral.h"
|
||||
|
||||
#include <ConfigurableFirmata.h>
|
||||
#include <FirmataExt.h>
|
||||
|
||||
namespace {
|
||||
FirmataExt firmataExtension;
|
||||
RoverPeripheral peripheral;
|
||||
} // namespace
|
||||
|
||||
// A weak no-op preserves the zero-boilerplate case while allowing a sketch to
|
||||
// define the same function when animations or state machines need regular work.
|
||||
void __attribute__((weak)) updateRoverPeripheral() {}
|
||||
|
||||
void setup() {
|
||||
// The public configuration hook runs after Arduino initialization, allowing
|
||||
// peripheral code to safely use pinMode() and initialize third-party devices.
|
||||
Serial.begin(115200);
|
||||
configureRoverPeripheral(peripheral);
|
||||
|
||||
// ConfigurableFirmata batches reads on ESP32-class boards. Arduino's default
|
||||
// one-second Stream timeout would delay short commands while waiting for the
|
||||
// batch buffer to fill, so consume only bytes that have already arrived.
|
||||
Serial.setTimeout(0);
|
||||
Firmata.begin(Serial);
|
||||
peripheral.begin(firmataExtension);
|
||||
|
||||
// Applying a normal Firmata reset after registration establishes every
|
||||
// declared initial output and makes the first host connection deterministic.
|
||||
Firmata.parse(SYSTEM_RESET);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// ConfigurableFirmata retains partial parser state between iterations. Stop
|
||||
// after each complete message so user update work cannot be starved by a
|
||||
// sustained burst, while ordinary short commands are still drained at once.
|
||||
while (Firmata.available()) {
|
||||
Firmata.processInput();
|
||||
if (!Firmata.isParsingMessage()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
peripheral.update();
|
||||
updateRoverPeripheral();
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
#include "RoverPeripheralFirmata.h"
|
||||
|
||||
namespace {
|
||||
constexpr byte kPeripheralFeature = 0x01;
|
||||
constexpr byte kDescribeOperation = 0x00;
|
||||
constexpr byte kDescriptionOperation = 0x01;
|
||||
constexpr byte kControlOperation = 0x02;
|
||||
|
||||
const char* buttonModeName(ButtonMode mode) {
|
||||
return mode == ButtonMode::Toggle ? "toggle" : "momentary";
|
||||
}
|
||||
|
||||
const char* outputTypeName(uint8_t value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return "servo";
|
||||
case 1:
|
||||
return "pwm";
|
||||
case 2:
|
||||
return "digital";
|
||||
default:
|
||||
return "custom";
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
RoverPeripheralFirmata* RoverPeripheralFirmata::instance_ = nullptr;
|
||||
|
||||
RoverPeripheralFirmata::RoverPeripheralFirmata(const String& name) : name_(name) {}
|
||||
|
||||
void RoverPeripheralFirmata::setName(const String& name) {
|
||||
if (name.length() == 0) {
|
||||
// A blank heading makes multiple attached peripherals impossible to
|
||||
// distinguish. Treat it as a firmware-authoring error at startup rather
|
||||
// than advertising ambiguous controls to the rover.
|
||||
abort();
|
||||
}
|
||||
name_ = name;
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::validateControlName(const String& name) const {
|
||||
if (name.length() == 0) {
|
||||
// Registration errors are programmer errors discovered during setup. A
|
||||
// hard stop is preferable to advertising a partially usable device whose
|
||||
// behavior depends on which malformed control the driver touches first.
|
||||
abort();
|
||||
}
|
||||
for (const ControlRegistration& existing : controls_) {
|
||||
if (existing.id == name) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::validateRange(const String& name, int minimum, int maximum) const {
|
||||
if (name.length() == 0 || minimum > maximum) {
|
||||
abort();
|
||||
}
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, const ServoOutput& output) {
|
||||
validateControlName(config.name);
|
||||
validateRange(config.name, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.name;
|
||||
control.name = config.name;
|
||||
control.type = ControlType::Slider;
|
||||
control.output = OutputType::Servo;
|
||||
control.minimum = config.minimum;
|
||||
control.maximum = config.maximum;
|
||||
control.pin = output.pin;
|
||||
controls_.push_back(control);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addPwmSlider(const SliderControlConfig& config, const PwmOutput& output) {
|
||||
validateControlName(config.name);
|
||||
validateRange(config.name, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.name;
|
||||
control.name = config.name;
|
||||
control.type = ControlType::Slider;
|
||||
control.output = OutputType::Pwm;
|
||||
control.minimum = config.minimum;
|
||||
control.maximum = config.maximum;
|
||||
control.pin = output.pin;
|
||||
controls_.push_back(control);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output) {
|
||||
validateControlName(config.name);
|
||||
ControlRegistration control;
|
||||
control.id = config.name;
|
||||
control.name = config.name;
|
||||
control.type = ControlType::Button;
|
||||
control.output = OutputType::Digital;
|
||||
control.buttonMode = config.mode;
|
||||
control.pin = output.pin;
|
||||
control.polarity = output.polarity;
|
||||
controls_.push_back(control);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addSlider(const SliderControlConfig& config, SliderCallback callback) {
|
||||
validateControlName(config.name);
|
||||
validateRange(config.name, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.name;
|
||||
control.name = config.name;
|
||||
control.type = ControlType::Slider;
|
||||
control.output = OutputType::Custom;
|
||||
control.minimum = config.minimum;
|
||||
control.maximum = config.maximum;
|
||||
control.sliderCallback = callback;
|
||||
controls_.push_back(control);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
|
||||
validateControlName(config.name);
|
||||
ControlRegistration control;
|
||||
control.id = config.name;
|
||||
control.name = config.name;
|
||||
control.type = ControlType::Button;
|
||||
control.output = OutputType::Custom;
|
||||
control.buttonMode = config.mode;
|
||||
control.buttonCallback = callback;
|
||||
controls_.push_back(control);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addNumber(const NumberControlConfig& config, NumberCallback callback) {
|
||||
validateControlName(config.name);
|
||||
validateRange(config.name, config.minimum, config.maximum);
|
||||
ControlRegistration control;
|
||||
control.id = config.name;
|
||||
control.name = config.name;
|
||||
control.type = ControlType::Number;
|
||||
control.output = OutputType::Custom;
|
||||
control.minimum = config.minimum;
|
||||
control.maximum = config.maximum;
|
||||
control.numberCallback = callback;
|
||||
controls_.push_back(control);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addText(const TextControlConfig& config, TextCallback callback) {
|
||||
validateControlName(config.name);
|
||||
if (config.maximumLength == 0) {
|
||||
abort();
|
||||
}
|
||||
ControlRegistration control;
|
||||
control.id = config.name;
|
||||
control.name = config.name;
|
||||
control.type = ControlType::Text;
|
||||
control.output = OutputType::Custom;
|
||||
control.maximumLength = config.maximumLength;
|
||||
control.textCallback = callback;
|
||||
controls_.push_back(control);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addRoverCameraServo(const RoverCameraServoConfig& config) {
|
||||
cameraServo_ = config;
|
||||
hasCameraServo_ = true;
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addRoverHeadlight(const RoverDigitalOutputConfig& config) {
|
||||
headlight_ = config;
|
||||
hasHeadlight_ = true;
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::addRoverLaser(const RoverDigitalOutputConfig& config) {
|
||||
laser_ = config;
|
||||
hasLaser_ = true;
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::begin(FirmataExt& extension) {
|
||||
if (instance_ != nullptr && instance_ != this) {
|
||||
abort();
|
||||
}
|
||||
instance_ = this;
|
||||
extension.addFeature(*this);
|
||||
|
||||
// Discovery uses Firmata's standard REPORT_FIRMWARE query to distinguish a
|
||||
// rover peripheral from unrelated Firmata devices. The helper owns this
|
||||
// identity so every sketch gets it without repeating protocol boilerplate.
|
||||
Firmata.setFirmwareNameAndVersion("RoverPeripheralFirmata", 1, 0);
|
||||
|
||||
// SET_DIGITAL_PIN_VALUE is a fixed Firmata command rather than SysEx, so it
|
||||
// cannot travel through FirmataFeature::handleSysex. Firmata exposes one
|
||||
// callback for it and this peripheral owns the standard output implementation.
|
||||
Firmata.attach(SET_DIGITAL_PIN_VALUE, digitalPinValueCallback);
|
||||
Firmata.attach(SYSTEM_RESET, systemResetCallback);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::update() {
|
||||
// Custom callbacks execute synchronously from Firmata's parser for now. This
|
||||
// method intentionally remains available so future non-blocking peripheral
|
||||
// work can be serviced without changing the sketch's main loop shape.
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::handleCapability(byte pin) {
|
||||
if (!IS_PIN_DIGITAL(pin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The peripheral supports the output modes roverd may select. Capability
|
||||
// reporting stays standard Firmata, so the Linux probe can also inspect it
|
||||
// with any other conforming client.
|
||||
Firmata.write(PIN_MODE_OUTPUT);
|
||||
Firmata.write(1);
|
||||
if (IS_PIN_PWM(pin)) {
|
||||
Firmata.write(PIN_MODE_PWM);
|
||||
Firmata.write(DEFAULT_PWM_RESOLUTION);
|
||||
}
|
||||
Firmata.write(PIN_MODE_SERVO);
|
||||
Firmata.write(14);
|
||||
}
|
||||
|
||||
boolean RoverPeripheralFirmata::handlePinMode(byte pin, int mode) {
|
||||
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A pin can only have one active hardware generator. Detaching a previous
|
||||
// servo before switching modes prevents it from continuing to pulse after a
|
||||
// later digital or PWM configuration takes ownership of the pin.
|
||||
if (mode != PIN_MODE_SERVO) {
|
||||
detachServo(pin);
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case PIN_MODE_OUTPUT:
|
||||
pinMode(PIN_TO_DIGITAL(pin), OUTPUT);
|
||||
digitalWrite(PIN_TO_DIGITAL(pin), LOW);
|
||||
Firmata.setPinState(pin, 0);
|
||||
return true;
|
||||
case PIN_MODE_PWM:
|
||||
if (!IS_PIN_PWM(pin)) {
|
||||
return false;
|
||||
}
|
||||
pinMode(PIN_TO_PWM(pin), OUTPUT);
|
||||
analogWrite(PIN_TO_PWM(pin), 0);
|
||||
Firmata.setPinState(pin, 0);
|
||||
return true;
|
||||
case PIN_MODE_SERVO:
|
||||
attachServo(pin);
|
||||
Firmata.setPinState(pin, 0);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
boolean RoverPeripheralFirmata::handleSysex(byte command, byte argc, byte* argv) {
|
||||
if (command == kPeripheralFeature) {
|
||||
if (argc == 0) {
|
||||
return true;
|
||||
}
|
||||
if (argv[0] == kDescribeOperation) {
|
||||
buildAndSendDescription();
|
||||
} else if (argv[0] == kControlOperation) {
|
||||
dispatchCustomControl(argc, argv);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command == SERVO_CONFIG && argc >= 5) {
|
||||
const byte pin = argv[0];
|
||||
const int minimumPulse = argv[1] | (argv[2] << 7);
|
||||
const int maximumPulse = argv[3] | (argv[4] << 7);
|
||||
if (pin < TOTAL_PINS && IS_PIN_DIGITAL(pin)) {
|
||||
Firmata.setPinMode(pin, PIN_MODE_SERVO);
|
||||
attachServo(pin, minimumPulse, maximumPulse);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command == EXTENDED_ANALOG && argc >= 2) {
|
||||
const byte pin = argv[0];
|
||||
if (pin >= TOTAL_PINS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int value = 0;
|
||||
// Extended analog values contain a variable number of seven-bit chunks.
|
||||
// Reassembling every received chunk keeps servo angles and PWM values fully
|
||||
// compatible with normal Firmata clients rather than assuming eight bits.
|
||||
for (byte index = 1; index < argc && index <= 4; ++index) {
|
||||
value |= static_cast<int>(argv[index]) << (7 * (index - 1));
|
||||
}
|
||||
|
||||
const byte mode = Firmata.getPinMode(pin);
|
||||
if (mode == PIN_MODE_PWM && IS_PIN_PWM(pin)) {
|
||||
analogWrite(PIN_TO_PWM(pin), value);
|
||||
Firmata.setPinState(pin, value);
|
||||
} else if (mode == PIN_MODE_SERVO && servos_[pin] != nullptr) {
|
||||
servos_[pin]->write(value);
|
||||
Firmata.setPinState(pin, value);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::reset() {
|
||||
for (byte pin = 0; pin < TOTAL_PINS; ++pin) {
|
||||
detachServo(pin);
|
||||
}
|
||||
|
||||
// Built-in role defaults are applied on Firmata reset as well as boot. This
|
||||
// makes reconnecting a client deterministic without creating a second state
|
||||
// model on the ESP32.
|
||||
if (hasHeadlight_) {
|
||||
pinMode(headlight_.pin, OUTPUT);
|
||||
const bool physicalHigh = headlight_.initiallyOn != (headlight_.polarity == OutputPolarity::ActiveLow);
|
||||
writeDigitalPin(headlight_.pin, physicalHigh);
|
||||
}
|
||||
if (hasLaser_) {
|
||||
pinMode(laser_.pin, OUTPUT);
|
||||
const bool physicalHigh = laser_.initiallyOn != (laser_.polarity == OutputPolarity::ActiveLow);
|
||||
writeDigitalPin(laser_.pin, physicalHigh);
|
||||
}
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::buildAndSendDescription() {
|
||||
JsonDocument document;
|
||||
document["name"] = name_;
|
||||
|
||||
if (hasCameraServo_ || hasHeadlight_ || hasLaser_) {
|
||||
JsonObject roverControls = document["roverControls"].to<JsonObject>();
|
||||
if (hasCameraServo_) {
|
||||
JsonObject servo = roverControls["cameraServo"].to<JsonObject>();
|
||||
servo["pin"] = cameraServo_.pin;
|
||||
servo["minimumAngleDegrees"] = cameraServo_.minimumAngleDegrees;
|
||||
servo["maximumAngleDegrees"] = cameraServo_.maximumAngleDegrees;
|
||||
servo["homeAngleDegrees"] = cameraServo_.homeAngleDegrees;
|
||||
servo["nudgeDegrees"] = cameraServo_.nudgeDegrees;
|
||||
servo["minimumPulseMicroseconds"] = cameraServo_.minimumPulseMicroseconds;
|
||||
servo["maximumPulseMicroseconds"] = cameraServo_.maximumPulseMicroseconds;
|
||||
servo["allowRawPulse"] = cameraServo_.allowRawPulse;
|
||||
servo["inverted"] = cameraServo_.inverted;
|
||||
}
|
||||
|
||||
auto addDigitalRole = [&roverControls](const char* key, const RoverDigitalOutputConfig& config) {
|
||||
JsonObject role = roverControls[key].to<JsonObject>();
|
||||
role["pin"] = config.pin;
|
||||
role["activeLow"] = config.polarity == OutputPolarity::ActiveLow;
|
||||
role["initiallyOn"] = config.initiallyOn;
|
||||
};
|
||||
if (hasHeadlight_) {
|
||||
addDigitalRole("headlight", headlight_);
|
||||
}
|
||||
if (hasLaser_) {
|
||||
addDigitalRole("laser", laser_);
|
||||
}
|
||||
}
|
||||
|
||||
JsonArray controls = document["controls"].to<JsonArray>();
|
||||
for (const ControlRegistration& registration : controls_) {
|
||||
JsonObject control = controls.add<JsonObject>();
|
||||
control["id"] = registration.id;
|
||||
control["name"] = registration.name;
|
||||
|
||||
switch (registration.type) {
|
||||
case ControlType::Slider:
|
||||
control["type"] = "slider";
|
||||
control["min"] = registration.minimum;
|
||||
control["max"] = registration.maximum;
|
||||
break;
|
||||
case ControlType::Button:
|
||||
control["type"] = "button";
|
||||
control["mode"] = buttonModeName(registration.buttonMode);
|
||||
break;
|
||||
case ControlType::Number:
|
||||
control["type"] = "number";
|
||||
control["min"] = registration.minimum;
|
||||
control["max"] = registration.maximum;
|
||||
break;
|
||||
case ControlType::Text:
|
||||
control["type"] = "text";
|
||||
control["maxLength"] = registration.maximumLength;
|
||||
break;
|
||||
}
|
||||
|
||||
JsonObject output = control["output"].to<JsonObject>();
|
||||
output["type"] = outputTypeName(static_cast<uint8_t>(registration.output));
|
||||
if (registration.output != OutputType::Custom) {
|
||||
output["pin"] = registration.pin;
|
||||
}
|
||||
if (registration.output == OutputType::Digital && registration.polarity == OutputPolarity::ActiveLow) {
|
||||
output["activeLow"] = true;
|
||||
}
|
||||
}
|
||||
|
||||
String payload;
|
||||
serializeJson(document, payload);
|
||||
|
||||
// ConfigurableFirmata's convenience sendSysex takes a byte-sized raw length.
|
||||
// Descriptions can exceed that, so write the standard framing and each 7-bit
|
||||
// pair directly. This remains one ordinary Firmata SysEx message on the wire.
|
||||
Firmata.startSysex();
|
||||
Firmata.write(kPeripheralFeature);
|
||||
Firmata.write(kDescriptionOperation);
|
||||
for (size_t index = 0; index < payload.length(); ++index) {
|
||||
Firmata.sendValueAsTwo7bitBytes(static_cast<uint8_t>(payload[index]));
|
||||
}
|
||||
Firmata.endSysex();
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::dispatchCustomControl(byte argc, byte* argv) {
|
||||
if (argc < 3 || ((argc - 1) % 2) != 0) {
|
||||
Firmata.sendString(F("Invalid rover control payload"));
|
||||
return;
|
||||
}
|
||||
|
||||
String decoded;
|
||||
decoded.reserve((argc - 1) / 2);
|
||||
for (byte index = 1; index + 1 < argc; index += 2) {
|
||||
if (argv[index + 1] > 1) {
|
||||
Firmata.sendString(F("Invalid rover control encoding"));
|
||||
return;
|
||||
}
|
||||
decoded += static_cast<char>(argv[index] | (argv[index + 1] << 7));
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
if (deserializeJson(document, decoded) != DeserializationError::Ok) {
|
||||
Firmata.sendString(F("Invalid rover control JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
const String controlID = document["control"].as<String>();
|
||||
for (ControlRegistration& registration : controls_) {
|
||||
if (registration.id != controlID || registration.output != OutputType::Custom) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The registration type is the source of truth for value conversion. This
|
||||
// prevents an unexpected JSON value from silently selecting a different
|
||||
// callback signature or invoking unrelated application behavior.
|
||||
switch (registration.type) {
|
||||
case ControlType::Slider:
|
||||
if (registration.sliderCallback) {
|
||||
registration.sliderCallback(document["value"].as<int>());
|
||||
}
|
||||
break;
|
||||
case ControlType::Button:
|
||||
if (registration.buttonCallback) {
|
||||
registration.buttonCallback(document["value"].as<bool>());
|
||||
}
|
||||
break;
|
||||
case ControlType::Number:
|
||||
if (registration.numberCallback) {
|
||||
registration.numberCallback(document["value"].as<int>());
|
||||
}
|
||||
break;
|
||||
case ControlType::Text:
|
||||
if (registration.textCallback) {
|
||||
String value = document["value"].as<String>();
|
||||
if (value.length() > registration.maximumLength) {
|
||||
value.remove(registration.maximumLength);
|
||||
}
|
||||
registration.textCallback(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Firmata.sendString(F("Unknown rover control"));
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::writeDigitalPin(byte pin, bool physicalHigh) {
|
||||
// Standard Firmata digital values represent the electrical pin level. roverd
|
||||
// applies the advertised activeLow mapping before sending a command, keeping
|
||||
// this firmware compatible with raw Firmata clients and avoiding inversion in
|
||||
// two different layers.
|
||||
digitalWrite(PIN_TO_DIGITAL(pin), physicalHigh ? HIGH : LOW);
|
||||
Firmata.setPinState(pin, physicalHigh ? 1 : 0);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::attachServo(byte pin, int minimumPulseMicroseconds, int maximumPulseMicroseconds) {
|
||||
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
|
||||
return;
|
||||
}
|
||||
if (servos_[pin] == nullptr) {
|
||||
servos_[pin] = new Servo();
|
||||
}
|
||||
if (servos_[pin]->attached()) {
|
||||
servos_[pin]->detach();
|
||||
}
|
||||
if (minimumPulseMicroseconds > 0 && maximumPulseMicroseconds > minimumPulseMicroseconds) {
|
||||
servos_[pin]->attach(PIN_TO_SERVO(pin), minimumPulseMicroseconds, maximumPulseMicroseconds);
|
||||
} else {
|
||||
servos_[pin]->attach(PIN_TO_SERVO(pin));
|
||||
}
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::detachServo(byte pin) {
|
||||
if (pin >= TOTAL_PINS || servos_[pin] == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (servos_[pin]->attached()) {
|
||||
servos_[pin]->detach();
|
||||
}
|
||||
delete servos_[pin];
|
||||
servos_[pin] = nullptr;
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::digitalPinValueCallback(byte pin, int value) {
|
||||
if (instance_ == nullptr || pin >= TOTAL_PINS || Firmata.getPinMode(pin) != PIN_MODE_OUTPUT) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Polarity is advertised by the peripheral and applied by roverd before this
|
||||
// standard raw pin-level command reaches the ESP32.
|
||||
instance_->writeDigitalPin(pin, value != 0);
|
||||
}
|
||||
|
||||
void RoverPeripheralFirmata::systemResetCallback() {
|
||||
if (instance_ != nullptr) {
|
||||
instance_->reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ConfigurableFirmata.h>
|
||||
#include <ESP32Servo.h>
|
||||
#include <FirmataExt.h>
|
||||
#include <RoverPeripheral.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
/*
|
||||
* RoverPeripheralFirmata is the protocol-facing implementation behind the
|
||||
* small RoverPeripheral public facade. Keeping this class private prevents
|
||||
* peripheral sketches from depending on Firmata types while ordinary Firmata
|
||||
* tooling can still use digital, PWM, and servo commands on the same stream.
|
||||
*/
|
||||
class RoverPeripheralFirmata : public FirmataFeature {
|
||||
public:
|
||||
explicit RoverPeripheralFirmata(const String& name);
|
||||
|
||||
void setName(const String& name);
|
||||
|
||||
void addServoSlider(const SliderControlConfig& config, const ServoOutput& output);
|
||||
void addPwmSlider(const SliderControlConfig& config, const PwmOutput& output);
|
||||
void addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output);
|
||||
void addSlider(const SliderControlConfig& config, SliderCallback callback);
|
||||
void addButton(const ButtonControlConfig& config, ButtonCallback callback);
|
||||
void addNumber(const NumberControlConfig& config, NumberCallback callback);
|
||||
void addText(const TextControlConfig& config, TextCallback callback);
|
||||
|
||||
void addRoverCameraServo(const RoverCameraServoConfig& config);
|
||||
void addRoverHeadlight(const RoverDigitalOutputConfig& config);
|
||||
void addRoverLaser(const RoverDigitalOutputConfig& config);
|
||||
|
||||
void begin(FirmataExt& extension);
|
||||
void update();
|
||||
|
||||
// FirmataFeature methods let FirmataExt route standard and custom SysEx
|
||||
// operations through the same parser that owns the serial connection.
|
||||
void handleCapability(byte pin) override;
|
||||
boolean handlePinMode(byte pin, int mode) override;
|
||||
boolean handleSysex(byte command, byte argc, byte* argv) override;
|
||||
void reset() override;
|
||||
|
||||
private:
|
||||
enum class ControlType {
|
||||
Slider,
|
||||
Button,
|
||||
Number,
|
||||
Text,
|
||||
};
|
||||
|
||||
enum class OutputType {
|
||||
Servo,
|
||||
Pwm,
|
||||
Digital,
|
||||
Custom,
|
||||
};
|
||||
|
||||
struct ControlRegistration {
|
||||
String id;
|
||||
String name;
|
||||
ControlType type;
|
||||
OutputType output;
|
||||
int minimum = 0;
|
||||
int maximum = 0;
|
||||
size_t maximumLength = 0;
|
||||
ButtonMode buttonMode = ButtonMode::Momentary;
|
||||
uint8_t pin = 0;
|
||||
OutputPolarity polarity = OutputPolarity::ActiveHigh;
|
||||
SliderCallback sliderCallback;
|
||||
ButtonCallback buttonCallback;
|
||||
NumberCallback numberCallback;
|
||||
TextCallback textCallback;
|
||||
};
|
||||
|
||||
String name_;
|
||||
std::vector<ControlRegistration> controls_;
|
||||
bool hasCameraServo_ = false;
|
||||
bool hasHeadlight_ = false;
|
||||
bool hasLaser_ = false;
|
||||
RoverCameraServoConfig cameraServo_;
|
||||
RoverDigitalOutputConfig headlight_;
|
||||
RoverDigitalOutputConfig laser_;
|
||||
Servo* servos_[TOTAL_PINS] = {};
|
||||
|
||||
void validateControlName(const String& name) const;
|
||||
void validateRange(const String& name, int minimum, int maximum) const;
|
||||
void buildAndSendDescription();
|
||||
void dispatchCustomControl(byte argc, byte* argv);
|
||||
void writeDigitalPin(byte pin, bool enabled);
|
||||
void attachServo(byte pin, int minimumPulseMicroseconds = -1, int maximumPulseMicroseconds = -1);
|
||||
void detachServo(byte pin);
|
||||
|
||||
static RoverPeripheralFirmata* instance_;
|
||||
static void digitalPinValueCallback(byte pin, int value);
|
||||
static void systemResetCallback();
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
[platformio]
|
||||
default_envs = esp32dev
|
||||
|
||||
[env]
|
||||
platform = espressif32
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
lib_deps =
|
||||
; Install the local package through PlatformIO's dependency manager so this
|
||||
; reference project exercises the same transitive dependency behavior as an
|
||||
; external project using the published Registry package.
|
||||
RoverPeripheral=file://../libraries/RoverPeripheralFirmata
|
||||
|
||||
; This is the generic ESP32-WROOM-32/DevKitC target used by boards carrying a
|
||||
; CH340 or CP210x USB-to-UART bridge. Linux normally exposes it as ttyUSB*.
|
||||
[env:esp32dev]
|
||||
board = esp32dev
|
||||
|
||||
; Native USB boards use the same sketch and Firmata stream. These flags make the
|
||||
; ESP32-S3's USB CDC serial port active at boot, normally appearing as ttyACM*.
|
||||
[env:esp32-s3-devkitc-1]
|
||||
board = esp32-s3-devkitc-1
|
||||
build_flags =
|
||||
-D ARDUINO_USB_MODE=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
@@ -0,0 +1,97 @@
|
||||
#include <RoverPeripheral.h>
|
||||
|
||||
namespace {
|
||||
// Every example pin is present on both the classic ESP32 DevKitC and the
|
||||
// ESP32-S3 DevKitC. GPIO 19 and 20 are deliberately avoided because native-USB
|
||||
// S3 boards use them for USB D- and D+.
|
||||
constexpr uint8_t kSpecialActionPin = 21;
|
||||
|
||||
int repeatCount = 1;
|
||||
String displayMessage;
|
||||
|
||||
void runSpecialAction(bool pressed) {
|
||||
// Receiving both button edges lets application hardware remain active only
|
||||
// while the driver holds the momentary control.
|
||||
digitalWrite(kSpecialActionPin, pressed ? HIGH : LOW);
|
||||
}
|
||||
|
||||
void setRepeatCount(int value) {
|
||||
// A real device can use this value when it starts its next animation or
|
||||
// actuator sequence. Storing it keeps this reference callback non-blocking.
|
||||
repeatCount = value;
|
||||
}
|
||||
|
||||
void setDisplayMessage(const String& value) {
|
||||
// Display hardware can render the stored value from updateRoverPeripheral().
|
||||
// Avoiding Serial output is important because Serial belongs to Firmata.
|
||||
displayMessage = value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void configureRoverPeripheral(RoverPeripheral& io) {
|
||||
io.name("Rover GPIO");
|
||||
|
||||
// Standard roles retain the rover's existing HUD controls while moving the
|
||||
// electrical outputs to this ESP32 on either a Pi or laptop rover host.
|
||||
RoverCameraServoConfig cameraServo;
|
||||
cameraServo.pin = 14;
|
||||
cameraServo.minimumAngleDegrees = -15;
|
||||
cameraServo.maximumAngleDegrees = 30;
|
||||
cameraServo.homeAngleDegrees = 0;
|
||||
cameraServo.nudgeDegrees = 2;
|
||||
cameraServo.minimumPulseMicroseconds = 900;
|
||||
cameraServo.maximumPulseMicroseconds = 2100;
|
||||
cameraServo.allowRawPulse = false;
|
||||
cameraServo.inverted = false;
|
||||
io.addCameraServo(cameraServo);
|
||||
|
||||
RoverDigitalOutputConfig headlight;
|
||||
headlight.pin = 18;
|
||||
headlight.polarity = OutputPolarity::ActiveHigh;
|
||||
headlight.initiallyOn = false;
|
||||
io.addHeadlight(headlight);
|
||||
|
||||
RoverDigitalOutputConfig laser;
|
||||
laser.pin = 16;
|
||||
laser.polarity = OutputPolarity::ActiveHigh;
|
||||
laser.initiallyOn = false;
|
||||
io.addLaser(laser);
|
||||
|
||||
pinMode(kSpecialActionPin, OUTPUT);
|
||||
digitalWrite(kSpecialActionPin, LOW);
|
||||
|
||||
// Accessory controls render in precisely this registration order.
|
||||
SliderControlConfig servoPosition;
|
||||
servoPosition.name = "Servo position";
|
||||
servoPosition.minimum = 0;
|
||||
servoPosition.maximum = 180;
|
||||
|
||||
ServoOutput servoOutput;
|
||||
servoOutput.pin = 13;
|
||||
io.addSlider(servoPosition, servoOutput);
|
||||
|
||||
SliderControlConfig lightBrightness;
|
||||
lightBrightness.name = "Light brightness";
|
||||
lightBrightness.minimum = 0;
|
||||
lightBrightness.maximum = 255;
|
||||
|
||||
PwmOutput lightOutput;
|
||||
lightOutput.pin = 17;
|
||||
io.addSlider(lightBrightness, lightOutput);
|
||||
|
||||
ButtonControlConfig specialAction;
|
||||
specialAction.name = "Special action";
|
||||
specialAction.mode = ButtonMode::Momentary;
|
||||
io.addButton(specialAction, runSpecialAction);
|
||||
|
||||
NumberControlConfig repeats;
|
||||
repeats.name = "Repeat count";
|
||||
repeats.minimum = 1;
|
||||
repeats.maximum = 20;
|
||||
io.addNumber(repeats, setRepeatCount);
|
||||
|
||||
TextControlConfig message;
|
||||
message.name = "Display message";
|
||||
message.maximumLength = 64;
|
||||
io.addText(message, setDisplayMessage);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
# `/` Performance Investigation Summary
|
||||
|
||||
This is the high-level handoff for the default route at:
|
||||
|
||||
```sh
|
||||
https://rover.otter.land/
|
||||
```
|
||||
|
||||
The detailed issue writeups live in `perf/issues/`.
|
||||
|
||||
## Main Finding
|
||||
|
||||
The `/` page is doing too much main-thread work for low-end devices. On the current powerful
|
||||
machine it can appear smooth, but CPU throttling shows real risk for mobile and weaker
|
||||
computers.
|
||||
|
||||
The biggest measured problem is repeated global input listener churn. The broader underlying
|
||||
theme is that high-frequency live data and broad React context invalidation cause many parts
|
||||
of the page to rerender or recommit DOM attributes.
|
||||
|
||||
## Key Measurements
|
||||
|
||||
### Clean `/` Profile, 8x Mobile CPU Throttle
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-15-51-398Z/report.json
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
```txt
|
||||
average frame interval: ~140ms
|
||||
p95 frame interval: ~166.8ms
|
||||
p99 frame interval: ~383.4ms
|
||||
long tasks: 67
|
||||
event listener delta: +13,481
|
||||
JS heap delta: +22.76MB
|
||||
ScriptDuration delta: ~35.85s
|
||||
TaskDuration delta: ~52.88s
|
||||
```
|
||||
|
||||
Top notable CPU self-time included:
|
||||
|
||||
```txt
|
||||
removeEventListener ~1.30s
|
||||
addEventListener ~0.94s
|
||||
```
|
||||
|
||||
### Listener Audit
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-03-31-625Z-listeners/listener-report.json
|
||||
```
|
||||
|
||||
Worst churn:
|
||||
|
||||
```txt
|
||||
keydown adds=8040 removes=8039
|
||||
keyup adds=8040 removes=8039
|
||||
blur adds=8040 removes=8039
|
||||
gamepadconnected adds=8040 removes=8039
|
||||
gamepaddisconnected adds=8040 removes=8039
|
||||
```
|
||||
|
||||
### `/` Runtime Audit, 6x Mobile CPU Throttle, 30s
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
```txt
|
||||
average frame interval: ~88ms
|
||||
p95 frame interval: 150ms
|
||||
p99 frame interval: 183ms
|
||||
long tasks: 18
|
||||
WebSocket messages: at least 1000 sampled
|
||||
WebSocket bytes: ~798KB sampled
|
||||
DOM mutation records: 41,062
|
||||
DOM attribute records: 40,761
|
||||
```
|
||||
|
||||
Top sampled WebSocket events:
|
||||
|
||||
```txt
|
||||
log:entry 526
|
||||
sensorFrame 306
|
||||
commandAck 145
|
||||
session:sync 5
|
||||
```
|
||||
|
||||
### `/` Runtime Audit With Mutation Targets, 6x Mobile CPU Throttle, 18s
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
```txt
|
||||
average frame interval: ~116.6ms
|
||||
p95 frame interval: ~166.7ms
|
||||
p99 frame interval: ~316.6ms
|
||||
long tasks: 16
|
||||
DOM mutation records: 18,758
|
||||
DOM attribute records: 18,490
|
||||
```
|
||||
|
||||
Top WebSocket events:
|
||||
|
||||
```txt
|
||||
log:entry 850
|
||||
sensorFrame 277
|
||||
commandAck 200
|
||||
session:sync 23
|
||||
```
|
||||
|
||||
Top mutation targets:
|
||||
|
||||
```txt
|
||||
input.accent-emerald-400 name 5300
|
||||
input.accent-emerald-400 type 2650
|
||||
field-input name ~1300 each
|
||||
div.h-full style 1238
|
||||
path d 621
|
||||
path fill 619
|
||||
```
|
||||
|
||||
## Ordered Work Plan
|
||||
|
||||
1. Fix `KeyboardInputManager` and `GamepadInputManager` listener churn.
|
||||
2. Split/stabilize `ControlContext` so action consumers do not rerender on every state change.
|
||||
3. Batch or gate `log:entry` socket updates.
|
||||
4. Throttle visual telemetry rendering.
|
||||
5. Memoize or lazy-mount `ReplaySourcesPanel`.
|
||||
6. Split and memoize chat composer/nickname form.
|
||||
7. Clean up or share timers.
|
||||
|
||||
## Reproduction Commands
|
||||
|
||||
Install Playwright dependency if missing:
|
||||
|
||||
```sh
|
||||
npm install --prefix perf playwright@1.60.0
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
Clean CPU profile:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=8 VIEWPORT=390x844 MOBILE=1 node perf/live-cpu-profile.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Listener audit:
|
||||
|
||||
```sh
|
||||
node perf/live-listener-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Runtime audit:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Runtime audit with shorter mutation target sample:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=18000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
The `perf/results/2026-06-11T03-11-39-596Z-route-sweep/` route sweep includes `/spectate`,
|
||||
`/mini`, `/display`, and `/scanner`. Those are not the current priority. Use them only for
|
||||
context.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# `/spectate` CPU Performance Summary
|
||||
|
||||
This is the `/spectate` companion to the `/` performance writeups. The testing target
|
||||
was the deployed page:
|
||||
|
||||
```sh
|
||||
https://rover.otter.land/spectate
|
||||
```
|
||||
|
||||
The most useful artifact for this pass is:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
That run used:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
## High-Level Result
|
||||
|
||||
`/spectate` is CPU-heavy for a different reason than `/`.
|
||||
|
||||
The `/` page's biggest confirmed issue is global input listener churn. `/spectate` does
|
||||
not show that same dominant pattern. The fresh `/spectate` audit had 379 event listeners,
|
||||
mostly normal media/image listeners, and no giant add/remove loop.
|
||||
|
||||
The `/spectate` hot path is instead a live-display fan-out problem:
|
||||
|
||||
- several rover media players are mounted at once
|
||||
- each rover card renders live HUD telemetry and a top-down SVG map
|
||||
- camera/snapshot frames create new blobs and `img.src` values
|
||||
- the route subscribes to broad session/log/chat state while also rendering the whole
|
||||
spectator layout
|
||||
- the page maintains several WebRTC players at the same time
|
||||
|
||||
## Key Measurements
|
||||
|
||||
Fresh `/spectate` runtime audit, 30s, mobile viewport, 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
TaskDuration: 28.48s over a 30.9s sample
|
||||
ScriptDuration: 12.11s
|
||||
Long tasks: 81
|
||||
Worst long task: 375ms
|
||||
Average frame gap: 113.9ms
|
||||
p95 frame gap: 233.3ms
|
||||
p99 frame gap: 300.0ms
|
||||
Nodes: 7,059
|
||||
Layout objects: 6,150
|
||||
RTCPeerConnections: 6
|
||||
LayoutCount: 243
|
||||
RecalcStyleCount: 248
|
||||
```
|
||||
|
||||
The 8x CPU-throttled profile from the earlier route comparison was worse:
|
||||
|
||||
```txt
|
||||
Task delta: 49.13s over a 52.85s sample
|
||||
Script delta: 19.58s
|
||||
Layout delta: 516
|
||||
Recalc delta: 522
|
||||
Node delta: +3,815
|
||||
```
|
||||
|
||||
That profile also put `Blob`, `decodeString`, and `setAttribute` in the CPU stack, which
|
||||
matches the runtime evidence for camera blobs, socket payload parsing, and SVG/image
|
||||
attribute churn.
|
||||
|
||||
## Socket And DOM Pressure
|
||||
|
||||
The fresh audit captured these live events during the 30s window:
|
||||
|
||||
```txt
|
||||
sensorFrame: 1,354 events, 2,531,090 bytes
|
||||
log:entry: 634 events, 150,179 bytes
|
||||
unlabeled binary: 126 events, 11,048,477 bytes
|
||||
roomCamera:frame: 117 events
|
||||
session:sync: 54 events, 914,000 bytes
|
||||
commandAck: 68 events
|
||||
roverHostStats: 17 events
|
||||
roverSnapshot:frame: 7 events
|
||||
```
|
||||
|
||||
The most important DOM mutations were:
|
||||
|
||||
```txt
|
||||
path d: 2,688 mutations
|
||||
path fill: 2,682 mutations
|
||||
img.h-full.w-full src: 117 mutations
|
||||
span.h-2.w-2 class: 117 mutations
|
||||
```
|
||||
|
||||
The `path` mutations point strongly at the per-rover `TopDownMap` SVG overlays. The
|
||||
`img.src` and blink class mutations point at room camera snapshot rendering.
|
||||
|
||||
## Ordered Issue List
|
||||
|
||||
1. [Telemetry fan-out and always-on HUD maps](./spectate-issues/001-telemetry-fanout-hud-maps.md)
|
||||
2. [Multi-rover WebRTC media fan-out](./spectate-issues/002-multi-rover-webrtc-media-fanout.md)
|
||||
3. [Room camera and snapshot object URL churn](./spectate-issues/003-room-camera-snapshot-object-url-churn.md)
|
||||
4. [Broad spectator session rerenders](./spectate-issues/004-broad-spectator-session-rerenders.md)
|
||||
5. [Shared log/session stream pressure](./spectate-issues/005-shared-log-session-stream-pressure.md)
|
||||
6. [Spectator role handshake retries](./spectate-issues/006-spectator-role-handshake-retries.md)
|
||||
|
||||
## Upstream Dependency Map
|
||||
|
||||
Fixing the `/` backlog first is a good plan.
|
||||
|
||||
Likely improved by `/` fixes:
|
||||
|
||||
- telemetry visual throttling
|
||||
- log batching/gating
|
||||
- chat/nickname churn
|
||||
- session selector/store improvements
|
||||
- generic timer cleanup
|
||||
|
||||
Not likely to be fixed by `/` alone:
|
||||
|
||||
- several simultaneous WHEP video/audio players
|
||||
- per-rover spectator HUD maps
|
||||
- room camera snapshot blob/object URL churn
|
||||
- spectator role/subscribe handshake behavior
|
||||
- mobile policy for how many live feeds should be active at once
|
||||
|
||||
## Suggested Re-Test
|
||||
|
||||
After each `/spectate` fix, run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
|
||||
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
|
||||
```
|
||||
|
||||
The most useful numbers to compare:
|
||||
|
||||
- `TaskDuration`
|
||||
- `ScriptDuration`
|
||||
- long task count and worst duration
|
||||
- frame average/p95/p99
|
||||
- `RTCPeerConnections`
|
||||
- `mutationTargets` for `path d`, `path fill`, and `img src`
|
||||
- `webSocketEvents` for `sensorFrame`, `log:entry`, `session:sync`, and binary frame volume
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# FIXED!
|
||||
# Issue 001: Global Input Listener Churn
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page repeatedly removes and re-adds global keyboard and gamepad event listeners.
|
||||
This is the clearest and most actionable CPU problem found on the control page.
|
||||
|
||||
On weaker CPUs, this turns into measurable main-thread cost. It also makes the input layer
|
||||
fragile because every broad render/effect invalidation touches global browser listeners.
|
||||
|
||||
## Severity
|
||||
|
||||
High.
|
||||
|
||||
This should be fixed first. It is directly measured and likely amplifies other `/` issues.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/controls/inputs/KeyboardInputManager.jsx`
|
||||
- `webui/src/controls/inputs/GamepadInputManager.jsx`
|
||||
- `webui/src/controls/inputs/gamepadHub.js`
|
||||
- Related multiplier: `webui/src/controls/ControlContext.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
### Listener Audit
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-03-31-625Z-listeners/listener-report.json
|
||||
```
|
||||
|
||||
At the second snapshot, the audit saw:
|
||||
|
||||
```txt
|
||||
keydown adds=8040 removes=8039
|
||||
keyup adds=8040 removes=8039
|
||||
blur adds=8040 removes=8039
|
||||
gamepadconnected adds=8040 removes=8039
|
||||
gamepaddisconnected adds=8040 removes=8039
|
||||
```
|
||||
|
||||
The stack mapped those to the live bundle locations corresponding to:
|
||||
|
||||
- `KeyboardInputManager.jsx` global `keydown`, `keyup`, `blur` effect
|
||||
- `gamepadHub.js` global `gamepadconnected`, `gamepaddisconnected` listener registration
|
||||
|
||||
### Root Runtime Audit
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In a 30s `/` run at mobile viewport with 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
add:window:keydown 770
|
||||
remove:window:keydown 769
|
||||
add:window:keyup 770
|
||||
remove:window:keyup 769
|
||||
add:window:blur 770
|
||||
remove:window:blur 769
|
||||
add:window:gamepadconnected 770
|
||||
remove:window:gamepadconnected 769
|
||||
add:window:gamepaddisconnected 770
|
||||
remove:window:gamepaddisconnected 769
|
||||
```
|
||||
|
||||
### Clean CPU Profile
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-15-51-398Z/report.json
|
||||
```
|
||||
|
||||
Clean `/` profile, mobile viewport, 8x CPU throttle:
|
||||
|
||||
```txt
|
||||
average frame interval: ~140ms
|
||||
p95 frame interval: ~166.8ms
|
||||
p99 frame interval: ~383.4ms
|
||||
long tasks: 67
|
||||
event listener delta: +13,481
|
||||
removeEventListener: ~1.30s self time
|
||||
addEventListener: ~0.94s self time
|
||||
```
|
||||
|
||||
## Likely Cause
|
||||
|
||||
`KeyboardInputManager` installs global listeners inside a `useEffect` with a very large
|
||||
dependency list:
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) { ... }
|
||||
function handleKeyUp(event) { ... }
|
||||
function handleBlur() { ... }
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.addEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.removeEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, [
|
||||
actionTokens,
|
||||
blurChat,
|
||||
driveFromKeys,
|
||||
ensureServoLoop,
|
||||
ensureSongLoop,
|
||||
focusChat,
|
||||
isChatFocused,
|
||||
keymap.chatFocus,
|
||||
...
|
||||
dockAssist,
|
||||
]);
|
||||
```
|
||||
|
||||
Many dependencies change when `ControlContext` or settings/session state changes. That
|
||||
causes the effect to tear down and reinstall global listeners over and over.
|
||||
|
||||
`GamepadInputManager` has the same pattern in a subscription effect:
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
return subscribeGamepadHub((hubState) => { ... });
|
||||
}, [
|
||||
activeSignature,
|
||||
ensureProfile,
|
||||
gamepadSettings?.defaults?.profile,
|
||||
gamepadSettings?.profiles,
|
||||
handleButtonEdge,
|
||||
handleCameraAxis,
|
||||
registerInputState,
|
||||
runMacro,
|
||||
setAuxMotors,
|
||||
setDriveVector,
|
||||
setMode,
|
||||
toggleHeadlight,
|
||||
dockAssist,
|
||||
]);
|
||||
```
|
||||
|
||||
When this effect resubscribes, `subscribeGamepadHub` may add/remove global device listeners
|
||||
in `gamepadHub.js`.
|
||||
|
||||
## Fix Strategy
|
||||
|
||||
### KeyboardInputManager
|
||||
|
||||
Install global listeners once:
|
||||
|
||||
```jsx
|
||||
const latestRef = useRef(null);
|
||||
|
||||
latestRef.current = {
|
||||
keymap,
|
||||
actionTokens,
|
||||
isChatFocused,
|
||||
focusChat,
|
||||
resetAll,
|
||||
driveFromKeys,
|
||||
ensureServoLoop,
|
||||
ensureSongLoop,
|
||||
...
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
const latest = latestRef.current;
|
||||
if (!latest) return;
|
||||
// Existing logic, reading from latest instead of closure variables.
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
const latest = latestRef.current;
|
||||
if (!latest) return;
|
||||
// Existing logic.
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
latestRef.current?.resetAll();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.addEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
window.removeEventListener('keyup', handleKeyUp, { capture: true });
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
Keep mutable input state in refs as it already mostly does:
|
||||
|
||||
- `activeTokensRef`
|
||||
- `lastVectorRef`
|
||||
- `lastAuxRef`
|
||||
- `servoIntervalRef`
|
||||
- `songIntervalRef`
|
||||
- `hornActiveRef`
|
||||
|
||||
### GamepadInputManager
|
||||
|
||||
Use the same pattern:
|
||||
|
||||
- Keep latest settings/actions/state in a ref.
|
||||
- Subscribe to `subscribeGamepadHub` once.
|
||||
- The subscription callback reads latest values from the ref.
|
||||
|
||||
Do not resubscribe just because profile/settings/action references changed.
|
||||
|
||||
### gamepadHub.js
|
||||
|
||||
After `GamepadInputManager` is stable, confirm whether `gamepadHub.js` still needs changes.
|
||||
It already tries to add global device listeners only when there are subscribers. The churn
|
||||
is likely caused by subscriber churn, not necessarily a bug in the hub itself.
|
||||
|
||||
## Validation
|
||||
|
||||
Before fix:
|
||||
|
||||
```sh
|
||||
node perf/live-listener-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected current bad result:
|
||||
|
||||
```txt
|
||||
hundreds or thousands of add/remove pairs for:
|
||||
keydown
|
||||
keyup
|
||||
blur
|
||||
gamepadconnected
|
||||
gamepaddisconnected
|
||||
```
|
||||
|
||||
After fix:
|
||||
|
||||
```txt
|
||||
keydown add ~= 1
|
||||
keyup add ~= 1
|
||||
blur add ~= 1
|
||||
gamepadconnected add ~= 1
|
||||
gamepaddisconnected add ~= 1
|
||||
removes only on page teardown
|
||||
```
|
||||
|
||||
Also run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
CPU_THROTTLE=8 VIEWPORT=390x844 MOBILE=1 node perf/live-cpu-profile.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- `JSEventListeners` delta should stop climbing dramatically.
|
||||
- `addEventListener` / `removeEventListener` should disappear from top CPU self-time.
|
||||
- Average frame interval and long-task count should improve, though other issues will remain.
|
||||
|
||||
## Risks
|
||||
|
||||
- Keyboard control safety matters. Be careful to preserve `resetAll()` on blur and text-input
|
||||
ignoring behavior.
|
||||
- If using refs, avoid stale data bugs by updating the ref every render before browser events
|
||||
can fire.
|
||||
- Verify horn hold, mic push-to-talk, chat focus, drive macro, dock assist, camera tilt, song
|
||||
controls, and Home Assistant shortcuts.
|
||||
@@ -0,0 +1,192 @@
|
||||
# FIXED!
|
||||
# Issue 002: ControlContext Broad Invalidation
|
||||
|
||||
## Summary
|
||||
|
||||
`ControlContext` exposes one large changing context value containing all control state,
|
||||
pipeline objects, limiter state, and action callbacks. This likely causes many consumers
|
||||
to re-render and effects to re-run when unrelated control state changes.
|
||||
|
||||
This is probably the multiplier behind the global input listener churn and some repeated
|
||||
DOM commits on `/`.
|
||||
|
||||
## Severity
|
||||
|
||||
High, but fix after issue 001 unless doing both together.
|
||||
|
||||
Issue 001 is the symptom with the clearest measurement. Issue 002 is the architectural
|
||||
cause that may also affect other parts of the page.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/controls/ControlContext.jsx`
|
||||
- `webui/src/controls/inputs/KeyboardInputManager.jsx`
|
||||
- `webui/src/controls/inputs/GamepadInputManager.jsx`
|
||||
- Many consumers of `useControlSystem()`
|
||||
|
||||
## Evidence
|
||||
|
||||
The `contextValue` in `ControlContext.jsx` is:
|
||||
|
||||
```jsx
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
dispatch,
|
||||
pipeline,
|
||||
overcurrentLimiter,
|
||||
actions: {
|
||||
setMode,
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
...
|
||||
},
|
||||
}),
|
||||
[
|
||||
state,
|
||||
pipeline,
|
||||
overcurrentLimiter,
|
||||
setMode,
|
||||
setDriveVector,
|
||||
...
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
Any state change changes the whole context value. Any consumer using:
|
||||
|
||||
```jsx
|
||||
const { state, actions } = useControlSystem();
|
||||
```
|
||||
|
||||
is subscribed to the broad value, even if it only needs one action or one small state field.
|
||||
|
||||
This connects to measured symptoms:
|
||||
|
||||
- Input listener effects rerun because their action/callback dependencies change.
|
||||
- `/` runtime audit saw repeated attribute commits in UI that should not need to change every
|
||||
telemetry/log tick.
|
||||
- CPU profiles show large minified React frames (`C9`, `ad`, `Fl`, etc.) alongside listener
|
||||
churn.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
React context invalidation is coarse. When provider value identity changes, every consumer
|
||||
below it can be scheduled to render. Even memoized children are not enough if they consume
|
||||
the changing context directly.
|
||||
|
||||
This project has several high-frequency update sources:
|
||||
|
||||
- telemetry frames
|
||||
- logs
|
||||
- session syncs
|
||||
- control state changes
|
||||
- settings state
|
||||
- timers
|
||||
|
||||
If `ControlContext` changes broadly, it makes it harder to isolate these updates.
|
||||
|
||||
## Likely Cause Pattern
|
||||
|
||||
Several action callbacks depend on `state` or `pipeline`, which changes their identity:
|
||||
|
||||
```jsx
|
||||
const setDriveVector = useCallback(..., [pipeline, recordControlIntent, state.manualDockAssist?.active]);
|
||||
const setServoAngle = useCallback(..., [pipeline, recordControlIntent, state.manualDockAssist?.active]);
|
||||
const runMacro = useCallback(..., [driveMacroBackoffEnabled, pipeline, ..., state.macros, ...]);
|
||||
const startHorn = useCallback(..., [dispatch, normalizedHornSettings, pipeline, ..., state.horn?.active, ...]);
|
||||
```
|
||||
|
||||
Those callbacks are then included in `contextValue`.
|
||||
|
||||
Consumers that include those callbacks in effects rerun when the callback identity changes.
|
||||
|
||||
## Fix Strategy
|
||||
|
||||
### Option A: Split State and Actions Contexts
|
||||
|
||||
Create separate contexts:
|
||||
|
||||
```jsx
|
||||
const ControlStateContext = createContext(null);
|
||||
const ControlActionsContext = createContext(null);
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```jsx
|
||||
<ControlStateContext.Provider value={stateValue}>
|
||||
<ControlActionsContext.Provider value={actionsValue}>
|
||||
{children}
|
||||
</ControlActionsContext.Provider>
|
||||
</ControlStateContext.Provider>
|
||||
```
|
||||
|
||||
Action-only consumers should not rerender when state changes.
|
||||
|
||||
### Option B: Add Selectors
|
||||
|
||||
Create a small external-store style control store, similar to `SessionContext` or
|
||||
`TelemetryContext`, so consumers can select only the state slice they need:
|
||||
|
||||
```jsx
|
||||
useControlSelector((state) => state.camera.angle)
|
||||
useControlActions()
|
||||
```
|
||||
|
||||
This is more work but scales better.
|
||||
|
||||
### Option C: Stabilize Actions With Refs
|
||||
|
||||
Keep action function identities stable and read latest state/pipeline from refs:
|
||||
|
||||
```jsx
|
||||
const latestRef = useRef(null);
|
||||
latestRef.current = { state, pipeline, overcurrentLimiter, ... };
|
||||
|
||||
const setDriveVector = useCallback((vector, meta = {}) => {
|
||||
const { state, pipeline } = latestRef.current;
|
||||
...
|
||||
}, []);
|
||||
```
|
||||
|
||||
This pairs well with issue 001 because stable actions make stable input effects easier.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
For incremental work:
|
||||
|
||||
1. Fix issue 001 directly using refs in input managers.
|
||||
2. Split `ControlActionsContext` from `ControlStateContext`.
|
||||
3. Convert heavy/control-critical consumers first:
|
||||
- `KeyboardInputManager`
|
||||
- `GamepadInputManager`
|
||||
- mobile controls
|
||||
- right pane controls
|
||||
4. Later add selector hooks for state-heavy consumers.
|
||||
|
||||
## Validation
|
||||
|
||||
After issue 001 + partial issue 002:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- listener add/remove counts near 1
|
||||
- fewer repeated input attribute mutations
|
||||
- fewer long tasks
|
||||
- lower `ScriptDuration` and `TaskDuration`
|
||||
|
||||
For development builds, React DevTools Profiler would be ideal to verify fewer renders. The
|
||||
deployed production page does not currently publish source maps, so local profiling may be
|
||||
more useful after implementing.
|
||||
|
||||
## Risks
|
||||
|
||||
- Ref-based stable actions can hide stale state bugs if refs are not updated reliably.
|
||||
- Splitting context touches many files. Prefer a small staged migration.
|
||||
- Control commands are safety-sensitive; verify stop/horn/mic/docking behavior carefully.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# FIXED!
|
||||
# Issue 003: High-Volume Socket Log Stream
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page receives a very high volume of `log:entry` socket messages. These update the
|
||||
global session store even when logs are not the primary visible task.
|
||||
|
||||
This is not the single biggest CPU stack item, but it is a major source of live update
|
||||
pressure on low-end devices.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-high.
|
||||
|
||||
Fix after listener/context work, unless logs are known to be unusually noisy in production.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/context/SessionContext.jsx`
|
||||
- `webui/src/components/LogPanel/index.jsx`
|
||||
- Server log emission paths, depending on where `log:entry` is emitted
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In an 18s `/` audit at mobile viewport with 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
log:entry count: 850
|
||||
log:entry bytes: 185,008
|
||||
```
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In a 30s audit, among the most recent sampled 1000 WebSocket messages:
|
||||
|
||||
```txt
|
||||
log:entry count: 526
|
||||
log:entry bytes: 111,535
|
||||
```
|
||||
|
||||
In `SessionContext.jsx`, each log entry does:
|
||||
|
||||
```jsx
|
||||
function handleLogEntry(entry) {
|
||||
setState((prev) => ({ ...prev, logs: [...prev.logs.slice(-199), entry] }));
|
||||
}
|
||||
```
|
||||
|
||||
This creates a new session state object and a new logs array for every log message.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Even if `LogPanel` is not visible or not currently selected, `SessionContext` still processes
|
||||
the update. Every session subscriber selector is evaluated on each `setState`.
|
||||
|
||||
At 850 log messages in 18s, this is roughly 47 log entries per second.
|
||||
|
||||
On a powerful desktop this is fine. On a weaker phone or low-end laptop, it becomes steady
|
||||
background pressure.
|
||||
|
||||
## Likely Cause
|
||||
|
||||
The server appears to broadcast operational logs very frequently. The client stores the last
|
||||
200 logs globally.
|
||||
|
||||
The `/` page includes `LogPanel` under settings and may also have other components depending
|
||||
on session state.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Do Not Subscribe to Logs Unless Log UI Is Visible
|
||||
|
||||
Best conceptual fix:
|
||||
|
||||
- Add an explicit client event like `log:subscribe` / `log:unsubscribe`.
|
||||
- Only subscribe when `LogPanel` is open or user is admin.
|
||||
- For ordinary mobile control users, skip logs entirely.
|
||||
|
||||
### Option B: Batch Log Entries Client-Side
|
||||
|
||||
Keep receiving logs, but batch state updates:
|
||||
|
||||
```jsx
|
||||
const pendingLogsRef = useRef([]);
|
||||
const flushTimerRef = useRef(null);
|
||||
|
||||
function handleLogEntry(entry) {
|
||||
pendingLogsRef.current.push(entry);
|
||||
if (flushTimerRef.current) return;
|
||||
flushTimerRef.current = setTimeout(() => {
|
||||
const batch = pendingLogsRef.current.splice(0);
|
||||
flushTimerRef.current = null;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: [...prev.logs, ...batch].slice(-200),
|
||||
}));
|
||||
}, 250);
|
||||
}
|
||||
```
|
||||
|
||||
This changes 47 state updates/sec into about 4 updates/sec.
|
||||
|
||||
### Option C: Store Logs in a Separate External Store
|
||||
|
||||
Move logs out of main `SessionContext` so unrelated session consumers are not touched by
|
||||
log spam.
|
||||
|
||||
For example:
|
||||
|
||||
```jsx
|
||||
LogStoreProvider
|
||||
useLogEntries()
|
||||
```
|
||||
|
||||
This isolates log updates from user/roster/session state.
|
||||
|
||||
### Option D: Server-Side Sampling or Severity Filtering
|
||||
|
||||
Only send:
|
||||
|
||||
- warnings/errors by default
|
||||
- full logs to admin/log panel subscribers
|
||||
- sampled info/debug entries
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Start with client batching. It is low-risk and easy to validate.
|
||||
|
||||
Then consider subscription gating if logs are not essential for normal users.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
The socket `log:entry` count may remain high if only batching client-side, but UI state
|
||||
updates should reduce. To measure batching directly, add temporary counters in
|
||||
`SessionContext` or extend the audit to count React commits after local changes.
|
||||
|
||||
Expected user-visible perf improvements:
|
||||
|
||||
- lower `ScriptDuration`
|
||||
- lower `TaskDuration`
|
||||
- fewer repeated DOM commits if log updates were causing broad subscribers to render
|
||||
|
||||
## Risks
|
||||
|
||||
- Logs may be used for operator visibility during incidents.
|
||||
- Batching can delay log display by 100-500ms. That should be acceptable for UI logs.
|
||||
- Subscription gating requires server/client protocol changes.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# FIXED!
|
||||
# Issue 004: Sensor Telemetry Render Frequency
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page receives frequent `sensorFrame` messages and renders multiple telemetry/HUD
|
||||
consumers from those frames. On throttled mobile CPU, this shows up as repeated SVG path
|
||||
attribute mutations and significant live DOM churn.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-high.
|
||||
|
||||
This is likely the biggest non-input-manager source of steady rendering work.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/context/TelemetryContext.jsx`
|
||||
- `webui/src/components/TopDownMap/TopDownMapContent.jsx`
|
||||
- `webui/src/components/TopDownMap/visuals.jsx`
|
||||
- `webui/src/components/DriverVideo/index.jsx`
|
||||
- `webui/src/components/HudOverlays/*`
|
||||
- `webui/src/components/TelemetryPanel/index.jsx`
|
||||
- `webui/src/components/PiHostStatsCard/index.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In an 18s `/` audit at mobile viewport with 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
sensorFrame count: 277
|
||||
sensorFrame bytes: 520,169
|
||||
```
|
||||
|
||||
That is about 15.4 sensor frames/sec.
|
||||
|
||||
The same audit saw SVG attribute mutations:
|
||||
|
||||
```txt
|
||||
path d mutations: 621
|
||||
path fill mutations: 619
|
||||
```
|
||||
|
||||
These map strongly to `TopDownMapContent.jsx` and `visuals.jsx`, where sensor values are
|
||||
converted into SVG arcs, cones, wheels, brush visuals, and colors.
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In a 30s audit:
|
||||
|
||||
```txt
|
||||
sensorFrame count among latest sampled 1000 messages: 306
|
||||
sensorFrame bytes: 574,616
|
||||
```
|
||||
|
||||
## Current Code Path
|
||||
|
||||
`TelemetryContext.jsx` updates every incoming frame:
|
||||
|
||||
```jsx
|
||||
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
|
||||
if (!roverId) return;
|
||||
const previous = framesRef.current[roverId] ?? {};
|
||||
framesRef.current = {
|
||||
...framesRef.current,
|
||||
[roverId]: {
|
||||
...previous,
|
||||
roverId,
|
||||
sensors,
|
||||
raw: frame?.data || null,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
notifyRover(roverId);
|
||||
}
|
||||
```
|
||||
|
||||
Consumers subscribe by rover:
|
||||
|
||||
```jsx
|
||||
useTelemetryFrame(roverId)
|
||||
```
|
||||
|
||||
Every `notifyRover(roverId)` wakes all consumers for that rover.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Telemetry itself may need to remain high-frequency for control safety or debugging, but the
|
||||
visual UI usually does not need to render at full sensor frequency.
|
||||
|
||||
The expensive part is not receiving the socket message. It is causing React/SVG/HUD updates
|
||||
and DOM attribute changes at the same cadence.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Throttle Render Notifications
|
||||
|
||||
Keep the latest frame in `framesRef` immediately, but notify subscribers at a lower rate.
|
||||
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
const pendingRoversRef = useRef(new Set());
|
||||
const notifyScheduledRef = useRef(false);
|
||||
|
||||
function scheduleNotify(roverId) {
|
||||
pendingRoversRef.current.add(roverId);
|
||||
if (notifyScheduledRef.current) return;
|
||||
notifyScheduledRef.current = true;
|
||||
setTimeout(() => {
|
||||
notifyScheduledRef.current = false;
|
||||
const rovers = [...pendingRoversRef.current];
|
||||
pendingRoversRef.current.clear();
|
||||
rovers.forEach((id) => notifyRover(id));
|
||||
}, 100); // 10Hz visual updates
|
||||
}
|
||||
```
|
||||
|
||||
Then `handleSensorFrame` writes latest data immediately but calls `scheduleNotify(roverId)`.
|
||||
|
||||
### Option B: Separate Raw Telemetry From Visual Telemetry
|
||||
|
||||
Expose:
|
||||
|
||||
```jsx
|
||||
useTelemetryFrameRaw(roverId) // high-frequency, only for critical logic
|
||||
useTelemetryFrameVisual(roverId) // throttled for UI rendering
|
||||
```
|
||||
|
||||
Use throttled visual telemetry in:
|
||||
|
||||
- TopDownMap
|
||||
- HUD overlays
|
||||
- telemetry panels
|
||||
- battery bars
|
||||
- host stats display
|
||||
|
||||
Keep raw telemetry for safety mechanisms if needed.
|
||||
|
||||
### Option C: Memoize/Split Sensor Consumers by Field
|
||||
|
||||
Do not re-render the entire map or all overlays when only one sensor field changed.
|
||||
|
||||
Examples:
|
||||
|
||||
- Battery components subscribe only to battery fields.
|
||||
- Light bump bars subscribe only to light bump fields.
|
||||
- Overcurrent overlay subscribes only to overcurrent fields.
|
||||
|
||||
This is more architectural but can reduce unnecessary work.
|
||||
|
||||
### Option D: Render TopDownMap Less Often
|
||||
|
||||
At the component level:
|
||||
|
||||
```jsx
|
||||
const visualSensors = useThrottledValue(sensors, 100);
|
||||
```
|
||||
|
||||
This is easier than changing `TelemetryContext`, but less central.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
Start with a throttled visual notification path in `TelemetryContext`. It gives the largest
|
||||
blast-radius reduction without changing every consumer immediately.
|
||||
|
||||
A reasonable target:
|
||||
|
||||
- Desktop: 10-15Hz visual updates
|
||||
- Mobile: 5-10Hz visual updates
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=18000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
- `path d` and `path fill` mutation counts should drop.
|
||||
- `LayoutCount` and `RecalcStyleCount` may drop.
|
||||
- `ScriptDuration` should drop.
|
||||
- Frame p95 should improve.
|
||||
|
||||
Socket `sensorFrame` count may remain the same if only rendering is throttled.
|
||||
|
||||
## Risks
|
||||
|
||||
- Do not throttle logic that protects the rover or sends control commands unless you are sure
|
||||
it is only display logic.
|
||||
- Operators may expect smooth sensor visuals, but 10Hz is usually enough for dashboards.
|
||||
- Make sure low battery/overcurrent warnings still feel responsive.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Issue 005: ReplaySourcesPanel Repeated DOM Commits
|
||||
|
||||
## Summary
|
||||
|
||||
`ReplaySourcesPanel` appears to re-commit many input/checkbox attributes repeatedly on `/`,
|
||||
especially in mobile portrait where the panel is always mounted near the top of the page.
|
||||
|
||||
This may be a component-local issue, or it may be caused by broader context/session updates.
|
||||
Tackle it after issues 001 and 002 unless profiling still shows it as large.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium.
|
||||
|
||||
It is visible in mutation probes, but likely partly downstream of broader invalidation.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/components/ReplaySourcesPanel/index.jsx`
|
||||
- `webui/src/App.jsx`
|
||||
- `webui/src/context/SessionContext.jsx`
|
||||
- `webui/src/settings/SettingsProvider.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In an 18s `/` audit at mobile viewport with 6x CPU throttle, top mutation targets included:
|
||||
|
||||
```txt
|
||||
input.accent-emerald-400 name: 5300
|
||||
input.accent-emerald-400 type: 2650
|
||||
input#replay-sources-mobile-portrait-title name: 666
|
||||
input#replay-sources-mobile-portrait-title type: 333
|
||||
```
|
||||
|
||||
The `accent-emerald-400` class maps to `ReplaySourcesPanel` checkboxes:
|
||||
|
||||
```jsx
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSidebar}
|
||||
...
|
||||
className="accent-emerald-400"
|
||||
/>
|
||||
```
|
||||
|
||||
and source item checkboxes:
|
||||
|
||||
```jsx
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(item.key)}
|
||||
onChange={() => onToggle(item.key)}
|
||||
className="accent-emerald-400"
|
||||
/>
|
||||
```
|
||||
|
||||
The mobile portrait route mounts it here:
|
||||
|
||||
```jsx
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||
```
|
||||
|
||||
in `App.jsx`.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Repeated DOM attribute commits are a symptom of repeated React commits. Even if the attribute
|
||||
values do not change semantically, React still touches DOM attributes when the subtree
|
||||
rerenders/commits.
|
||||
|
||||
The panel is not central to driving, so it should not be doing meaningful work during normal
|
||||
control-page idle.
|
||||
|
||||
## Likely Causes
|
||||
|
||||
The component subscribes to several session slices:
|
||||
|
||||
```jsx
|
||||
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
```
|
||||
|
||||
Potential problems:
|
||||
|
||||
- `session:sync` may provide new array/object identities even when contents are unchanged.
|
||||
- `normalizeSources(replaySources || [])` runs every render and produces new objects.
|
||||
- `GroupList` is not memoized.
|
||||
- `selected.includes(item.key)` is recomputed for each item on each render.
|
||||
- `remainingMs` interval updates every 250ms when cooldown is active.
|
||||
- Parent/mobile layout re-renders can also re-render this panel.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Memoize Normalized Sources
|
||||
|
||||
```jsx
|
||||
const sources = useMemo(
|
||||
() => normalizeSources(replaySources || []),
|
||||
[replaySources],
|
||||
);
|
||||
```
|
||||
|
||||
This is already not memoized in the current code.
|
||||
|
||||
### Option B: Use Shallow Equality Selectors
|
||||
|
||||
For arrays like `replaySources` and `roster`, use a selector/equality function that avoids
|
||||
rerendering when content did not change.
|
||||
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
const replaySources = useSessionSelector(
|
||||
(state) => state.session?.replaySources ?? [],
|
||||
shallowReplaySourcesEqual,
|
||||
);
|
||||
```
|
||||
|
||||
### Option C: Memoize GroupList
|
||||
|
||||
```jsx
|
||||
const GroupList = React.memo(function GroupList(...) { ... });
|
||||
```
|
||||
|
||||
Also memoize:
|
||||
|
||||
```jsx
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
```
|
||||
|
||||
Then use `selectedSet.has(item.key)`.
|
||||
|
||||
### Option D: Do Not Mount Replay Panel By Default on Mobile
|
||||
|
||||
If replay is not a core mobile driving workflow, consider putting it behind a tab/accordion
|
||||
or lazy mounting it only when expanded.
|
||||
|
||||
This is a product decision.
|
||||
|
||||
### Option E: Stop Passing Unstable Callbacks
|
||||
|
||||
Memoize callbacks:
|
||||
|
||||
```jsx
|
||||
const toggleKey = useCallback((key) => { ... }, []);
|
||||
const handleReplay = useCallback(async () => { ... }, [...]);
|
||||
```
|
||||
|
||||
This helps if child components are memoized.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
1. Fix issues 001 and 002 first.
|
||||
2. Rerun root runtime audit.
|
||||
3. If `ReplaySourcesPanel` still dominates mutation targets:
|
||||
- memoize `sources`
|
||||
- memoize `GroupList`
|
||||
- add shallow equality selectors
|
||||
- consider lazy mounting on mobile
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=18000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected improvement:
|
||||
|
||||
```txt
|
||||
input.accent-emerald-400 name/type mutations should drop sharply
|
||||
replay title input name/type mutations should drop
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Replay source selections and saved panel settings must still update correctly.
|
||||
- If lazy mounting, preserve persisted title/sidebar settings.
|
||||
- Do not break replay cooldown display.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Issue 006: Chat and Nickname Composer Churn
|
||||
|
||||
## Summary
|
||||
|
||||
The mobile `/` page defaults to the chat tab, and the chat/nickname composer shows repeated
|
||||
DOM attribute commits in runtime audits. This is not the top CPU issue, but it is part of
|
||||
steady idle churn.
|
||||
|
||||
## Severity
|
||||
|
||||
Medium-low to medium.
|
||||
|
||||
Fix after the listener/context/log/telemetry work unless chat remains high in mutation
|
||||
reports.
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `webui/src/components/ChatPanel/index.jsx`
|
||||
- `webui/src/components/NicknameForm/index.jsx`
|
||||
- `webui/src/context/ChatContext.jsx`
|
||||
- `webui/src/App.jsx`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In an 18s `/` audit at mobile viewport with 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
input.field-input.chat-composer-input name: 666
|
||||
input.field-input.flex-1 name: 1328
|
||||
input.field-input.w-full name: 1332
|
||||
input.accent-cyan-500 name: 1324
|
||||
input.accent-cyan-500 type: 662
|
||||
```
|
||||
|
||||
These map to:
|
||||
|
||||
- chat composer input
|
||||
- nickname form input
|
||||
- TTS/speak checkbox
|
||||
- related chat form controls
|
||||
|
||||
The chat panel is mounted by default in mobile feature tabs:
|
||||
|
||||
```jsx
|
||||
const [activeTab, setActiveTab] = useState('chat');
|
||||
...
|
||||
<TabPanel id="chat">
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
...
|
||||
</TabPanel>
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
The chat UI is useful, but it should not re-commit its composer inputs hundreds of times
|
||||
while the user is simply watching/driving.
|
||||
|
||||
Also, text inputs and focus-related logic interact with keyboard driving. Excess churn here
|
||||
can have indirect effects on input handling.
|
||||
|
||||
## Likely Causes
|
||||
|
||||
`ChatPanel` consumes:
|
||||
|
||||
```jsx
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const { messages, typing, sendMessage, ... } = useChat();
|
||||
const { value: ttsSettings, save: saveTtsSettings } = useSettingsNamespace('tts', ...);
|
||||
```
|
||||
|
||||
It also has effects that sync local state from settings:
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
const nextEngine = ...
|
||||
if (engine !== nextEngine) setEngine(nextEngine);
|
||||
...
|
||||
}, [engine, googlePitch, googleSpeed, pitch, ttsSettings..., voice]);
|
||||
```
|
||||
|
||||
Potential problems:
|
||||
|
||||
- broad session/chat context updates rerender the whole panel
|
||||
- message list and composer are in the same component
|
||||
- typing events cause panel updates
|
||||
- nickname form may be rerendering with the whole chat panel
|
||||
- TTS settings sync effect has many dependencies
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Split ChatPanel Into Memoized Subcomponents
|
||||
|
||||
Separate:
|
||||
|
||||
- `ChatMessageList`
|
||||
- `ChatComposer`
|
||||
- `TtsControls`
|
||||
- `NicknameForm`
|
||||
|
||||
Only `ChatMessageList` should rerender when messages change.
|
||||
Only `ChatComposer` should rerender when draft/sending/focus changes.
|
||||
|
||||
### Option B: Memoize Composer
|
||||
|
||||
```jsx
|
||||
const ChatComposer = React.memo(function ChatComposer(props) { ... });
|
||||
```
|
||||
|
||||
Pass stable callbacks where possible.
|
||||
|
||||
### Option C: Debounce Typing Updates
|
||||
|
||||
`setTypingActive(Boolean(next.trim()))` fires on every keystroke. That is fine while typing,
|
||||
but should not be involved during idle. If it causes socket chatter, debounce it.
|
||||
|
||||
### Option D: Stabilize NicknameForm
|
||||
|
||||
If `NicknameForm` consumes settings/session state broadly, make it use precise selectors and
|
||||
memoize it.
|
||||
|
||||
### Option E: Avoid Re-Syncing Local TTS State Too Often
|
||||
|
||||
The settings sync effect can be simplified or guarded so it only runs when the settings
|
||||
object actually changes, not on every local state update.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
1. Fix issues 001-004 first.
|
||||
2. Rerun mutation audit.
|
||||
3. If chat/nickname inputs remain high:
|
||||
- split `ChatPanel`
|
||||
- memoize composer
|
||||
- stabilize `NicknameForm`
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=18000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected improvements:
|
||||
|
||||
```txt
|
||||
chat-composer-input mutations should drop
|
||||
NicknameForm input mutations should drop
|
||||
accent-cyan-500 checkbox mutations should drop
|
||||
```
|
||||
|
||||
Also manually verify:
|
||||
|
||||
- typing
|
||||
- sending chat
|
||||
- TTS options
|
||||
- nickname edit/save
|
||||
- Enter-to-focus/send chat shortcut
|
||||
|
||||
## Risks
|
||||
|
||||
- Chat focus behavior is tied to keyboard controls.
|
||||
- Splitting components can accidentally break `registerInputRef`.
|
||||
- TTS settings must still persist correctly.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# FIXED!
|
||||
# Issue 007: Timers and Polling Cleanup
|
||||
|
||||
## Summary
|
||||
|
||||
The `/` page creates multiple intervals and timeouts. They are not the dominant problem,
|
||||
but they contribute to steady background work and can wake components even when hidden.
|
||||
|
||||
## Severity
|
||||
|
||||
Low to medium.
|
||||
|
||||
Address after the larger structural issues unless a specific timer is found to be hot.
|
||||
|
||||
## Affected Files
|
||||
|
||||
Known or likely timer users:
|
||||
|
||||
- `webui/src/components/AlertFeed/index.jsx`
|
||||
- `webui/src/components/ReplaySourcesPanel/index.jsx`
|
||||
- `webui/src/components/HudOverlays/TurnsOverlay/index.jsx`
|
||||
- `webui/src/components/RoverQueuesPanel/index.jsx`
|
||||
- `webui/src/components/ModeGateOverlay/index.jsx`
|
||||
- `webui/src/components/SocketConnectionPill/index.jsx`
|
||||
- `webui/src/controls/ControlContext.jsx` horn heat interval
|
||||
- `webui/src/controls/overcurrentLimiter.js`
|
||||
|
||||
## Evidence
|
||||
|
||||
Artifact:
|
||||
|
||||
```sh
|
||||
perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json
|
||||
```
|
||||
|
||||
In an 18s `/` audit at mobile viewport with 6x CPU throttle:
|
||||
|
||||
```txt
|
||||
intervals created: 22
|
||||
timeouts created: 11
|
||||
interval fires: 148
|
||||
timeout fires: 6
|
||||
```
|
||||
|
||||
Most common timer registrations:
|
||||
|
||||
```txt
|
||||
interval:200 7
|
||||
timeout:12000 4
|
||||
interval:60000 4
|
||||
interval:250 4
|
||||
interval:3000 4
|
||||
interval:1000 2
|
||||
```
|
||||
|
||||
In a 30s audit:
|
||||
|
||||
```txt
|
||||
interval fires: 593
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Timers wake the main thread. On a fast desktop they disappear into the noise; on a throttled
|
||||
mobile CPU, timers can combine with socket traffic and React work to create visible stalls.
|
||||
|
||||
The timer issue is not that any one interval is terrible. It is that many UI elements keep
|
||||
time independently.
|
||||
|
||||
## Likely Sources
|
||||
|
||||
### ReplaySourcesPanel
|
||||
|
||||
Cooldown display:
|
||||
|
||||
```jsx
|
||||
const interval = setInterval(update, 250);
|
||||
```
|
||||
|
||||
Only needed while cooldown is active and panel is visible.
|
||||
|
||||
### AlertFeed
|
||||
|
||||
Likely uses a frequent interval for alert lifetimes. This should only run while alerts exist.
|
||||
|
||||
### TurnsOverlay / RoverQueuesPanel
|
||||
|
||||
Countdown displays often use `setInterval(() => setNow(Date.now()), 250 or 1000)`.
|
||||
|
||||
### ControlContext Horn Heat
|
||||
|
||||
Horn heat interval at 100ms only runs when horn is active/cooling:
|
||||
|
||||
```jsx
|
||||
const tickMs = 100;
|
||||
const interval = setInterval(...)
|
||||
```
|
||||
|
||||
This is probably okay, but verify it only runs when needed.
|
||||
|
||||
## Fix Strategies
|
||||
|
||||
### Option A: Visibility-Gate Timers
|
||||
|
||||
Only run panel-specific timers when the panel is visible/open.
|
||||
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
if (!isVisible || !needsCountdown) return;
|
||||
```
|
||||
|
||||
### Option B: Coarse Intervals
|
||||
|
||||
Use 1000ms for human-readable countdowns unless sub-second precision matters.
|
||||
|
||||
Replay cooldowns and queue countdowns probably do not need 250ms.
|
||||
|
||||
### Option C: Shared Clock Store
|
||||
|
||||
Instead of each component creating its own interval, create one shared clock:
|
||||
|
||||
```jsx
|
||||
useClock(1000)
|
||||
useClock(250)
|
||||
```
|
||||
|
||||
Then multiple components can share a single interval.
|
||||
|
||||
### Option D: CSS Animations for Pure Visual Expiry
|
||||
|
||||
For alert progress bars, use CSS animation if the UI does not need React state each tick.
|
||||
|
||||
## Recommended Path
|
||||
|
||||
After major fixes, rerun the runtime audit. If intervals still show high:
|
||||
|
||||
1. Increase replay cooldown interval from 250ms to 1000ms.
|
||||
2. Gate timers by tab visibility.
|
||||
3. Create a shared clock hook for countdown UI.
|
||||
4. Convert alert lifetime visuals to CSS where possible.
|
||||
|
||||
## Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- lower `intervals`
|
||||
- lower `intervalFires`
|
||||
- no visible regression in countdowns/alerts
|
||||
|
||||
## Risks
|
||||
|
||||
- Timers used for control safety should not be slowed without careful review.
|
||||
- Alert expiration should still happen reliably.
|
||||
- Queue/turn countdowns can update less often, but should not become misleading.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# `/` CPU Performance Issue Index
|
||||
|
||||
This folder breaks the live-site `/` CPU investigation into separate, work-ready issues.
|
||||
Each issue can be handled in its own chat or branch without needing to re-run the whole
|
||||
investigation first.
|
||||
|
||||
The testing target was the deployed page:
|
||||
|
||||
```sh
|
||||
https://rover.otter.land/
|
||||
```
|
||||
|
||||
The important local perf tools are:
|
||||
|
||||
```sh
|
||||
node perf/live-cpu-profile.mjs https://rover.otter.land/ perf/results
|
||||
node perf/live-listener-audit.mjs https://rover.otter.land/ perf/results
|
||||
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 node perf/live-root-runtime-audit.mjs https://rover.otter.land/ perf/results
|
||||
```
|
||||
|
||||
The strongest `/` artifacts from this investigation are:
|
||||
|
||||
- `perf/results/2026-06-11T03-15-51-398Z/report.json`
|
||||
Clean `/` profile, mobile viewport, 8x CPU throttle.
|
||||
- `perf/results/2026-06-11T03-03-31-625Z-listeners/listener-report.json`
|
||||
Listener add/remove stack audit.
|
||||
- `perf/results/2026-06-11T03-21-51-263Z-root-runtime/root-runtime-report.json`
|
||||
`/` runtime audit, mobile viewport, 6x CPU throttle, 30s sample.
|
||||
- `perf/results/2026-06-11T03-22-44-714Z-root-runtime/root-runtime-report.json`
|
||||
`/` runtime audit, mobile viewport, 6x CPU throttle, 18s sample with mutation targets.
|
||||
|
||||
## Ranked Issues
|
||||
|
||||
1. [Global input listener churn](./001-global-input-listener-churn.md)
|
||||
2. [ControlContext broad invalidation](./002-control-context-broad-invalidation.md)
|
||||
3. [High-volume socket log stream](./003-high-volume-log-stream.md)
|
||||
4. [Sensor telemetry render frequency](./004-sensor-telemetry-render-frequency.md)
|
||||
5. [ReplaySourcesPanel repeated DOM commits](./005-replay-sources-panel-churn.md)
|
||||
6. [Chat and nickname composer churn](./006-chat-and-nickname-churn.md)
|
||||
7. [Timers and polling cleanup](./007-timers-and-polling.md)
|
||||
|
||||
## Quick Read
|
||||
|
||||
The highest-confidence first fix is issue 001. It is directly measured, large, and
|
||||
structurally fixable: install keyboard/gamepad global listeners once and have them read
|
||||
latest state/actions from refs.
|
||||
|
||||
Issue 002 is the likely multiplier behind issue 001 and several UI churn symptoms. The
|
||||
current `ControlContext` value changes broadly, and many callbacks depend on `state` or
|
||||
`pipeline`; that causes consumers/effects to refresh even when the visible behavior did
|
||||
not materially change.
|
||||
|
||||
Issues 003 and 004 are independent live-data pressure: the `/` page receives a lot of
|
||||
socket traffic, especially `log:entry` and `sensorFrame`, and those streams feed UI work.
|
||||
|
||||
Issue 005 and issue 006 are component-level symptoms visible in DOM mutation probes. They
|
||||
should probably be tackled after the context/listener work, because broad invalidation may
|
||||
be causing some of their repeated commits.
|
||||
|
||||
## Important Scope Note
|
||||
|
||||
There are also `/spectate` results in `perf/results` because the investigation briefly
|
||||
compared routes. The user later clarified that `/spectate` is not the current priority.
|
||||
These issue writeups intentionally focus on `/` only.
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const url = process.argv[2] || 'https://rover.otter.land';
|
||||
const outDir = process.argv[3] || path.resolve('perf/results');
|
||||
const cpuThrottle = Number(process.env.CPU_THROTTLE || 1);
|
||||
const viewport = (process.env.VIEWPORT || '1440x950').split('x').map((part) => Number(part));
|
||||
const isMobile = process.env.MOBILE === '1';
|
||||
const runId = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const runDir = path.join(outDir, runId);
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function summarizeCpuProfile(profile) {
|
||||
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
|
||||
const totals = new Map();
|
||||
const samples = profile.samples || [];
|
||||
const deltas = profile.timeDeltas || [];
|
||||
let totalUs = 0;
|
||||
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const delta = deltas[i] || 0;
|
||||
totalUs += delta;
|
||||
const node = nodes.get(samples[i]);
|
||||
if (!node) continue;
|
||||
const frame = node.callFrame || {};
|
||||
const key = `${frame.url || '(anonymous)'}::${frame.functionName || '(anonymous)'}`;
|
||||
const prev = totals.get(key) || {
|
||||
url: frame.url || '',
|
||||
functionName: frame.functionName || '(anonymous)',
|
||||
lineNumber: frame.lineNumber,
|
||||
columnNumber: frame.columnNumber,
|
||||
selfMs: 0,
|
||||
samples: 0,
|
||||
};
|
||||
prev.selfMs += delta / 1000;
|
||||
prev.samples += 1;
|
||||
totals.set(key, prev);
|
||||
}
|
||||
|
||||
return {
|
||||
totalProfileMs: totalUs / 1000,
|
||||
sampleCount: samples.length,
|
||||
topSelfTime: [...totals.values()]
|
||||
.sort((a, b) => b.selfMs - a.selfMs)
|
||||
.slice(0, 30),
|
||||
};
|
||||
}
|
||||
|
||||
function interestingMetrics(metrics) {
|
||||
const wanted = [
|
||||
'Timestamp',
|
||||
'Documents',
|
||||
'Frames',
|
||||
'JSEventListeners',
|
||||
'Nodes',
|
||||
'LayoutCount',
|
||||
'RecalcStyleCount',
|
||||
'LayoutDuration',
|
||||
'RecalcStyleDuration',
|
||||
'ScriptDuration',
|
||||
'TaskDuration',
|
||||
'JSHeapUsedSize',
|
||||
'JSHeapTotalSize',
|
||||
];
|
||||
return Object.fromEntries(
|
||||
metrics.metrics
|
||||
.filter((metric) => wanted.includes(metric.name))
|
||||
.map((metric) => [metric.name, metric.value]),
|
||||
);
|
||||
}
|
||||
|
||||
async function performInteractions(page) {
|
||||
await page.mouse.wheel(0, 1400);
|
||||
await sleep(800);
|
||||
await page.mouse.wheel(0, -900);
|
||||
await sleep(800);
|
||||
await page.keyboard.press('Tab');
|
||||
await sleep(300);
|
||||
await page.keyboard.press('Tab');
|
||||
await sleep(300);
|
||||
|
||||
const buttons = await page.locator('button:visible').elementHandles();
|
||||
for (const button of buttons.slice(0, 3)) {
|
||||
const box = await button.boundingBox();
|
||||
if (!box || box.width < 8 || box.height < 8) continue;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await fs.mkdir(runDir, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport[0] || 1440, height: viewport[1] || 950 },
|
||||
deviceScaleFactor: 1,
|
||||
isMobile,
|
||||
hasTouch: isMobile,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
await context.addInitScript(() => {
|
||||
window.__perfProbe = {
|
||||
longTasks: [],
|
||||
frames: [],
|
||||
intervalDrifts: [],
|
||||
};
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
window.__perfProbe.longTasks.push({
|
||||
name: entry.name,
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
});
|
||||
}
|
||||
}).observe({ type: 'longtask', buffered: true });
|
||||
} catch {}
|
||||
|
||||
let lastFrame = performance.now();
|
||||
const frameLoop = (now) => {
|
||||
window.__perfProbe.frames.push(now - lastFrame);
|
||||
lastFrame = now;
|
||||
requestAnimationFrame(frameLoop);
|
||||
};
|
||||
requestAnimationFrame(frameLoop);
|
||||
|
||||
let lastTick = performance.now();
|
||||
setInterval(() => {
|
||||
const now = performance.now();
|
||||
window.__perfProbe.intervalDrifts.push(now - lastTick - 250);
|
||||
lastTick = now;
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const consoleMessages = [];
|
||||
const failedRequests = [];
|
||||
const responses = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
consoleMessages.push({
|
||||
type: message.type(),
|
||||
text: message.text().slice(0, 1000),
|
||||
location: message.location(),
|
||||
});
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
failedRequests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
failure: request.failure()?.errorText,
|
||||
});
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
const status = response.status();
|
||||
if (status >= 400) responses.push({ url: response.url(), status });
|
||||
});
|
||||
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Performance.enable');
|
||||
await cdp.send('Profiler.enable');
|
||||
if (cpuThrottle > 1) {
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottle });
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForLoadState('load', { timeout: 60000 }).catch(() => {});
|
||||
await sleep(5000);
|
||||
const afterLoadMetrics = interestingMetrics(await cdp.send('Performance.getMetrics'));
|
||||
await page.screenshot({ path: path.join(runDir, 'loaded.png'), fullPage: true });
|
||||
|
||||
await cdp.send('Profiler.start');
|
||||
await sleep(15000);
|
||||
await performInteractions(page);
|
||||
await sleep(15000);
|
||||
const { profile } = await cdp.send('Profiler.stop');
|
||||
const endMetrics = interestingMetrics(await cdp.send('Performance.getMetrics'));
|
||||
|
||||
const perfProbe = await page.evaluate(() => window.__perfProbe);
|
||||
await page.screenshot({ path: path.join(runDir, 'after-profile.png'), fullPage: true });
|
||||
|
||||
const cpuSummary = summarizeCpuProfile(profile);
|
||||
const frameDeltas = perfProbe.frames.slice(5);
|
||||
const longTasks = perfProbe.longTasks.sort((a, b) => b.duration - a.duration);
|
||||
const intervalDrifts = perfProbe.intervalDrifts;
|
||||
const percentile = (values, p) => {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
||||
};
|
||||
|
||||
const report = {
|
||||
url,
|
||||
runDir,
|
||||
elapsedMs: Date.now() - start,
|
||||
userAgent: await page.evaluate(() => navigator.userAgent),
|
||||
title: await page.title(),
|
||||
finalUrl: page.url(),
|
||||
environment: {
|
||||
cpuThrottle,
|
||||
viewport: { width: viewport[0] || 1440, height: viewport[1] || 950 },
|
||||
isMobile,
|
||||
},
|
||||
metrics: {
|
||||
afterLoad: afterLoadMetrics,
|
||||
end: endMetrics,
|
||||
delta: Object.fromEntries(
|
||||
Object.keys(endMetrics).map((key) => [key, endMetrics[key] - (afterLoadMetrics[key] || 0)]),
|
||||
),
|
||||
},
|
||||
cpuSummary,
|
||||
responsiveness: {
|
||||
longTaskCount: longTasks.length,
|
||||
topLongTasks: longTasks.slice(0, 20),
|
||||
frameCount: frameDeltas.length,
|
||||
averageFrameMs: frameDeltas.reduce((sum, value) => sum + value, 0) / frameDeltas.length,
|
||||
p95FrameMs: percentile(frameDeltas, 0.95),
|
||||
p99FrameMs: percentile(frameDeltas, 0.99),
|
||||
intervalDriftCount: intervalDrifts.length,
|
||||
averageIntervalDriftMs:
|
||||
intervalDrifts.reduce((sum, value) => sum + value, 0) / intervalDrifts.length,
|
||||
p95IntervalDriftMs: percentile(intervalDrifts, 0.95),
|
||||
p99IntervalDriftMs: percentile(intervalDrifts, 0.99),
|
||||
},
|
||||
consoleMessages,
|
||||
failedRequests,
|
||||
errorResponses: responses,
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(runDir, 'cpu-profile.cpuprofile'), JSON.stringify(profile));
|
||||
await fs.writeFile(path.join(runDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const url = process.argv[2] || 'https://rover.otter.land';
|
||||
const outDir = process.argv[3] || path.resolve('perf/results');
|
||||
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-listeners`;
|
||||
const runDir = path.join(outDir, runId);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function run() {
|
||||
await fs.mkdir(runDir, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 950 },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
await context.addInitScript(() => {
|
||||
const originalAdd = EventTarget.prototype.addEventListener;
|
||||
const originalRemove = EventTarget.prototype.removeEventListener;
|
||||
const listenerIds = new WeakMap();
|
||||
let nextListenerId = 1;
|
||||
const rows = new Map();
|
||||
|
||||
function listenerId(listener) {
|
||||
if ((typeof listener !== 'function' && typeof listener !== 'object') || listener === null) {
|
||||
return String(listener);
|
||||
}
|
||||
let id = listenerIds.get(listener);
|
||||
if (!id) {
|
||||
id = nextListenerId++;
|
||||
listenerIds.set(listener, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function targetName(target) {
|
||||
if (target === window) return 'window';
|
||||
if (target === document) return 'document';
|
||||
if (target?.nodeType === Node.DOCUMENT_NODE) return 'document';
|
||||
if (target?.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = target.tagName?.toLowerCase() || 'element';
|
||||
const id = target.id ? `#${target.id}` : '';
|
||||
const className = String(target.className || '').trim().split(/\s+/).slice(0, 3).join('.');
|
||||
return `${tag}${id}${className ? `.${className}` : ''}`;
|
||||
}
|
||||
if (target?.constructor?.name) return target.constructor.name;
|
||||
return String(target);
|
||||
}
|
||||
|
||||
function shortStack() {
|
||||
return String(new Error().stack || '')
|
||||
.split('\n')
|
||||
.slice(3, 9)
|
||||
.map((line) => line.trim().replace(location.origin, ''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function note(kind, target, type, listener) {
|
||||
const stack = shortStack();
|
||||
const key = `${targetName(target)}::${type}::${stack}`;
|
||||
const row = rows.get(key) || {
|
||||
target: targetName(target),
|
||||
type: String(type),
|
||||
stack,
|
||||
adds: 0,
|
||||
removes: 0,
|
||||
activeApprox: 0,
|
||||
firstSeen: performance.now(),
|
||||
lastSeen: 0,
|
||||
};
|
||||
row[kind] += 1;
|
||||
row.activeApprox += kind === 'adds' ? 1 : -1;
|
||||
row.lastSeen = performance.now();
|
||||
row.exampleListener = String(listener).slice(0, 180);
|
||||
rows.set(key, row);
|
||||
}
|
||||
|
||||
EventTarget.prototype.addEventListener = function patchedAdd(type, listener, options) {
|
||||
note('adds', this, type, listener);
|
||||
return originalAdd.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
EventTarget.prototype.removeEventListener = function patchedRemove(type, listener, options) {
|
||||
note('removes', this, type, listener);
|
||||
return originalRemove.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
window.__listenerAudit = {
|
||||
snapshot() {
|
||||
return [...rows.values()]
|
||||
.sort((a, b) => Math.abs(b.activeApprox) - Math.abs(a.activeApprox) || b.adds - a.adds)
|
||||
.slice(0, 100);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForLoadState('load', { timeout: 60000 }).catch(() => {});
|
||||
await sleep(10000);
|
||||
const first = await page.evaluate(() => window.__listenerAudit.snapshot());
|
||||
await page.mouse.wheel(0, 1500);
|
||||
await sleep(1000);
|
||||
await page.mouse.wheel(0, -900);
|
||||
await sleep(20000);
|
||||
const second = await page.evaluate(() => window.__listenerAudit.snapshot());
|
||||
|
||||
const report = { url, finalUrl: page.url(), first, second };
|
||||
await fs.writeFile(path.join(runDir, 'listener-report.json'), JSON.stringify(report, null, 2));
|
||||
await page.screenshot({ path: path.join(runDir, 'listener-audit.png'), fullPage: true });
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const url = process.argv[2] || 'https://rover.otter.land/';
|
||||
const outDir = process.argv[3] || path.resolve('perf/results');
|
||||
const cpuThrottle = Number(process.env.CPU_THROTTLE || 6);
|
||||
const viewport = (process.env.VIEWPORT || '390x844').split('x').map((part) => Number(part));
|
||||
const isMobile = process.env.MOBILE !== '0';
|
||||
const sampleMs = Number(process.env.SAMPLE_MS || 45000);
|
||||
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-root-runtime`;
|
||||
const runDir = path.join(outDir, runId);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function percentile(values, p) {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(runDir, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport[0] || 390, height: viewport[1] || 844 },
|
||||
deviceScaleFactor: isMobile ? 3 : 1,
|
||||
isMobile,
|
||||
hasTouch: isMobile,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
await context.addInitScript(() => {
|
||||
const data = {
|
||||
startedAt: performance.now(),
|
||||
listeners: new Map(),
|
||||
timers: new Map(),
|
||||
timeouts: 0,
|
||||
intervals: 0,
|
||||
intervalFires: 0,
|
||||
timeoutFires: 0,
|
||||
webSocketMessages: [],
|
||||
webSocketCounts: new Map(),
|
||||
webSocketBytesByEvent: new Map(),
|
||||
mutations: [],
|
||||
mutationTargets: new Map(),
|
||||
frames: [],
|
||||
longTasks: [],
|
||||
consoleErrors: [],
|
||||
};
|
||||
|
||||
function localPercentile(values, p) {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
||||
}
|
||||
|
||||
function targetName(target) {
|
||||
if (target === window) return 'window';
|
||||
if (target === document) return 'document';
|
||||
if (target?.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = target.tagName?.toLowerCase() || 'element';
|
||||
const id = target.id ? `#${target.id}` : '';
|
||||
const classes = String(target.className || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
||||
return `${tag}${id}${classes ? `.${classes}` : ''}`;
|
||||
}
|
||||
return target?.constructor?.name || String(target);
|
||||
}
|
||||
|
||||
function listenerKey(target, type, kind) {
|
||||
return `${kind}:${targetName(target)}:${String(type)}`;
|
||||
}
|
||||
|
||||
const originalAdd = EventTarget.prototype.addEventListener;
|
||||
const originalRemove = EventTarget.prototype.removeEventListener;
|
||||
EventTarget.prototype.addEventListener = function add(type, listener, options) {
|
||||
const key = listenerKey(this, type, 'add');
|
||||
data.listeners.set(key, (data.listeners.get(key) || 0) + 1);
|
||||
return originalAdd.call(this, type, listener, options);
|
||||
};
|
||||
EventTarget.prototype.removeEventListener = function remove(type, listener, options) {
|
||||
const key = listenerKey(this, type, 'remove');
|
||||
data.listeners.set(key, (data.listeners.get(key) || 0) + 1);
|
||||
return originalRemove.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
const originalSetInterval = window.setInterval;
|
||||
const originalSetTimeout = window.setTimeout;
|
||||
window.setInterval = function patchedSetInterval(fn, delay, ...args) {
|
||||
data.intervals += 1;
|
||||
const key = `interval:${delay}`;
|
||||
data.timers.set(key, (data.timers.get(key) || 0) + 1);
|
||||
return originalSetInterval.call(this, (...innerArgs) => {
|
||||
data.intervalFires += 1;
|
||||
return typeof fn === 'function' ? fn(...innerArgs) : eval(fn);
|
||||
}, delay, ...args);
|
||||
};
|
||||
window.setTimeout = function patchedSetTimeout(fn, delay, ...args) {
|
||||
data.timeouts += 1;
|
||||
const key = `timeout:${delay}`;
|
||||
data.timers.set(key, (data.timers.get(key) || 0) + 1);
|
||||
return originalSetTimeout.call(this, (...innerArgs) => {
|
||||
data.timeoutFires += 1;
|
||||
return typeof fn === 'function' ? fn(...innerArgs) : eval(fn);
|
||||
}, delay, ...args);
|
||||
};
|
||||
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
if (OriginalWebSocket) {
|
||||
window.WebSocket = function PatchedWebSocket(...args) {
|
||||
const ws = new OriginalWebSocket(...args);
|
||||
ws.addEventListener('message', (event) => {
|
||||
const raw = event.data;
|
||||
const text = typeof raw === 'string' ? raw : '';
|
||||
const match = text.slice(0, 120).match(/\["([^"\]]+)/);
|
||||
const eventName = match?.[1] || text.slice(0, 12);
|
||||
data.webSocketCounts.set(eventName, (data.webSocketCounts.get(eventName) || 0) + 1);
|
||||
data.webSocketBytesByEvent.set(
|
||||
eventName,
|
||||
(data.webSocketBytesByEvent.get(eventName) || 0) + (typeof raw === 'string' ? raw.length : raw?.byteLength || raw?.size || 0),
|
||||
);
|
||||
data.webSocketMessages.push({
|
||||
at: Math.round(performance.now()),
|
||||
size: typeof raw === 'string' ? raw.length : raw?.byteLength || raw?.size || 0,
|
||||
prefix: text.slice(0, 80),
|
||||
});
|
||||
if (data.webSocketMessages.length > 1000) data.webSocketMessages.shift();
|
||||
});
|
||||
return ws;
|
||||
};
|
||||
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
||||
Object.assign(window.WebSocket, OriginalWebSocket);
|
||||
}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
data.longTasks.push({ startTime: entry.startTime, duration: entry.duration, name: entry.name });
|
||||
}
|
||||
}).observe({ type: 'longtask', buffered: true });
|
||||
} catch {}
|
||||
|
||||
let lastFrame = performance.now();
|
||||
requestAnimationFrame(function frame(now) {
|
||||
data.frames.push(now - lastFrame);
|
||||
lastFrame = now;
|
||||
requestAnimationFrame(frame);
|
||||
});
|
||||
|
||||
new MutationObserver((records) => {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let attrs = 0;
|
||||
let text = 0;
|
||||
for (const record of records) {
|
||||
added += record.addedNodes?.length || 0;
|
||||
removed += record.removedNodes?.length || 0;
|
||||
if (record.type === 'attributes') attrs += 1;
|
||||
if (record.type === 'characterData') text += 1;
|
||||
if (record.type === 'attributes' || record.type === 'characterData') {
|
||||
const key = `${targetName(record.target)}:${record.type}:${record.attributeName || ''}`;
|
||||
data.mutationTargets.set(key, (data.mutationTargets.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
data.mutations.push({ at: Math.round(performance.now()), records: records.length, added, removed, attrs, text });
|
||||
if (data.mutations.length > 1000) data.mutations.shift();
|
||||
}).observe(document, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args) => {
|
||||
data.consoleErrors.push(args.map((arg) => String(arg)).join(' ').slice(0, 500));
|
||||
return originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
window.__rootAudit = {
|
||||
snapshot() {
|
||||
const frames = data.frames.slice(5);
|
||||
const messages = data.webSocketMessages;
|
||||
const socketBuckets = new Map(
|
||||
[...data.webSocketCounts.entries()].map(([event, count]) => [
|
||||
event,
|
||||
{ event, count, bytes: data.webSocketBytesByEvent.get(event) || 0 },
|
||||
]),
|
||||
);
|
||||
return {
|
||||
elapsedMs: performance.now() - data.startedAt,
|
||||
listeners: [...data.listeners.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 40),
|
||||
timers: [...data.timers.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 40),
|
||||
timeouts: data.timeouts,
|
||||
intervals: data.intervals,
|
||||
timeoutFires: data.timeoutFires,
|
||||
intervalFires: data.intervalFires,
|
||||
webSocketMessageCount: messages.length,
|
||||
webSocketBytes: messages.reduce((sum, message) => sum + message.size, 0),
|
||||
webSocketEvents: [...socketBuckets.values()].sort((a, b) => b.count - a.count),
|
||||
mutationsTotal: data.mutations.reduce((acc, row) => ({
|
||||
records: acc.records + row.records,
|
||||
added: acc.added + row.added,
|
||||
removed: acc.removed + row.removed,
|
||||
attrs: acc.attrs + row.attrs,
|
||||
text: acc.text + row.text,
|
||||
}), { records: 0, added: 0, removed: 0, attrs: 0, text: 0 }),
|
||||
mutationTargets: [...data.mutationTargets.entries()]
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 50),
|
||||
mutationBursts: [...data.mutations].sort((a, b) => b.records - a.records).slice(0, 20),
|
||||
frames: {
|
||||
count: frames.length,
|
||||
average: frames.reduce((sum, value) => sum + value, 0) / frames.length,
|
||||
p95: localPercentile(frames, 0.95),
|
||||
p99: localPercentile(frames, 0.99),
|
||||
},
|
||||
longTasks: {
|
||||
count: data.longTasks.length,
|
||||
top: [...data.longTasks].sort((a, b) => b.duration - a.duration).slice(0, 20),
|
||||
},
|
||||
consoleErrors: data.consoleErrors,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Performance.enable');
|
||||
if (cpuThrottle > 1) await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottle });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForLoadState('load', { timeout: 90000 }).catch(() => {});
|
||||
await sleep(sampleMs);
|
||||
const metrics = await cdp.send('Performance.getMetrics');
|
||||
const snapshot = await page.evaluate(() => window.__rootAudit.snapshot());
|
||||
const report = {
|
||||
url,
|
||||
runDir,
|
||||
environment: { cpuThrottle, viewport: { width: viewport[0] || 390, height: viewport[1] || 844 }, isMobile, sampleMs },
|
||||
metrics: Object.fromEntries(metrics.metrics.map((metric) => [metric.name, metric.value])),
|
||||
snapshot,
|
||||
};
|
||||
await page.screenshot({ path: path.join(runDir, 'root-runtime.png'), fullPage: true });
|
||||
await fs.writeFile(path.join(runDir, 'root-runtime-report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const baseUrl = process.argv[2] || 'https://rover.otter.land';
|
||||
const outDir = process.argv[3] || path.resolve('perf/results');
|
||||
const cpuThrottle = Number(process.env.CPU_THROTTLE || 6);
|
||||
const viewport = (process.env.VIEWPORT || '390x844').split('x').map((part) => Number(part));
|
||||
const isMobile = process.env.MOBILE !== '0';
|
||||
const sampleMs = Number(process.env.SAMPLE_MS || 22000);
|
||||
const routes = (process.env.ROUTES || '/,/spectate,/mini,/display,/scanner')
|
||||
.split(',')
|
||||
.map((route) => route.trim())
|
||||
.filter(Boolean);
|
||||
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-route-sweep`;
|
||||
const runDir = path.join(outDir, runId);
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function percentile(values, p) {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
||||
}
|
||||
|
||||
function interestingMetrics(metrics) {
|
||||
const wanted = [
|
||||
'Documents',
|
||||
'JSEventListeners',
|
||||
'Nodes',
|
||||
'LayoutCount',
|
||||
'RecalcStyleCount',
|
||||
'LayoutDuration',
|
||||
'RecalcStyleDuration',
|
||||
'ScriptDuration',
|
||||
'TaskDuration',
|
||||
'JSHeapUsedSize',
|
||||
'JSHeapTotalSize',
|
||||
];
|
||||
return Object.fromEntries(
|
||||
metrics.metrics
|
||||
.filter((metric) => wanted.includes(metric.name))
|
||||
.map((metric) => [metric.name, metric.value]),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeCpuProfile(profile) {
|
||||
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
|
||||
const totals = new Map();
|
||||
let totalUs = 0;
|
||||
for (let i = 0; i < (profile.samples || []).length; i += 1) {
|
||||
const delta = profile.timeDeltas?.[i] || 0;
|
||||
totalUs += delta;
|
||||
const frame = nodes.get(profile.samples[i])?.callFrame || {};
|
||||
const key = `${frame.url || '(browser)'}::${frame.functionName || '(anonymous)'}:${frame.lineNumber}:${frame.columnNumber}`;
|
||||
const row = totals.get(key) || {
|
||||
url: frame.url || '',
|
||||
functionName: frame.functionName || '(anonymous)',
|
||||
lineNumber: frame.lineNumber,
|
||||
columnNumber: frame.columnNumber,
|
||||
selfMs: 0,
|
||||
samples: 0,
|
||||
};
|
||||
row.selfMs += delta / 1000;
|
||||
row.samples += 1;
|
||||
totals.set(key, row);
|
||||
}
|
||||
return {
|
||||
totalProfileMs: totalUs / 1000,
|
||||
topSelfTime: [...totals.values()].sort((a, b) => b.selfMs - a.selfMs).slice(0, 15),
|
||||
};
|
||||
}
|
||||
|
||||
async function installProbe(context) {
|
||||
await context.addInitScript(() => {
|
||||
const originalAdd = EventTarget.prototype.addEventListener;
|
||||
const originalRemove = EventTarget.prototype.removeEventListener;
|
||||
const listenerRows = new Map();
|
||||
|
||||
function targetName(target) {
|
||||
if (target === window) return 'window';
|
||||
if (target === document) return 'document';
|
||||
if (target?.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = target.tagName?.toLowerCase() || 'element';
|
||||
return target.id ? `${tag}#${target.id}` : tag;
|
||||
}
|
||||
return target?.constructor?.name || String(target);
|
||||
}
|
||||
|
||||
function stack() {
|
||||
return String(new Error().stack || '')
|
||||
.split('\n')
|
||||
.slice(3, 7)
|
||||
.map((line) => line.trim().replace(location.origin, ''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function note(kind, target, type) {
|
||||
const key = `${targetName(target)}::${type}::${stack()}`;
|
||||
const row = listenerRows.get(key) || {
|
||||
target: targetName(target),
|
||||
type: String(type),
|
||||
stack: stack(),
|
||||
adds: 0,
|
||||
removes: 0,
|
||||
};
|
||||
row[kind] += 1;
|
||||
listenerRows.set(key, row);
|
||||
}
|
||||
|
||||
EventTarget.prototype.addEventListener = function patchedAdd(type, listener, options) {
|
||||
note('adds', this, type);
|
||||
return originalAdd.call(this, type, listener, options);
|
||||
};
|
||||
EventTarget.prototype.removeEventListener = function patchedRemove(type, listener, options) {
|
||||
note('removes', this, type);
|
||||
return originalRemove.call(this, type, listener, options);
|
||||
};
|
||||
|
||||
window.__sweepProbe = {
|
||||
longTasks: [],
|
||||
frames: [],
|
||||
intervalDrifts: [],
|
||||
listenerSnapshot() {
|
||||
return [...listenerRows.values()]
|
||||
.map((row) => ({ ...row, churn: row.adds + row.removes }))
|
||||
.sort((a, b) => b.churn - a.churn)
|
||||
.slice(0, 20);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
window.__sweepProbe.longTasks.push({
|
||||
name: entry.name,
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
});
|
||||
}
|
||||
}).observe({ type: 'longtask', buffered: true });
|
||||
} catch {}
|
||||
|
||||
let lastFrame = performance.now();
|
||||
const frameLoop = (now) => {
|
||||
window.__sweepProbe.frames.push(now - lastFrame);
|
||||
lastFrame = now;
|
||||
requestAnimationFrame(frameLoop);
|
||||
};
|
||||
requestAnimationFrame(frameLoop);
|
||||
|
||||
let lastTick = performance.now();
|
||||
setInterval(() => {
|
||||
const now = performance.now();
|
||||
window.__sweepProbe.intervalDrifts.push(now - lastTick - 250);
|
||||
lastTick = now;
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
async function runRoute(browser, route) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewport[0] || 390, height: viewport[1] || 844 },
|
||||
deviceScaleFactor: isMobile ? 3 : 1,
|
||||
isMobile,
|
||||
hasTouch: isMobile,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
await installProbe(context);
|
||||
const page = await context.newPage();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('Performance.enable');
|
||||
await cdp.send('Profiler.enable');
|
||||
if (cpuThrottle > 1) {
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottle });
|
||||
}
|
||||
|
||||
const consoleErrors = [];
|
||||
const errorResponses = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning'].includes(message.type())) {
|
||||
consoleErrors.push({ type: message.type(), text: message.text().slice(0, 500) });
|
||||
}
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
errorResponses.push({ url: response.url(), status: response.status() });
|
||||
}
|
||||
});
|
||||
|
||||
const url = new URL(route, baseUrl).toString();
|
||||
const startWall = Date.now();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForLoadState('load', { timeout: 90000 }).catch(() => {});
|
||||
await sleep(4000);
|
||||
const startMetrics = interestingMetrics(await cdp.send('Performance.getMetrics'));
|
||||
await cdp.send('Profiler.start');
|
||||
await sleep(sampleMs / 2);
|
||||
await page.mouse.wheel(0, 900).catch(() => {});
|
||||
await sleep(sampleMs / 2);
|
||||
const { profile } = await cdp.send('Profiler.stop');
|
||||
const endMetrics = interestingMetrics(await cdp.send('Performance.getMetrics'));
|
||||
const probe = await page.evaluate(() => ({
|
||||
longTasks: window.__sweepProbe.longTasks,
|
||||
frames: window.__sweepProbe.frames.slice(5),
|
||||
intervalDrifts: window.__sweepProbe.intervalDrifts,
|
||||
listeners: window.__sweepProbe.listenerSnapshot(),
|
||||
}));
|
||||
const screenshotName = route === '/' ? 'root' : route.replace(/^\//, '').replace(/\W+/g, '-');
|
||||
await page.screenshot({ path: path.join(runDir, `${screenshotName || 'root'}.png`), fullPage: true });
|
||||
await context.close();
|
||||
|
||||
const delta = Object.fromEntries(
|
||||
Object.keys(endMetrics).map((key) => [key, endMetrics[key] - (startMetrics[key] || 0)]),
|
||||
);
|
||||
const frames = probe.frames;
|
||||
const longTasks = probe.longTasks.sort((a, b) => b.duration - a.duration);
|
||||
return {
|
||||
route,
|
||||
finalUrl: page.url(),
|
||||
elapsedMs: Date.now() - startWall,
|
||||
metrics: { start: startMetrics, end: endMetrics, delta },
|
||||
cpu: summarizeCpuProfile(profile),
|
||||
responsiveness: {
|
||||
longTaskCount: longTasks.length,
|
||||
worstLongTaskMs: longTasks[0]?.duration || 0,
|
||||
topLongTasks: longTasks.slice(0, 8),
|
||||
frameCount: frames.length,
|
||||
averageFrameMs: frames.reduce((sum, value) => sum + value, 0) / frames.length,
|
||||
p95FrameMs: percentile(frames, 0.95),
|
||||
p99FrameMs: percentile(frames, 0.99),
|
||||
intervalDriftP95Ms: percentile(probe.intervalDrifts, 0.95),
|
||||
intervalDriftP99Ms: percentile(probe.intervalDrifts, 0.99),
|
||||
},
|
||||
listenerChurn: probe.listeners,
|
||||
consoleErrors,
|
||||
errorResponses,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(runDir, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
const results = [];
|
||||
for (const route of routes) {
|
||||
console.error(`profiling ${route} at ${cpuThrottle}x`);
|
||||
results.push(await runRoute(browser, route));
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
const report = {
|
||||
baseUrl,
|
||||
runDir,
|
||||
environment: {
|
||||
cpuThrottle,
|
||||
viewport: { width: viewport[0] || 390, height: viewport[1] || 844 },
|
||||
isMobile,
|
||||
sampleMs,
|
||||
routes,
|
||||
},
|
||||
results,
|
||||
rankedByAverageFrame: [...results].sort(
|
||||
(a, b) => b.responsiveness.averageFrameMs - a.responsiveness.averageFrameMs,
|
||||
),
|
||||
rankedByTaskDuration: [...results].sort(
|
||||
(a, b) => b.metrics.delta.TaskDuration - a.metrics.delta.TaskDuration,
|
||||
),
|
||||
rankedByListenerChurn: [...results].sort((a, b) => {
|
||||
const churnA = a.listenerChurn.reduce((sum, row) => sum + row.churn, 0);
|
||||
const churnB = b.listenerChurn.reduce((sum, row) => sum + row.churn, 0);
|
||||
return churnB - churnA;
|
||||
}),
|
||||
};
|
||||
await fs.writeFile(path.join(runDir, 'route-sweep-report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "perf",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 390 KiB |
@@ -0,0 +1,334 @@
|
||||
{
|
||||
"url": "https://rover.otter.land",
|
||||
"runDir": "perf/results/2026-06-11T03-02-07-015Z",
|
||||
"elapsedMs": 38584,
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
|
||||
"title": "Roomba Rover",
|
||||
"finalUrl": "https://rover.otter.land/",
|
||||
"metrics": {
|
||||
"afterLoad": {
|
||||
"Timestamp": 38634.714754,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 66899,
|
||||
"Nodes": 2977,
|
||||
"LayoutCount": 89,
|
||||
"RecalcStyleCount": 274,
|
||||
"LayoutDuration": 0.106766,
|
||||
"RecalcStyleDuration": 0.065778,
|
||||
"ScriptDuration": 4.06233,
|
||||
"TaskDuration": 4.933287,
|
||||
"JSHeapUsedSize": 35455032,
|
||||
"JSHeapTotalSize": 80216064
|
||||
},
|
||||
"end": {
|
||||
"Timestamp": 38667.823166,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 75714,
|
||||
"Nodes": 2955,
|
||||
"LayoutCount": 654,
|
||||
"RecalcStyleCount": 2517,
|
||||
"LayoutDuration": 0.32419,
|
||||
"RecalcStyleDuration": 0.312982,
|
||||
"ScriptDuration": 32.06036,
|
||||
"TaskDuration": 37.597822,
|
||||
"JSHeapUsedSize": 39685520,
|
||||
"JSHeapTotalSize": 86081536
|
||||
},
|
||||
"delta": {
|
||||
"Timestamp": 33.10841200000141,
|
||||
"Documents": 0,
|
||||
"Frames": 0,
|
||||
"JSEventListeners": 8815,
|
||||
"Nodes": -22,
|
||||
"LayoutCount": 565,
|
||||
"RecalcStyleCount": 2243,
|
||||
"LayoutDuration": 0.21742399999999998,
|
||||
"RecalcStyleDuration": 0.24720399999999998,
|
||||
"ScriptDuration": 27.998030000000004,
|
||||
"TaskDuration": 32.664535,
|
||||
"JSHeapUsedSize": 4230488,
|
||||
"JSHeapTotalSize": 5865472
|
||||
}
|
||||
},
|
||||
"cpuSummary": {
|
||||
"totalProfileMs": 32896.945,
|
||||
"sampleCount": 30557,
|
||||
"topSelfTime": [
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(program)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 4657.831000000009,
|
||||
"samples": 4273
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "C9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 94304,
|
||||
"selfMs": 2470.519000000005,
|
||||
"samples": 2297
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "ad",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 7898,
|
||||
"selfMs": 2383.6760000000036,
|
||||
"samples": 2219
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "removeEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1596.3859999999981,
|
||||
"samples": 1476
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "addEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1314.546999999998,
|
||||
"samples": 1219
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "T9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 115769,
|
||||
"selfMs": 1280.09,
|
||||
"samples": 1195
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Q",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 39681,
|
||||
"selfMs": 1246.5730000000015,
|
||||
"samples": 1160
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "va",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 10653,
|
||||
"selfMs": 1170.771,
|
||||
"samples": 1088
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Fl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 101199,
|
||||
"selfMs": 1028.4150000000006,
|
||||
"samples": 954
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n2",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 42234,
|
||||
"selfMs": 1007.8280000000008,
|
||||
"samples": 938
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w1",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40723,
|
||||
"selfMs": 992.6080000000019,
|
||||
"samples": 927
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "zl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 80827,
|
||||
"selfMs": 975.6390000000019,
|
||||
"samples": 905
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "v9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 71024,
|
||||
"selfMs": 909.7280000000015,
|
||||
"samples": 848
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 17,
|
||||
"columnNumber": 5982,
|
||||
"selfMs": 710.5740000000005,
|
||||
"samples": 662
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Eh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 95724,
|
||||
"selfMs": 656.2710000000002,
|
||||
"samples": 613
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w4",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 91309,
|
||||
"selfMs": 544.7980000000002,
|
||||
"samples": 508
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Al",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 97450,
|
||||
"selfMs": 485.71200000000005,
|
||||
"samples": 453
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "kc",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 1388916,
|
||||
"selfMs": 482.39700000000016,
|
||||
"samples": 450
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "B6",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 34145,
|
||||
"selfMs": 475.1790000000002,
|
||||
"samples": 446
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "o1",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 70367,
|
||||
"selfMs": 441.8229999999999,
|
||||
"samples": 412
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "lt",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 7349,
|
||||
"selfMs": 398.3480000000001,
|
||||
"samples": 369
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "r",
|
||||
"lineNumber": 1,
|
||||
"columnNumber": 967,
|
||||
"selfMs": 348.187,
|
||||
"samples": 326
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(idle)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 319.7700000000001,
|
||||
"samples": 297
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "X",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40176,
|
||||
"selfMs": 283.33400000000006,
|
||||
"samples": 264
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "a2",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 71,
|
||||
"selfMs": 258.0969999999999,
|
||||
"samples": 239
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 56257,
|
||||
"selfMs": 216.19699999999995,
|
||||
"samples": 202
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(garbage collector)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 214.13299999999995,
|
||||
"samples": 195
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "t9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 50605,
|
||||
"selfMs": 207.45599999999985,
|
||||
"samples": 194
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "now",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 202.39299999999992,
|
||||
"samples": 189
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "po",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 214243,
|
||||
"selfMs": 192.35299999999984,
|
||||
"samples": 178
|
||||
}
|
||||
]
|
||||
},
|
||||
"responsiveness": {
|
||||
"longTaskCount": 1,
|
||||
"topLongTasks": [
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 5330.30000000447,
|
||||
"duration": 83
|
||||
}
|
||||
],
|
||||
"frameCount": 2261,
|
||||
"averageFrameMs": 16.83551525873507,
|
||||
"p95FrameMs": 16.799999999999272,
|
||||
"p99FrameMs": 16.80000000000291,
|
||||
"intervalDriftCount": 152,
|
||||
"averageIntervalDriftMs": 0.02039473680289168,
|
||||
"p95IntervalDriftMs": 4.600000001490116,
|
||||
"p99IntervalDriftMs": 23.399999998509884
|
||||
},
|
||||
"consoleMessages": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[socket] connecting to https://rover.otter.land",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 11,
|
||||
"column": 63768,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63768
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedRequests": [],
|
||||
"errorResponses": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 360 KiB |
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"url": "https://rover.otter.land",
|
||||
"runDir": "perf/results/2026-06-11T03-04-44-940Z",
|
||||
"elapsedMs": 41919,
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
|
||||
"title": "Roomba Rover",
|
||||
"finalUrl": "https://rover.otter.land/",
|
||||
"environment": {
|
||||
"cpuThrottle": 4,
|
||||
"viewport": {
|
||||
"width": 1366,
|
||||
"height": 768
|
||||
},
|
||||
"isMobile": false
|
||||
},
|
||||
"metrics": {
|
||||
"afterLoad": {
|
||||
"Timestamp": 38792.763829,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 6177,
|
||||
"Nodes": 2848,
|
||||
"LayoutCount": 38,
|
||||
"RecalcStyleCount": 43,
|
||||
"LayoutDuration": 0.233029,
|
||||
"RecalcStyleDuration": 0.13828,
|
||||
"ScriptDuration": 3.172814,
|
||||
"TaskDuration": 4.881969,
|
||||
"JSHeapUsedSize": 17107224,
|
||||
"JSHeapTotalSize": 28311552
|
||||
},
|
||||
"end": {
|
||||
"Timestamp": 38828.522855,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 3175,
|
||||
"Nodes": 3046,
|
||||
"LayoutCount": 767,
|
||||
"RecalcStyleCount": 773,
|
||||
"LayoutDuration": 1.403177,
|
||||
"RecalcStyleDuration": 0.88412,
|
||||
"ScriptDuration": 24.747626,
|
||||
"TaskDuration": 39.759494,
|
||||
"JSHeapUsedSize": 41718720,
|
||||
"JSHeapTotalSize": 81625088
|
||||
},
|
||||
"delta": {
|
||||
"Timestamp": 35.75902599999972,
|
||||
"Documents": 0,
|
||||
"Frames": 0,
|
||||
"JSEventListeners": -3002,
|
||||
"Nodes": 198,
|
||||
"LayoutCount": 729,
|
||||
"RecalcStyleCount": 730,
|
||||
"LayoutDuration": 1.170148,
|
||||
"RecalcStyleDuration": 0.7458400000000001,
|
||||
"ScriptDuration": 21.574812,
|
||||
"TaskDuration": 34.877525,
|
||||
"JSHeapUsedSize": 24611496,
|
||||
"JSHeapTotalSize": 53313536
|
||||
}
|
||||
},
|
||||
"cpuSummary": {
|
||||
"totalProfileMs": 34677.553,
|
||||
"sampleCount": 31910,
|
||||
"topSelfTime": [
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(program)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 12142.741999999993,
|
||||
"samples": 10968
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "C9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 94304,
|
||||
"selfMs": 1976.3040000000042,
|
||||
"samples": 1842
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "ad",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 7898,
|
||||
"selfMs": 1545.372000000002,
|
||||
"samples": 1431
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 1532780,
|
||||
"selfMs": 974.0629999999992,
|
||||
"samples": 908
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "v9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 71024,
|
||||
"selfMs": 844.4459999999995,
|
||||
"samples": 805
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Q",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 39681,
|
||||
"selfMs": 841.3410000000006,
|
||||
"samples": 763
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Fl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 101199,
|
||||
"selfMs": 826.49,
|
||||
"samples": 777
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w1",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40723,
|
||||
"selfMs": 811.0100000000001,
|
||||
"samples": 754
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "removeEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 811.0059999999984,
|
||||
"samples": 723
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "va",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 10653,
|
||||
"selfMs": 752.6890000000008,
|
||||
"samples": 698
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(idle)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 664.0250000000005,
|
||||
"samples": 625
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "zl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 80827,
|
||||
"selfMs": 596.2229999999997,
|
||||
"samples": 556
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "addEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 573.276,
|
||||
"samples": 544
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Eh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 95724,
|
||||
"selfMs": 515.44,
|
||||
"samples": 477
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Kl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 117211,
|
||||
"selfMs": 499.10000000000025,
|
||||
"samples": 470
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Al",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 97450,
|
||||
"selfMs": 497.787,
|
||||
"samples": 453
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n2",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 42234,
|
||||
"selfMs": 408.08700000000005,
|
||||
"samples": 381
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "o1",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 70367,
|
||||
"selfMs": 381.6320000000001,
|
||||
"samples": 359
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "r",
|
||||
"lineNumber": 1,
|
||||
"columnNumber": 967,
|
||||
"selfMs": 345.4999999999999,
|
||||
"samples": 328
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Oh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 116246,
|
||||
"selfMs": 334.56799999999987,
|
||||
"samples": 313
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "a2",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 71,
|
||||
"selfMs": 314.36699999999996,
|
||||
"samples": 293
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "lt",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 7349,
|
||||
"selfMs": 313.014,
|
||||
"samples": 286
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Z4",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 28258,
|
||||
"selfMs": 290.6389999999998,
|
||||
"samples": 261
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(garbage collector)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 236.61700000000016,
|
||||
"samples": 210
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "B6",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 34145,
|
||||
"selfMs": 229.56900000000002,
|
||||
"samples": 213
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "t9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 50605,
|
||||
"selfMs": 208.06900000000002,
|
||||
"samples": 191
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "Blob",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 179.771,
|
||||
"samples": 158
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w4",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 91309,
|
||||
"selfMs": 147.32200000000006,
|
||||
"samples": 132
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 56257,
|
||||
"selfMs": 135.37199999999999,
|
||||
"samples": 125
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "ma",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 10401,
|
||||
"selfMs": 129.19600000000003,
|
||||
"samples": 118
|
||||
}
|
||||
]
|
||||
},
|
||||
"responsiveness": {
|
||||
"longTaskCount": 14,
|
||||
"topLongTasks": [
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 193.60000000149012,
|
||||
"duration": 184
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 381.20000000298023,
|
||||
"duration": 137
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 23610.70000000298,
|
||||
"duration": 131
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1045,
|
||||
"duration": 130
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1972.2999999970198,
|
||||
"duration": 107
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 6042.70000000298,
|
||||
"duration": 105
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1874.3999999985099,
|
||||
"duration": 97
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 2697.7000000029802,
|
||||
"duration": 96
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 518.2999999970198,
|
||||
"duration": 81
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 2618.5,
|
||||
"duration": 79
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 839.3999999985099,
|
||||
"duration": 75
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 974.1000000014901,
|
||||
"duration": 70
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 770.7999999970198,
|
||||
"duration": 68
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 39623.79999999702,
|
||||
"duration": 54
|
||||
}
|
||||
],
|
||||
"frameCount": 577,
|
||||
"averageFrameMs": 69.98561525129982,
|
||||
"p95FrameMs": 133.29999999999927,
|
||||
"p99FrameMs": 166.70000000000027,
|
||||
"intervalDriftCount": 159,
|
||||
"averageIntervalDriftMs": 6.808805031455913,
|
||||
"p95IntervalDriftMs": 83.19999999552965,
|
||||
"p99IntervalDriftMs": 239.80000000447035
|
||||
},
|
||||
"consoleMessages": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[socket] connecting to https://rover.otter.land",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 11,
|
||||
"column": 63768,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63768
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedRequests": [],
|
||||
"errorResponses": [
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 226 KiB |
@@ -0,0 +1,403 @@
|
||||
{
|
||||
"url": "https://rover.otter.land",
|
||||
"runDir": "perf/results/2026-06-11T03-05-35-042Z",
|
||||
"elapsedMs": 41711,
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
|
||||
"title": "Roomba Rover",
|
||||
"finalUrl": "https://rover.otter.land/",
|
||||
"environment": {
|
||||
"cpuThrottle": 4,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true
|
||||
},
|
||||
"metrics": {
|
||||
"afterLoad": {
|
||||
"Timestamp": 38842.912408,
|
||||
"Documents": 3,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 5426,
|
||||
"Nodes": 2393,
|
||||
"LayoutCount": 100,
|
||||
"RecalcStyleCount": 106,
|
||||
"LayoutDuration": 0.207151,
|
||||
"RecalcStyleDuration": 0.170546,
|
||||
"ScriptDuration": 3.283922,
|
||||
"TaskDuration": 5.024573,
|
||||
"JSHeapUsedSize": 12044476,
|
||||
"JSHeapTotalSize": 27000832
|
||||
},
|
||||
"end": {
|
||||
"Timestamp": 38878.342222,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 40107,
|
||||
"Nodes": 2536,
|
||||
"LayoutCount": 1291,
|
||||
"RecalcStyleCount": 1289,
|
||||
"LayoutDuration": 1.04779,
|
||||
"RecalcStyleDuration": 1.180594,
|
||||
"ScriptDuration": 24.454767,
|
||||
"TaskDuration": 39.786697,
|
||||
"JSHeapUsedSize": 17640640,
|
||||
"JSHeapTotalSize": 82411520
|
||||
},
|
||||
"delta": {
|
||||
"Timestamp": 35.42981400000281,
|
||||
"Documents": -2,
|
||||
"Frames": 0,
|
||||
"JSEventListeners": 34681,
|
||||
"Nodes": 143,
|
||||
"LayoutCount": 1191,
|
||||
"RecalcStyleCount": 1183,
|
||||
"LayoutDuration": 0.840639,
|
||||
"RecalcStyleDuration": 1.0100479999999998,
|
||||
"ScriptDuration": 21.170845,
|
||||
"TaskDuration": 34.762124,
|
||||
"JSHeapUsedSize": 5596164,
|
||||
"JSHeapTotalSize": 55410688
|
||||
}
|
||||
},
|
||||
"cpuSummary": {
|
||||
"totalProfileMs": 34204.39,
|
||||
"sampleCount": 31429,
|
||||
"topSelfTime": [
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(program)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 12727.823999999955,
|
||||
"samples": 11515
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "C9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 94304,
|
||||
"selfMs": 1738.6469999999997,
|
||||
"samples": 1612
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "ad",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 7898,
|
||||
"selfMs": 1547.8840000000005,
|
||||
"samples": 1425
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "removeEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1048.3049999999994,
|
||||
"samples": 922
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Fl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 101199,
|
||||
"selfMs": 1013.1350000000006,
|
||||
"samples": 958
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w1",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40723,
|
||||
"selfMs": 846.9869999999997,
|
||||
"samples": 777
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "va",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 10653,
|
||||
"selfMs": 826.692,
|
||||
"samples": 756
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Q",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 39681,
|
||||
"selfMs": 813.2879999999994,
|
||||
"samples": 744
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "v9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 71024,
|
||||
"selfMs": 764.6159999999996,
|
||||
"samples": 716
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "addEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 711.6550000000001,
|
||||
"samples": 655
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 17,
|
||||
"columnNumber": 9774,
|
||||
"selfMs": 643.1059999999997,
|
||||
"samples": 593
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Eh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 95724,
|
||||
"selfMs": 609.4899999999997,
|
||||
"samples": 573
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "zl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 80827,
|
||||
"selfMs": 558.8860000000001,
|
||||
"samples": 525
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Kl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 117211,
|
||||
"selfMs": 555.4119999999999,
|
||||
"samples": 516
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(idle)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 476.40799999999973,
|
||||
"samples": 445
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Al",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 97450,
|
||||
"selfMs": 392.31999999999965,
|
||||
"samples": 400
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "kc",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 1388916,
|
||||
"selfMs": 363.94399999999945,
|
||||
"samples": 336
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n2",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 42234,
|
||||
"selfMs": 356.98299999999995,
|
||||
"samples": 334
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Z4",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 28258,
|
||||
"selfMs": 322.32900000000024,
|
||||
"samples": 293
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "lt",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 7349,
|
||||
"selfMs": 291.17899999999986,
|
||||
"samples": 268
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "o1",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 70367,
|
||||
"selfMs": 269.2040000000001,
|
||||
"samples": 257
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "B6",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 34145,
|
||||
"selfMs": 248.9579999999999,
|
||||
"samples": 228
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "r",
|
||||
"lineNumber": 1,
|
||||
"columnNumber": 967,
|
||||
"selfMs": 246.97999999999996,
|
||||
"samples": 223
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Oh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 116246,
|
||||
"selfMs": 239.94800000000004,
|
||||
"samples": 223
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "a2",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 71,
|
||||
"selfMs": 219.92599999999987,
|
||||
"samples": 209
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "t9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 50605,
|
||||
"selfMs": 170.78300000000004,
|
||||
"samples": 157
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "N7",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 100487,
|
||||
"selfMs": 150.48000000000005,
|
||||
"samples": 144
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w4",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 91309,
|
||||
"selfMs": 144.64899999999994,
|
||||
"samples": 138
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Td.k1.createElement",
|
||||
"lineNumber": 1,
|
||||
"columnNumber": 6938,
|
||||
"selfMs": 138.19599999999997,
|
||||
"samples": 132
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 56257,
|
||||
"selfMs": 135.858,
|
||||
"samples": 128
|
||||
}
|
||||
]
|
||||
},
|
||||
"responsiveness": {
|
||||
"longTaskCount": 10,
|
||||
"topLongTasks": [
|
||||
{
|
||||
"name": "unknown",
|
||||
"startTime": 5777.89999999851,
|
||||
"duration": 234
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 185,
|
||||
"duration": 188
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 23461.59999999404,
|
||||
"duration": 179
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 957.7999999970198,
|
||||
"duration": 111
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 376.59999999403954,
|
||||
"duration": 106
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1511.7999999970198,
|
||||
"duration": 83
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 696.7999999970198,
|
||||
"duration": 73
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 770.7999999970198,
|
||||
"duration": 69
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 482.59999999403954,
|
||||
"duration": 67
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 892.6999999955297,
|
||||
"duration": 64
|
||||
}
|
||||
],
|
||||
"frameCount": 1031,
|
||||
"averageFrameMs": 38.92502424830261,
|
||||
"p95FrameMs": 66.70000000000073,
|
||||
"p99FrameMs": 133.3000000000029,
|
||||
"intervalDriftCount": 157,
|
||||
"averageIntervalDriftMs": 8.188535031856626,
|
||||
"p95IntervalDriftMs": 62.100000001490116,
|
||||
"p99IntervalDriftMs": 431.09999999403954
|
||||
},
|
||||
"consoleMessages": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[socket] connecting to https://rover.otter.land",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 11,
|
||||
"column": 63768,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63768
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedRequests": [],
|
||||
"errorResponses": [
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 604 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,527 @@
|
||||
{
|
||||
"url": "https://rover.otter.land/spectate",
|
||||
"runDir": "perf/results/2026-06-11T03-14-34-168Z",
|
||||
"elapsedMs": 64190,
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
|
||||
"title": "Roomba Rover",
|
||||
"finalUrl": "https://rover.otter.land/spectate",
|
||||
"environment": {
|
||||
"cpuThrottle": 8,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true
|
||||
},
|
||||
"metrics": {
|
||||
"afterLoad": {
|
||||
"Timestamp": 39382.268457,
|
||||
"Documents": 3,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 494,
|
||||
"Nodes": 8431,
|
||||
"LayoutCount": 16,
|
||||
"RecalcStyleCount": 18,
|
||||
"LayoutDuration": 0.410692,
|
||||
"RecalcStyleDuration": 0.442447,
|
||||
"ScriptDuration": 2.378027,
|
||||
"TaskDuration": 5.043961,
|
||||
"JSHeapUsedSize": 14710548,
|
||||
"JSHeapTotalSize": 20447232
|
||||
},
|
||||
"end": {
|
||||
"Timestamp": 39435.115434,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 509,
|
||||
"Nodes": 12246,
|
||||
"LayoutCount": 532,
|
||||
"RecalcStyleCount": 540,
|
||||
"LayoutDuration": 2.892458,
|
||||
"RecalcStyleDuration": 6.325277,
|
||||
"ScriptDuration": 21.960492,
|
||||
"TaskDuration": 54.170961,
|
||||
"JSHeapUsedSize": 14649044,
|
||||
"JSHeapTotalSize": 65634304
|
||||
},
|
||||
"delta": {
|
||||
"Timestamp": 52.84697700000106,
|
||||
"Documents": -2,
|
||||
"Frames": 0,
|
||||
"JSEventListeners": 15,
|
||||
"Nodes": 3815,
|
||||
"LayoutCount": 516,
|
||||
"RecalcStyleCount": 522,
|
||||
"LayoutDuration": 2.481766,
|
||||
"RecalcStyleDuration": 5.88283,
|
||||
"ScriptDuration": 19.582465,
|
||||
"TaskDuration": 49.126999999999995,
|
||||
"JSHeapUsedSize": -61504,
|
||||
"JSHeapTotalSize": 45187072
|
||||
}
|
||||
},
|
||||
"cpuSummary": {
|
||||
"totalProfileMs": 41344.156,
|
||||
"sampleCount": 31576,
|
||||
"topSelfTime": [
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(program)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 21236.87599999999,
|
||||
"samples": 16120
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(idle)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 2981.150999999998,
|
||||
"samples": 2232
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 64833,
|
||||
"selfMs": 1365.8530000000014,
|
||||
"samples": 1053
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "C9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 94304,
|
||||
"selfMs": 1234.6300000000003,
|
||||
"samples": 936
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "ad",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 7898,
|
||||
"selfMs": 842.8760000000004,
|
||||
"samples": 664
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Fl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 101199,
|
||||
"selfMs": 674.3050000000004,
|
||||
"samples": 527
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w1",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40723,
|
||||
"selfMs": 621.5390000000002,
|
||||
"samples": 475
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Q",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 39681,
|
||||
"selfMs": 505.4100000000001,
|
||||
"samples": 396
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "Blob",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 479.50199999999967,
|
||||
"samples": 331
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Eh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 95724,
|
||||
"selfMs": 423.5530000000003,
|
||||
"samples": 343
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "decodeString",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 48609,
|
||||
"selfMs": 423.2989999999997,
|
||||
"samples": 328
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "B6",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 34145,
|
||||
"selfMs": 404.793,
|
||||
"samples": 311
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Kl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 117211,
|
||||
"selfMs": 398.8710000000001,
|
||||
"samples": 291
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "setAttribute",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 384.0259999999998,
|
||||
"samples": 319
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "zl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 80827,
|
||||
"selfMs": 372.5609999999999,
|
||||
"samples": 298
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Al",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 97450,
|
||||
"selfMs": 370.4030000000001,
|
||||
"samples": 280
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n2",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 42234,
|
||||
"selfMs": 356.20099999999996,
|
||||
"samples": 280
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Kt",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 48095,
|
||||
"selfMs": 303.89100000000013,
|
||||
"samples": 222
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "va",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 10653,
|
||||
"selfMs": 289.63100000000003,
|
||||
"samples": 227
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(garbage collector)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 257.03600000000006,
|
||||
"samples": 183
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "createObjectURL",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 223.09499999999994,
|
||||
"samples": 181
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "r",
|
||||
"lineNumber": 1,
|
||||
"columnNumber": 967,
|
||||
"selfMs": 212.97800000000012,
|
||||
"samples": 168
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w4",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 91309,
|
||||
"selfMs": 202.98600000000016,
|
||||
"samples": 154
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "removeChild",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 197.55400000000006,
|
||||
"samples": 144
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "v9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 71024,
|
||||
"selfMs": 175.75600000000009,
|
||||
"samples": 132
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "X",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40176,
|
||||
"selfMs": 166.36100000000005,
|
||||
"samples": 136
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "o1",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 70367,
|
||||
"selfMs": 162.70300000000003,
|
||||
"samples": 132
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "V",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 38470,
|
||||
"selfMs": 159.72000000000003,
|
||||
"samples": 124
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Oh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 116246,
|
||||
"selfMs": 151.22299999999993,
|
||||
"samples": 109
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "V8",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 1487297,
|
||||
"selfMs": 147.01899999999992,
|
||||
"samples": 113
|
||||
}
|
||||
]
|
||||
},
|
||||
"responsiveness": {
|
||||
"longTaskCount": 322,
|
||||
"topLongTasks": [
|
||||
{
|
||||
"name": "unknown",
|
||||
"startTime": 12297.80000000447,
|
||||
"duration": 518
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 250.39999999850988,
|
||||
"duration": 401
|
||||
},
|
||||
{
|
||||
"name": "unknown",
|
||||
"startTime": 12929.20000000298,
|
||||
"duration": 308
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1907.3999999985099,
|
||||
"duration": 274
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 36069.80000000447,
|
||||
"duration": 230
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 2530.89999999851,
|
||||
"duration": 195
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 54969,
|
||||
"duration": 190
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 55840.80000000447,
|
||||
"duration": 189
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 24437.39999999851,
|
||||
"duration": 188
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 47105.5,
|
||||
"duration": 172
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1736,
|
||||
"duration": 170
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 2367.60000000149,
|
||||
"duration": 163
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1320.3000000044703,
|
||||
"duration": 161
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 659.6000000014901,
|
||||
"duration": 159
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 30711.60000000149,
|
||||
"duration": 146
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 54631.5,
|
||||
"duration": 146
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 3079,
|
||||
"duration": 139
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 819.2000000029802,
|
||||
"duration": 136
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 42828.70000000298,
|
||||
"duration": 132
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1482.2000000029802,
|
||||
"duration": 129
|
||||
}
|
||||
],
|
||||
"frameCount": 460,
|
||||
"averageFrameMs": 125.46608695652174,
|
||||
"p95FrameMs": 233.39999999999782,
|
||||
"p99FrameMs": 433.3000000000011,
|
||||
"intervalDriftCount": 78,
|
||||
"averageIntervalDriftMs": 502.9153846153655,
|
||||
"p95IntervalDriftMs": 1530.1999999955297,
|
||||
"p99IntervalDriftMs": 2415.60000000149
|
||||
},
|
||||
"consoleMessages": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[socket] connecting to https://rover.otter.land",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 11,
|
||||
"column": 63768,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63768
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to enter spectator mode Error: Spectator role required\n at lo.<anonymous> (https://rover.otter.land/assets/index-l3JqdlyF.js:12:64487)\n at lo.onack (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55937)\n at lo.onpacket (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55148)\n at V2.emit (https://rover.otter.land/assets/index-l3JqdlyF.js:12:25579)\n at https://rover.otter.land/assets/index-l3JqdlyF.js:12:61163",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 125,
|
||||
"column": 14862,
|
||||
"lineNumber": 125,
|
||||
"columnNumber": 14862
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to enter spectator mode Error: Spectator role required\n at lo.<anonymous> (https://rover.otter.land/assets/index-l3JqdlyF.js:12:64487)\n at lo.onack (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55937)\n at lo.onpacket (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55148)\n at V2.emit (https://rover.otter.land/assets/index-l3JqdlyF.js:12:25579)\n at https://rover.otter.land/assets/index-l3JqdlyF.js:12:61163",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 125,
|
||||
"column": 14862,
|
||||
"lineNumber": 125,
|
||||
"columnNumber": 14862
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to enter spectator mode Error: Spectator role required\n at lo.<anonymous> (https://rover.otter.land/assets/index-l3JqdlyF.js:12:64487)\n at lo.onack (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55937)\n at lo.onpacket (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55148)\n at V2.emit (https://rover.otter.land/assets/index-l3JqdlyF.js:12:25579)\n at https://rover.otter.land/assets/index-l3JqdlyF.js:12:61163",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 125,
|
||||
"column": 14862,
|
||||
"lineNumber": 125,
|
||||
"columnNumber": 14862
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to enter spectator mode Error: Spectator role required\n at lo.<anonymous> (https://rover.otter.land/assets/index-l3JqdlyF.js:12:64487)\n at lo.onack (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55937)\n at lo.onpacket (https://rover.otter.land/assets/index-l3JqdlyF.js:12:55148)\n at V2.emit (https://rover.otter.land/assets/index-l3JqdlyF.js:12:25579)\n at https://rover.otter.land/assets/index-l3JqdlyF.js:12:61163",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 125,
|
||||
"column": 14862,
|
||||
"lineNumber": 125,
|
||||
"columnNumber": 14862
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedRequests": [],
|
||||
"errorResponses": [
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 221 KiB |
@@ -0,0 +1,483 @@
|
||||
{
|
||||
"url": "https://rover.otter.land/",
|
||||
"runDir": "perf/results/2026-06-11T03-15-51-398Z",
|
||||
"elapsedMs": 68429,
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
|
||||
"title": "Roomba Rover",
|
||||
"finalUrl": "https://rover.otter.land/",
|
||||
"environment": {
|
||||
"cpuThrottle": 8,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true
|
||||
},
|
||||
"metrics": {
|
||||
"afterLoad": {
|
||||
"Timestamp": 39459.436441,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 964,
|
||||
"Nodes": 2332,
|
||||
"LayoutCount": 17,
|
||||
"RecalcStyleCount": 19,
|
||||
"LayoutDuration": 0.26351,
|
||||
"RecalcStyleDuration": 0.176918,
|
||||
"ScriptDuration": 3.214383,
|
||||
"TaskDuration": 5.084233,
|
||||
"JSHeapUsedSize": 8964616,
|
||||
"JSHeapTotalSize": 20971520
|
||||
},
|
||||
"end": {
|
||||
"Timestamp": 39514.079576,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 14445,
|
||||
"Nodes": 2446,
|
||||
"LayoutCount": 390,
|
||||
"RecalcStyleCount": 565,
|
||||
"LayoutDuration": 1.338259,
|
||||
"RecalcStyleDuration": 1.340215,
|
||||
"ScriptDuration": 39.06709,
|
||||
"TaskDuration": 57.967635,
|
||||
"JSHeapUsedSize": 32831868,
|
||||
"JSHeapTotalSize": 80576512
|
||||
},
|
||||
"delta": {
|
||||
"Timestamp": 54.64313499999844,
|
||||
"Documents": 0,
|
||||
"Frames": 0,
|
||||
"JSEventListeners": 13481,
|
||||
"Nodes": 114,
|
||||
"LayoutCount": 373,
|
||||
"RecalcStyleCount": 546,
|
||||
"LayoutDuration": 1.0747490000000002,
|
||||
"RecalcStyleDuration": 1.163297,
|
||||
"ScriptDuration": 35.852707,
|
||||
"TaskDuration": 52.883402000000004,
|
||||
"JSHeapUsedSize": 23867252,
|
||||
"JSHeapTotalSize": 59604992
|
||||
}
|
||||
},
|
||||
"cpuSummary": {
|
||||
"totalProfileMs": 42872.299,
|
||||
"sampleCount": 32614,
|
||||
"topSelfTime": [
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(program)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 11770.136000000031,
|
||||
"samples": 8654
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "C9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 94304,
|
||||
"selfMs": 2390.596999999998,
|
||||
"samples": 1844
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "ad",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 7898,
|
||||
"selfMs": 2379.0989999999983,
|
||||
"samples": 1867
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 129074,
|
||||
"selfMs": 1738.0870000000016,
|
||||
"samples": 1347
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Fl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 101199,
|
||||
"selfMs": 1436.2390000000005,
|
||||
"samples": 1104
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "removeEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1298.9879999999996,
|
||||
"samples": 915
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(idle)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1226.9839999999986,
|
||||
"samples": 919
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "v9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 71024,
|
||||
"selfMs": 1131.7309999999986,
|
||||
"samples": 880
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Q",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 39681,
|
||||
"selfMs": 1085.1430000000012,
|
||||
"samples": 833
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "va",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 10653,
|
||||
"selfMs": 1036.2670000000012,
|
||||
"samples": 808
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w1",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40723,
|
||||
"selfMs": 1021.851000000001,
|
||||
"samples": 794
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "addEventListener",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 935.4199999999993,
|
||||
"samples": 733
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "zl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 80827,
|
||||
"selfMs": 898.5989999999993,
|
||||
"samples": 684
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Eh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 95724,
|
||||
"selfMs": 875.6580000000007,
|
||||
"samples": 685
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Al",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 97450,
|
||||
"selfMs": 826.9259999999988,
|
||||
"samples": 630
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Kl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 117211,
|
||||
"selfMs": 710.8159999999998,
|
||||
"samples": 538
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n2",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 42234,
|
||||
"selfMs": 586.3330000000001,
|
||||
"samples": 449
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "kc",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 1388916,
|
||||
"selfMs": 431.253,
|
||||
"samples": 337
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Z4",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 28258,
|
||||
"selfMs": 429.435,
|
||||
"samples": 323
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "o1",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 70367,
|
||||
"selfMs": 426.5330000000003,
|
||||
"samples": 321
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "Oh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 116246,
|
||||
"selfMs": 376.3850000000001,
|
||||
"samples": 289
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "decodeString",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 48609,
|
||||
"selfMs": 349.87800000000004,
|
||||
"samples": 266
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "B6",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 34145,
|
||||
"selfMs": 310.59499999999986,
|
||||
"samples": 239
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "r",
|
||||
"lineNumber": 1,
|
||||
"columnNumber": 967,
|
||||
"selfMs": 293.97700000000003,
|
||||
"samples": 230
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(garbage collector)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 260.9860000000001,
|
||||
"samples": 178
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "setAttribute",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 234.46500000000006,
|
||||
"samples": 165
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "n9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 56257,
|
||||
"selfMs": 221.65700000000007,
|
||||
"samples": 163
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "t9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 50605,
|
||||
"selfMs": 209.57700000000006,
|
||||
"samples": 175
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "ma",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 10401,
|
||||
"selfMs": 205.84599999999998,
|
||||
"samples": 161
|
||||
},
|
||||
{
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"functionName": "w4",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 91309,
|
||||
"selfMs": 181.84999999999997,
|
||||
"samples": 140
|
||||
}
|
||||
]
|
||||
},
|
||||
"responsiveness": {
|
||||
"longTaskCount": 67,
|
||||
"topLongTasks": [
|
||||
{
|
||||
"name": "unknown",
|
||||
"startTime": 12851.70000000298,
|
||||
"duration": 419
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 224.70000000298023,
|
||||
"duration": 369
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1742.9000000059605,
|
||||
"duration": 229
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 601.4000000059605,
|
||||
"duration": 205
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 3460.2000000029802,
|
||||
"duration": 203
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 36108.80000000447,
|
||||
"duration": 202
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 3289.8000000044703,
|
||||
"duration": 170
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1209.8000000044703,
|
||||
"duration": 143
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1605.5,
|
||||
"duration": 137
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 1353.3000000044703,
|
||||
"duration": 123
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 4036.60000000149,
|
||||
"duration": 110
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 31583.70000000298,
|
||||
"duration": 107
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 807.1000000014901,
|
||||
"duration": 106
|
||||
},
|
||||
{
|
||||
"name": "unknown",
|
||||
"startTime": 13343,
|
||||
"duration": 100
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 24534.20000000298,
|
||||
"duration": 100
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 30488.30000000447,
|
||||
"duration": 97
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 21806.40000000596,
|
||||
"duration": 89
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 55545.5,
|
||||
"duration": 87
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 36721.70000000298,
|
||||
"duration": 82
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 44980.20000000298,
|
||||
"duration": 79
|
||||
}
|
||||
],
|
||||
"frameCount": 427,
|
||||
"averageFrameMs": 140.080093676815,
|
||||
"p95FrameMs": 166.79999999999563,
|
||||
"p99FrameMs": 383.4000000000001,
|
||||
"intervalDriftCount": 58,
|
||||
"averageIntervalDriftMs": 790.1465517241379,
|
||||
"p95IntervalDriftMs": 1347.1999999955297,
|
||||
"p99IntervalDriftMs": 1620.7999999970198
|
||||
},
|
||||
"consoleMessages": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[socket] connecting to https://rover.otter.land",
|
||||
"location": {
|
||||
"url": "https://rover.otter.land/assets/index-l3JqdlyF.js",
|
||||
"line": 11,
|
||||
"column": 63768,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63768
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedRequests": [],
|
||||
"errorResponses": [
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
},
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
{
|
||||
"url": "https://rover.otter.land/",
|
||||
"runDir": "perf/results/2026-06-11T03-21-51-263Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 6,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 30000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 39844.241947,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 15871,
|
||||
"LayoutObjects": 1938,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 2430,
|
||||
"Resources": 13,
|
||||
"ContextLifecycleStateObservers": 12,
|
||||
"V8PerContextDatas": 2,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 2,
|
||||
"ResourceFetchers": 1,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 2,
|
||||
"ArrayBufferContents": 3,
|
||||
"LayoutCount": 297,
|
||||
"RecalcStyleCount": 320,
|
||||
"LayoutDuration": 0.596739,
|
||||
"RecalcStyleDuration": 0.63084,
|
||||
"DevToolsCommandDuration": 0.000137,
|
||||
"ScriptDuration": 20.110942,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 29.506741,
|
||||
"TaskOtherDuration": 8.168083,
|
||||
"ThreadTime": 30.245205,
|
||||
"ProcessTime": 35.2,
|
||||
"JSHeapUsedSize": 18751188,
|
||||
"JSHeapTotalSize": 45088768,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 39814.213839,
|
||||
"NavigationStart": 39813.701422
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 30586.79999999702,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:window:keydown",
|
||||
"count": 770
|
||||
},
|
||||
{
|
||||
"key": "add:window:keyup",
|
||||
"count": 770
|
||||
},
|
||||
{
|
||||
"key": "add:window:blur",
|
||||
"count": 770
|
||||
},
|
||||
{
|
||||
"key": "add:window:gamepadconnected",
|
||||
"count": 770
|
||||
},
|
||||
{
|
||||
"key": "add:window:gamepaddisconnected",
|
||||
"count": 770
|
||||
},
|
||||
{
|
||||
"key": "remove:window:keydown",
|
||||
"count": 769
|
||||
},
|
||||
{
|
||||
"key": "remove:window:keyup",
|
||||
"count": 769
|
||||
},
|
||||
{
|
||||
"key": "remove:window:blur",
|
||||
"count": 769
|
||||
},
|
||||
{
|
||||
"key": "remove:window:gamepadconnected",
|
||||
"count": 769
|
||||
},
|
||||
{
|
||||
"key": "remove:window:gamepaddisconnected",
|
||||
"count": 769
|
||||
},
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "add:window:online",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:document:visibilitychange",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:window:online",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "remove:document:visibilitychange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:canplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ended",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:error",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:pause",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:play",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:stalled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:waiting",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:auxclick",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:click",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:contextmenu",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:copy",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:cut",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drag",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragend",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragenter",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragexit",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragleave",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragstart",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drop",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:gotpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:input",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keydown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keypress",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keyup",
|
||||
"count": 2
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "interval:200",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"key": "timeout:12000",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:250",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:3000",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:60000",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:1000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:4000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:10000",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 13,
|
||||
"intervals": 25,
|
||||
"timeoutFires": 6,
|
||||
"intervalFires": 593,
|
||||
"webSocketMessageCount": 1000,
|
||||
"webSocketBytes": 797561,
|
||||
"webSocketEvents": [
|
||||
{
|
||||
"event": "log:entry",
|
||||
"count": 526,
|
||||
"bytes": 111535
|
||||
},
|
||||
{
|
||||
"event": "sensorFrame",
|
||||
"count": 306,
|
||||
"bytes": 574616
|
||||
},
|
||||
{
|
||||
"event": "commandAck",
|
||||
"count": 145,
|
||||
"bytes": 13778
|
||||
},
|
||||
{
|
||||
"event": "chat:typing",
|
||||
"count": 7,
|
||||
"bytes": 3073
|
||||
},
|
||||
{
|
||||
"event": "session:sync",
|
||||
"count": 5,
|
||||
"bytes": 87372
|
||||
},
|
||||
{
|
||||
"event": "chat:message",
|
||||
"count": 4,
|
||||
"bytes": 4115
|
||||
},
|
||||
{
|
||||
"event": "roverHostStats",
|
||||
"count": 4,
|
||||
"bytes": 3069
|
||||
},
|
||||
{
|
||||
"event": "2",
|
||||
"count": 3,
|
||||
"bytes": 3
|
||||
}
|
||||
],
|
||||
"mutationsTotal": {
|
||||
"records": 41062,
|
||||
"added": 229,
|
||||
"removed": 35,
|
||||
"attrs": 40761,
|
||||
"text": 37
|
||||
},
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 1534,
|
||||
"records": 164,
|
||||
"added": 105,
|
||||
"removed": 1,
|
||||
"attrs": 58,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 3007,
|
||||
"records": 93,
|
||||
"added": 13,
|
||||
"removed": 3,
|
||||
"attrs": 68,
|
||||
"text": 9
|
||||
},
|
||||
{
|
||||
"at": 1212,
|
||||
"records": 73,
|
||||
"added": 22,
|
||||
"removed": 5,
|
||||
"attrs": 42,
|
||||
"text": 4
|
||||
},
|
||||
{
|
||||
"at": 6080,
|
||||
"records": 72,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 72,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 7837,
|
||||
"records": 72,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 72,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 8493,
|
||||
"records": 72,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 72,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 16038,
|
||||
"records": 72,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 72,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26549,
|
||||
"records": 72,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 72,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 10855,
|
||||
"records": 71,
|
||||
"added": 1,
|
||||
"removed": 0,
|
||||
"attrs": 70,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 4339,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 6195,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 6225,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 9779,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15605,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15797,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15862,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18303,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18367,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 20816,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 20907,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 338,
|
||||
"average": 88.21153846153847,
|
||||
"p95": 150,
|
||||
"p99": 183.29999999999927
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 18,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 228,
|
||||
"duration": 287,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 520.5,
|
||||
"duration": 226,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1534.1000000014901,
|
||||
"duration": 140,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2490,
|
||||
"duration": 136,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1080,
|
||||
"duration": 132,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1212.3000000044703,
|
||||
"duration": 124,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1434.8999999985099,
|
||||
"duration": 99,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 747.1000000014901,
|
||||
"duration": 85,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2928.60000000149,
|
||||
"duration": 78,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 11610.30000000447,
|
||||
"duration": 72,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4531.10000000149,
|
||||
"duration": 62,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 19226.5,
|
||||
"duration": 62,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 6920.70000000298,
|
||||
"duration": 54,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 7968.39999999851,
|
||||
"duration": 52,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3745.60000000149,
|
||||
"duration": 51,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 12822.20000000298,
|
||||
"duration": 51,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 8143.70000000298,
|
||||
"duration": 50,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 10881.5,
|
||||
"duration": 50,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": []
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 923 KiB |
@@ -0,0 +1,843 @@
|
||||
{
|
||||
"url": "https://rover.otter.land/",
|
||||
"runDir": "perf/results/2026-06-11T03-22-44-714Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 6,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 18000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 39885.73698,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 8530,
|
||||
"LayoutObjects": 1864,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 2379,
|
||||
"Resources": 12,
|
||||
"ContextLifecycleStateObservers": 12,
|
||||
"V8PerContextDatas": 2,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 2,
|
||||
"ResourceFetchers": 1,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 2,
|
||||
"ArrayBufferContents": 8,
|
||||
"LayoutCount": 116,
|
||||
"RecalcStyleCount": 120,
|
||||
"LayoutDuration": 0.379961,
|
||||
"RecalcStyleDuration": 0.361646,
|
||||
"DevToolsCommandDuration": 0.000126,
|
||||
"ScriptDuration": 11.946269,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 17.591645,
|
||||
"TaskOtherDuration": 4.903643,
|
||||
"ThreadTime": 18.399532,
|
||||
"ProcessTime": 20.97,
|
||||
"JSHeapUsedSize": 24567940,
|
||||
"JSHeapTotalSize": 44564480,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 39867.72907,
|
||||
"NavigationStart": 39867.192605
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 18737.20000000298,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:window:keydown",
|
||||
"count": 334
|
||||
},
|
||||
{
|
||||
"key": "add:window:keyup",
|
||||
"count": 334
|
||||
},
|
||||
{
|
||||
"key": "add:window:blur",
|
||||
"count": 334
|
||||
},
|
||||
{
|
||||
"key": "add:window:gamepadconnected",
|
||||
"count": 334
|
||||
},
|
||||
{
|
||||
"key": "add:window:gamepaddisconnected",
|
||||
"count": 334
|
||||
},
|
||||
{
|
||||
"key": "remove:window:keydown",
|
||||
"count": 333
|
||||
},
|
||||
{
|
||||
"key": "remove:window:keyup",
|
||||
"count": 333
|
||||
},
|
||||
{
|
||||
"key": "remove:window:blur",
|
||||
"count": 333
|
||||
},
|
||||
{
|
||||
"key": "remove:window:gamepadconnected",
|
||||
"count": 333
|
||||
},
|
||||
{
|
||||
"key": "remove:window:gamepaddisconnected",
|
||||
"count": 333
|
||||
},
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "add:window:online",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "add:document:visibilitychange",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "remove:window:online",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:document:visibilitychange",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:audio:canplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ended",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:error",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:pause",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:play",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:stalled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:waiting",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:auxclick",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:click",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:contextmenu",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:copy",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:cut",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drag",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragend",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragenter",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragexit",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragleave",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragstart",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drop",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:gotpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:input",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keydown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keypress",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keyup",
|
||||
"count": 2
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "interval:200",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "timeout:12000",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:60000",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:250",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:3000",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:1000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:4000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:10000",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 11,
|
||||
"intervals": 22,
|
||||
"timeoutFires": 6,
|
||||
"intervalFires": 148,
|
||||
"webSocketMessageCount": 1000,
|
||||
"webSocketBytes": 762620,
|
||||
"webSocketEvents": [
|
||||
{
|
||||
"event": "log:entry",
|
||||
"count": 850,
|
||||
"bytes": 185008
|
||||
},
|
||||
{
|
||||
"event": "sensorFrame",
|
||||
"count": 277,
|
||||
"bytes": 520169
|
||||
},
|
||||
{
|
||||
"event": "commandAck",
|
||||
"count": 200,
|
||||
"bytes": 19180
|
||||
},
|
||||
{
|
||||
"event": "session:sync",
|
||||
"count": 23,
|
||||
"bytes": 401624
|
||||
},
|
||||
{
|
||||
"event": "",
|
||||
"count": 5,
|
||||
"bytes": 5008402
|
||||
},
|
||||
{
|
||||
"event": "chat:typing",
|
||||
"count": 4,
|
||||
"bytes": 1748
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:frame",
|
||||
"count": 3,
|
||||
"bytes": 291
|
||||
},
|
||||
{
|
||||
"event": "2",
|
||||
"count": 3,
|
||||
"bytes": 3
|
||||
},
|
||||
{
|
||||
"event": "roverHostStats",
|
||||
"count": 3,
|
||||
"bytes": 2306
|
||||
},
|
||||
{
|
||||
"event": "alert",
|
||||
"count": 2,
|
||||
"bytes": 256
|
||||
},
|
||||
{
|
||||
"event": "alert:new",
|
||||
"count": 2,
|
||||
"bytes": 329
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"pSJ",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"3t",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
},
|
||||
{
|
||||
"event": "mode",
|
||||
"count": 1,
|
||||
"bytes": 27
|
||||
},
|
||||
{
|
||||
"event": "controlGranted",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "auth:role",
|
||||
"count": 1,
|
||||
"bytes": 31
|
||||
},
|
||||
{
|
||||
"event": "rovers",
|
||||
"count": 1,
|
||||
"bytes": 5485
|
||||
},
|
||||
{
|
||||
"event": "chat:init",
|
||||
"count": 1,
|
||||
"bytes": 91696
|
||||
},
|
||||
{
|
||||
"event": "overseer:memory",
|
||||
"count": 1,
|
||||
"bytes": 1505
|
||||
},
|
||||
{
|
||||
"event": "log:init",
|
||||
"count": 1,
|
||||
"bytes": 41011
|
||||
},
|
||||
{
|
||||
"event": "barcode:state",
|
||||
"count": 1,
|
||||
"bytes": 1619
|
||||
},
|
||||
{
|
||||
"event": "kinect:pointCloudFrame",
|
||||
"count": 1,
|
||||
"bytes": 262
|
||||
},
|
||||
{
|
||||
"event": "kinect:colorFrame",
|
||||
"count": 1,
|
||||
"bytes": 169
|
||||
},
|
||||
{
|
||||
"event": "430[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 42
|
||||
},
|
||||
{
|
||||
"event": "431[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "432[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:status",
|
||||
"count": 1,
|
||||
"bytes": 79
|
||||
},
|
||||
{
|
||||
"event": "Freaky",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "434[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 194
|
||||
},
|
||||
{
|
||||
"event": "435[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 194
|
||||
}
|
||||
],
|
||||
"mutationsTotal": {
|
||||
"records": 18758,
|
||||
"added": 215,
|
||||
"removed": 17,
|
||||
"attrs": 18490,
|
||||
"text": 36
|
||||
},
|
||||
"mutationTargets": [
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:name",
|
||||
"count": 5300
|
||||
},
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:type",
|
||||
"count": 2650
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:name",
|
||||
"count": 1332
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:name",
|
||||
"count": 1328
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:name",
|
||||
"count": 1324
|
||||
},
|
||||
{
|
||||
"key": "div.h-full:attributes:style",
|
||||
"count": 1238
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:name",
|
||||
"count": 666
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.chat-composer-input:attributes:name",
|
||||
"count": 666
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:name",
|
||||
"count": 666
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:type",
|
||||
"count": 666
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:type",
|
||||
"count": 662
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:d",
|
||||
"count": 621
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:fill",
|
||||
"count": 619
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:type",
|
||||
"count": 333
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:type",
|
||||
"count": 333
|
||||
},
|
||||
{
|
||||
"key": "Text:characterData:",
|
||||
"count": 36
|
||||
},
|
||||
{
|
||||
"key": "input.min-w-0.flex-1:attributes:name",
|
||||
"count": 32
|
||||
},
|
||||
{
|
||||
"key": "section.panel-section.border:attributes:style",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"key": "header.flex.items-center:attributes:style",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:stroke",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "span.inline-flex.items-center:attributes:class",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.flex:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.min-h-9:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:value",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:value",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "div.flex.flex-col:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:placeholder",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "div.rounded-sm.px-2:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.button-dark.min-w-0:attributes:disabled",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.flex:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "div.mobile-touch-control.flex:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:value",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.button-dark.text-sm:attributes:disabled",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:playsinline",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:autoplay",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:disableremoteplayback",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:autoplay",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:disableremoteplayback",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "div.fixed.bottom-2:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "span.rounded.px-1:attributes:class",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 1633,
|
||||
"records": 164,
|
||||
"added": 105,
|
||||
"removed": 1,
|
||||
"attrs": 58,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 3468,
|
||||
"records": 79,
|
||||
"added": 11,
|
||||
"removed": 2,
|
||||
"attrs": 64,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 1295,
|
||||
"records": 73,
|
||||
"added": 22,
|
||||
"removed": 5,
|
||||
"attrs": 42,
|
||||
"text": 4
|
||||
},
|
||||
{
|
||||
"at": 5655,
|
||||
"records": 72,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 72,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 5790,
|
||||
"records": 72,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 72,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 16256,
|
||||
"records": 72,
|
||||
"added": 3,
|
||||
"removed": 0,
|
||||
"attrs": 67,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 6959,
|
||||
"records": 70,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 69,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 4549,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 4590,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 5282,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 5711,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 6485,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 7777,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 8498,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 8545,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 9500,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 9569,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 11021,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 11795,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 17038,
|
||||
"records": 68,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 68,
|
||||
"text": 0
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 153,
|
||||
"average": 116.5535947712418,
|
||||
"p95": 166.69999999999982,
|
||||
"p99": 316.60000000000014
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 16,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 238.79999999701977,
|
||||
"duration": 299,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 542.8999999985099,
|
||||
"duration": 192,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3064.10000000149,
|
||||
"duration": 177,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1632.8999999985099,
|
||||
"duration": 170,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1154.8999999985099,
|
||||
"duration": 140,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2930.2999999970198,
|
||||
"duration": 133,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1505.6999999955297,
|
||||
"duration": 127,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 735.6999999955297,
|
||||
"duration": 117,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1295.8999999985099,
|
||||
"duration": 108,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3674.89999999851,
|
||||
"duration": 83,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3387,
|
||||
"duration": 81,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3567.89999999851,
|
||||
"duration": 59,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1909,
|
||||
"duration": 57,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 7985.10000000149,
|
||||
"duration": 55,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 12968.5,
|
||||
"duration": 55,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 931.6999999955297,
|
||||
"duration": 53,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": []
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 875 KiB |
@@ -0,0 +1,858 @@
|
||||
{
|
||||
"url": "https://rover.otter.land/spectate",
|
||||
"runDir": "perf/results/2026-06-12T04-55-16-888Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 6,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 30000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 29302.703615,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 379,
|
||||
"LayoutObjects": 6150,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 7059,
|
||||
"Resources": 40,
|
||||
"ContextLifecycleStateObservers": 21,
|
||||
"V8PerContextDatas": 2,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 6,
|
||||
"ResourceFetchers": 1,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 0,
|
||||
"ArrayBufferContents": 28,
|
||||
"LayoutCount": 243,
|
||||
"RecalcStyleCount": 248,
|
||||
"LayoutDuration": 0.840302,
|
||||
"RecalcStyleDuration": 1.699961,
|
||||
"DevToolsCommandDuration": 0.000155,
|
||||
"ScriptDuration": 12.114687,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 28.48339,
|
||||
"TaskOtherDuration": 13.828285,
|
||||
"ThreadTime": 30.190528,
|
||||
"ProcessTime": 39.47,
|
||||
"JSHeapUsedSize": 22447504,
|
||||
"JSHeapTotalSize": 42467328,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 29272.696156,
|
||||
"NavigationStart": 29272.010802
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 30914.699999999255,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:audio:canplay",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ended",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:error",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:pause",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:play",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:stalled",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:audio:waiting",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:window:online",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:document:visibilitychange",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:img:error",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:img:load",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "remove:window:online",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "remove:document:visibilitychange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:abort",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:canplaythrough",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:durationchange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:emptied",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:encrypted",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:loadeddata",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:loadedmetadata",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:loadstart",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:playing",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:progress",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ratechange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:resize",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:seeked",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:seeking",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:suspend",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:timeupdate",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:volumechange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:abort",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:canplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:canplaythrough",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:durationchange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:emptied",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:encrypted",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:ended",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:error",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:video:loadeddata",
|
||||
"count": 3
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "interval:3000",
|
||||
"count": 12
|
||||
},
|
||||
{
|
||||
"key": "timeout:12000",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "timeout:1200",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:60000",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "interval:200",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 14,
|
||||
"intervals": 18,
|
||||
"timeoutFires": 7,
|
||||
"intervalFires": 112,
|
||||
"webSocketMessageCount": 1000,
|
||||
"webSocketBytes": 4207474,
|
||||
"webSocketEvents": [
|
||||
{
|
||||
"event": "sensorFrame",
|
||||
"count": 1354,
|
||||
"bytes": 2531090
|
||||
},
|
||||
{
|
||||
"event": "log:entry",
|
||||
"count": 634,
|
||||
"bytes": 150179
|
||||
},
|
||||
{
|
||||
"event": "",
|
||||
"count": 126,
|
||||
"bytes": 11048477
|
||||
},
|
||||
{
|
||||
"event": "roomCamera:frame",
|
||||
"count": 117,
|
||||
"bytes": 10413
|
||||
},
|
||||
{
|
||||
"event": "commandAck",
|
||||
"count": 68,
|
||||
"bytes": 6412
|
||||
},
|
||||
{
|
||||
"event": "session:sync",
|
||||
"count": 54,
|
||||
"bytes": 914000
|
||||
},
|
||||
{
|
||||
"event": "roverHostStats",
|
||||
"count": 17,
|
||||
"bytes": 13058
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:frame",
|
||||
"count": 7,
|
||||
"bytes": 682
|
||||
},
|
||||
{
|
||||
"event": "2",
|
||||
"count": 5,
|
||||
"bytes": 5
|
||||
},
|
||||
{
|
||||
"event": "auth:role",
|
||||
"count": 4,
|
||||
"bytes": 139
|
||||
},
|
||||
{
|
||||
"event": "roomCamera:status",
|
||||
"count": 4,
|
||||
"bytes": 284
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:status",
|
||||
"count": 3,
|
||||
"bytes": 238
|
||||
},
|
||||
{
|
||||
"event": "alert",
|
||||
"count": 2,
|
||||
"bytes": 256
|
||||
},
|
||||
{
|
||||
"event": "alert:new",
|
||||
"count": 2,
|
||||
"bytes": 330
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"3a5",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"lE",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
},
|
||||
{
|
||||
"event": "mode",
|
||||
"count": 1,
|
||||
"bytes": 27
|
||||
},
|
||||
{
|
||||
"event": "controlGranted",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "rovers",
|
||||
"count": 1,
|
||||
"bytes": 5485
|
||||
},
|
||||
{
|
||||
"event": "chat:init",
|
||||
"count": 1,
|
||||
"bytes": 77415
|
||||
},
|
||||
{
|
||||
"event": "overseer:memory",
|
||||
"count": 1,
|
||||
"bytes": 520
|
||||
},
|
||||
{
|
||||
"event": "log:init",
|
||||
"count": 1,
|
||||
"bytes": 44530
|
||||
},
|
||||
{
|
||||
"event": "barcode:state",
|
||||
"count": 1,
|
||||
"bytes": 1627
|
||||
},
|
||||
{
|
||||
"event": "kinect:pointCloudFrame",
|
||||
"count": 1,
|
||||
"bytes": 262
|
||||
},
|
||||
{
|
||||
"event": "kinect:colorFrame",
|
||||
"count": 1,
|
||||
"bytes": 169
|
||||
},
|
||||
{
|
||||
"event": "430[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "431[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "432[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 194
|
||||
},
|
||||
{
|
||||
"event": "433[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "434[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "435[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "WALL-E",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "437[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "438[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "Freaky",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "4310[{\"url\":",
|
||||
"count": 1,
|
||||
"bytes": 136
|
||||
},
|
||||
{
|
||||
"event": "4311[{\"url\":",
|
||||
"count": 1,
|
||||
"bytes": 148
|
||||
},
|
||||
{
|
||||
"event": "Bweeble",
|
||||
"count": 1,
|
||||
"bytes": 42
|
||||
},
|
||||
{
|
||||
"event": "carpet",
|
||||
"count": 1,
|
||||
"bytes": 68
|
||||
},
|
||||
{
|
||||
"event": "4314[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "4315[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
},
|
||||
{
|
||||
"event": "4316[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
},
|
||||
{
|
||||
"event": "4317[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 195
|
||||
},
|
||||
{
|
||||
"event": "4318[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
},
|
||||
{
|
||||
"event": "4319[{\"succe",
|
||||
"count": 1,
|
||||
"bytes": 22
|
||||
}
|
||||
],
|
||||
"mutationsTotal": {
|
||||
"records": 5868,
|
||||
"added": 440,
|
||||
"removed": 440,
|
||||
"attrs": 4921,
|
||||
"text": 67
|
||||
},
|
||||
"mutationTargets": [
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:d",
|
||||
"count": 2688
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:fill",
|
||||
"count": 2682
|
||||
},
|
||||
{
|
||||
"key": "Text:characterData:",
|
||||
"count": 164
|
||||
},
|
||||
{
|
||||
"key": "img.h-full.w-full:attributes:src",
|
||||
"count": 117
|
||||
},
|
||||
{
|
||||
"key": "span.h-2.w-2:attributes:class",
|
||||
"count": 117
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:name",
|
||||
"count": 102
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.chat-composer-input:attributes:name",
|
||||
"count": 102
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:stroke",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "section.panel-section.border:attributes:style",
|
||||
"count": 11
|
||||
},
|
||||
{
|
||||
"key": "span.rounded.px-1.5:attributes:class",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "header.flex.items-center:attributes:style",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"key": "article.surface.px-0.5:attributes:class",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"key": "li.surface.flex:attributes:class",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:playsinline",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:autoplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:disableremoteplayback",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:autoplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:disableremoteplayback",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:name",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "div.panel-section.flex:attributes:style",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "span.rounded.px-1:attributes:class",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:type",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:value",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 13575,
|
||||
"records": 119,
|
||||
"added": 57,
|
||||
"removed": 58,
|
||||
"attrs": 4,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18089,
|
||||
"records": 117,
|
||||
"added": 52,
|
||||
"removed": 53,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 27878,
|
||||
"records": 113,
|
||||
"added": 52,
|
||||
"removed": 52,
|
||||
"attrs": 8,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 22882,
|
||||
"records": 102,
|
||||
"added": 49,
|
||||
"removed": 49,
|
||||
"attrs": 4,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 9538,
|
||||
"records": 100,
|
||||
"added": 48,
|
||||
"removed": 48,
|
||||
"attrs": 4,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 14782,
|
||||
"records": 84,
|
||||
"added": 37,
|
||||
"removed": 35,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15222,
|
||||
"records": 83,
|
||||
"added": 39,
|
||||
"removed": 39,
|
||||
"attrs": 5,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 9811,
|
||||
"records": 68,
|
||||
"added": 28,
|
||||
"removed": 28,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13733,
|
||||
"records": 36,
|
||||
"added": 13,
|
||||
"removed": 13,
|
||||
"attrs": 10,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 19439,
|
||||
"records": 26,
|
||||
"added": 10,
|
||||
"removed": 10,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 23034,
|
||||
"records": 18,
|
||||
"added": 3,
|
||||
"removed": 3,
|
||||
"attrs": 12,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 23113,
|
||||
"records": 18,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 10,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 28037,
|
||||
"records": 18,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 10,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 10169,
|
||||
"records": 16,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 8,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18258,
|
||||
"records": 16,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 8,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13903,
|
||||
"records": 15,
|
||||
"added": 4,
|
||||
"removed": 4,
|
||||
"attrs": 7,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 14108,
|
||||
"records": 15,
|
||||
"added": 2,
|
||||
"removed": 2,
|
||||
"attrs": 10,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 16376,
|
||||
"records": 15,
|
||||
"added": 2,
|
||||
"removed": 2,
|
||||
"attrs": 11,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 11700,
|
||||
"records": 14,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 14,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13012,
|
||||
"records": 14,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 13,
|
||||
"text": 1
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 264,
|
||||
"average": 113.8840909090909,
|
||||
"p95": 233.29999999999927,
|
||||
"p99": 300
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 81,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 311.5999999977648,
|
||||
"duration": 375,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1978.699999999255,
|
||||
"duration": 299,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1254.5999999977648,
|
||||
"duration": 229,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1772,
|
||||
"duration": 206,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 1485.3999999985099,
|
||||
"duration": 170,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2646.800000000745,
|
||||
"duration": 165,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2507.5,
|
||||
"duration": 139,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 693.8000000007451,
|
||||
"duration": 125,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3026.800000000745,
|
||||
"duration": 119,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4148.39999999851,
|
||||
"duration": 113,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3461.099999997765,
|
||||
"duration": 109,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9812.89999999851,
|
||||
"duration": 106,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 14821.39999999851,
|
||||
"duration": 100,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3145.800000000745,
|
||||
"duration": 97,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4447.39999999851,
|
||||
"duration": 96,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9551,
|
||||
"duration": 96,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9939.300000000745,
|
||||
"duration": 95,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 18100.89999999851,
|
||||
"duration": 92,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 23114.89999999851,
|
||||
"duration": 92,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 18302.800000000745,
|
||||
"duration": 88,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": []
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.3 MiB |
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/",
|
||||
"runDir": "perf/results/2026-06-12T05-27-45-384Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 6,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 30000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 20904.330789,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 365,
|
||||
"LayoutObjects": 1886,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 2224,
|
||||
"Resources": 24,
|
||||
"ContextLifecycleStateObservers": 14,
|
||||
"V8PerContextDatas": 2,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 0,
|
||||
"ResourceFetchers": 1,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 2,
|
||||
"ArrayBufferContents": 1,
|
||||
"LayoutCount": 11,
|
||||
"RecalcStyleCount": 12,
|
||||
"LayoutDuration": 0.81696,
|
||||
"RecalcStyleDuration": 0.7972,
|
||||
"DevToolsCommandDuration": 0.000356,
|
||||
"ScriptDuration": 26.612771,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 34.234417,
|
||||
"TaskOtherDuration": 6.00713,
|
||||
"ThreadTime": 35.26645,
|
||||
"ProcessTime": 42.01,
|
||||
"JSHeapUsedSize": 20964432,
|
||||
"JSHeapTotalSize": 35799040,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 20874.278609,
|
||||
"NavigationStart": 20868.27481
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 37773.099999997765,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "add:window:online",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:document:visibilitychange",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "remove:window:online",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "remove:document:visibilitychange",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "add:WebSocket:message",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:document:fullscreenchange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:document:webkitfullscreenchange",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:canplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ended",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:error",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:pause",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:play",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:stalled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:waiting",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:auxclick",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:click",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:contextmenu",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:copy",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:cut",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drag",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragend",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragenter",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragexit",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragleave",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragstart",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drop",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:gotpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:input",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keydown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keypress",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keyup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:lostpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mousedown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mousemove",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseout",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:paste",
|
||||
"count": 2
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "interval:250",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "interval:200",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "interval:1000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "timeout:12000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "timeout:4000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "interval:60000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "interval:30000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:10000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:592",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 14,
|
||||
"intervals": 15,
|
||||
"timeoutFires": 9,
|
||||
"intervalFires": 46,
|
||||
"webSocketMessageCount": 103,
|
||||
"webSocketBytes": 512324,
|
||||
"webSocketEvents": [
|
||||
{
|
||||
"event": "log:entry",
|
||||
"count": 62,
|
||||
"bytes": 14692
|
||||
},
|
||||
{
|
||||
"event": "session:sync",
|
||||
"count": 14,
|
||||
"bytes": 237697
|
||||
},
|
||||
{
|
||||
"event": "mode",
|
||||
"count": 2,
|
||||
"bytes": 54
|
||||
},
|
||||
{
|
||||
"event": "controlGranted",
|
||||
"count": 2,
|
||||
"bytes": 82
|
||||
},
|
||||
{
|
||||
"event": "alert",
|
||||
"count": 2,
|
||||
"bytes": 256
|
||||
},
|
||||
{
|
||||
"event": "alert:new",
|
||||
"count": 2,
|
||||
"bytes": 330
|
||||
},
|
||||
{
|
||||
"event": "auth:role",
|
||||
"count": 2,
|
||||
"bytes": 62
|
||||
},
|
||||
{
|
||||
"event": "rovers",
|
||||
"count": 2,
|
||||
"bytes": 10972
|
||||
},
|
||||
{
|
||||
"event": "chat:init",
|
||||
"count": 2,
|
||||
"bytes": 155226
|
||||
},
|
||||
{
|
||||
"event": "overseer:memory",
|
||||
"count": 2,
|
||||
"bytes": 1040
|
||||
},
|
||||
{
|
||||
"event": "log:init",
|
||||
"count": 2,
|
||||
"bytes": 87839
|
||||
},
|
||||
{
|
||||
"event": "barcode:state",
|
||||
"count": 2,
|
||||
"bytes": 3254
|
||||
},
|
||||
{
|
||||
"event": "kinect:pointCloudFrame",
|
||||
"count": 2,
|
||||
"bytes": 524
|
||||
},
|
||||
{
|
||||
"event": "{\"type\":\"con",
|
||||
"count": 1,
|
||||
"bytes": 20
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"SuW",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"Wx",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"mxD",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"mW",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
}
|
||||
],
|
||||
"mutationsTotal": {
|
||||
"records": 536,
|
||||
"added": 170,
|
||||
"removed": 11,
|
||||
"attrs": 346,
|
||||
"text": 9
|
||||
},
|
||||
"mutationTargets": [
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:name",
|
||||
"count": 102
|
||||
},
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:type",
|
||||
"count": 51
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:name",
|
||||
"count": 28
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:name",
|
||||
"count": 24
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:name",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:name",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.chat-composer-input:attributes:name",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:name",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:type",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"key": "section.panel-section.border:attributes:style",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"key": "Text:characterData:",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:type",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "header.flex.items-center:attributes:style",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:type",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:type",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"key": "div.rounded-sm.px-2:attributes:class",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.flex:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.min-h-9:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:value",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "div.fixed.bottom-2:attributes:class",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input.min-w-0.flex-1:attributes:name",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "div.flex.flex-col:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:placeholder",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "span.inline-flex.items-center:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.button-dark.min-w-0:attributes:disabled",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 17875,
|
||||
"records": 171,
|
||||
"added": 117,
|
||||
"removed": 6,
|
||||
"attrs": 46,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 24975,
|
||||
"records": 62,
|
||||
"added": 5,
|
||||
"removed": 0,
|
||||
"attrs": 57,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 37525,
|
||||
"records": 56,
|
||||
"added": 4,
|
||||
"removed": 2,
|
||||
"attrs": 50,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 29221,
|
||||
"records": 54,
|
||||
"added": 1,
|
||||
"removed": 1,
|
||||
"attrs": 50,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 33394,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 1,
|
||||
"attrs": 49,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 29801,
|
||||
"records": 28,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 27,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 30093,
|
||||
"records": 28,
|
||||
"added": 0,
|
||||
"removed": 1,
|
||||
"attrs": 27,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 245,
|
||||
"records": 27,
|
||||
"added": 27,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 11721,
|
||||
"records": 23,
|
||||
"added": 2,
|
||||
"removed": 0,
|
||||
"attrs": 20,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 10636,
|
||||
"records": 20,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 20,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 218,
|
||||
"records": 6,
|
||||
"added": 6,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 272,
|
||||
"records": 3,
|
||||
"added": 3,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 227,
|
||||
"records": 2,
|
||||
"added": 2,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 352,
|
||||
"records": 2,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 282,
|
||||
"records": 1,
|
||||
"added": 1,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 5981,
|
||||
"records": 1,
|
||||
"added": 1,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 8409,
|
||||
"records": 1,
|
||||
"added": 1,
|
||||
"removed": 0,
|
||||
"attrs": 0,
|
||||
"text": 0
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 65,
|
||||
"average": 571.7707692307692,
|
||||
"p95": 3433.0999999999985,
|
||||
"p99": 6416.4
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 34,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 12843.900000002235,
|
||||
"duration": 5032,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 25604.5,
|
||||
"duration": 3618,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 21451,
|
||||
"duration": 3524,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 34259.10000000149,
|
||||
"duration": 3267,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 30382.300000000745,
|
||||
"duration": 3012,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3396.2000000029802,
|
||||
"duration": 2633,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 6155.400000002235,
|
||||
"duration": 2255,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 19590.10000000149,
|
||||
"duration": 1285,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 17883.60000000149,
|
||||
"duration": 1281,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 8914.300000000745,
|
||||
"duration": 653,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9985.5,
|
||||
"duration": 651,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 11118.60000000149,
|
||||
"duration": 603,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 8423.300000000745,
|
||||
"duration": 370,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 24981.70000000298,
|
||||
"duration": 323,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 29579.20000000298,
|
||||
"duration": 222,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 30099.400000002235,
|
||||
"duration": 200,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 29914.10000000149,
|
||||
"duration": 179,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 25344,
|
||||
"duration": 177,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 21260.20000000298,
|
||||
"duration": 140,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 29228.300000000745,
|
||||
"duration": 124,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": []
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 664 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 160 KiB |
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/",
|
||||
"runDir": "perf/results/2026-06-12T05-30-10-213Z",
|
||||
"elapsedMs": 184508,
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
|
||||
"title": "Roomba Rover",
|
||||
"finalUrl": "http://127.0.0.1:5173/",
|
||||
"environment": {
|
||||
"cpuThrottle": 8,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true
|
||||
},
|
||||
"metrics": {
|
||||
"afterLoad": {
|
||||
"Timestamp": 21026.536855,
|
||||
"Documents": 3,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 213,
|
||||
"Nodes": 310,
|
||||
"LayoutCount": 4,
|
||||
"RecalcStyleCount": 6,
|
||||
"LayoutDuration": 0.351349,
|
||||
"RecalcStyleDuration": 0.164,
|
||||
"ScriptDuration": 8.025346,
|
||||
"TaskDuration": 12.3993,
|
||||
"JSHeapUsedSize": 8055732,
|
||||
"JSHeapTotalSize": 9846784
|
||||
},
|
||||
"end": {
|
||||
"Timestamp": 21172.00552,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 776,
|
||||
"Nodes": 2366,
|
||||
"LayoutCount": 28,
|
||||
"RecalcStyleCount": 51,
|
||||
"LayoutDuration": 2.921363,
|
||||
"RecalcStyleDuration": 2.465639,
|
||||
"ScriptDuration": 125.009213,
|
||||
"TaskDuration": 154.711298,
|
||||
"JSHeapUsedSize": 49187212,
|
||||
"JSHeapTotalSize": 81772544
|
||||
},
|
||||
"delta": {
|
||||
"Timestamp": 145.46866500000033,
|
||||
"Documents": -2,
|
||||
"Frames": 0,
|
||||
"JSEventListeners": 563,
|
||||
"Nodes": 2056,
|
||||
"LayoutCount": 24,
|
||||
"RecalcStyleCount": 45,
|
||||
"LayoutDuration": 2.570014,
|
||||
"RecalcStyleDuration": 2.3016389999999998,
|
||||
"ScriptDuration": 116.983867,
|
||||
"TaskDuration": 142.311998,
|
||||
"JSHeapUsedSize": 41131480,
|
||||
"JSHeapTotalSize": 71925760
|
||||
}
|
||||
},
|
||||
"cpuSummary": {
|
||||
"totalProfileMs": 109222.492,
|
||||
"sampleCount": 82521,
|
||||
"topSelfTime": [
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=7eee8a21",
|
||||
"functionName": "exports.jsxDEV",
|
||||
"lineNumber": 238,
|
||||
"columnNumber": 31,
|
||||
"selfMs": 50684.10400000017,
|
||||
"samples": 40987
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(program)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 11988.050000000003,
|
||||
"samples": 3951
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/chunk-YX2XZWS3.js?v=7eee8a21",
|
||||
"functionName": "exports.createElement",
|
||||
"lineNumber": 760,
|
||||
"columnNumber": 38,
|
||||
"selfMs": 6174.760000000029,
|
||||
"samples": 5020
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "createTask",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 4341.4680000000035,
|
||||
"samples": 3581
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=7eee8a21",
|
||||
"functionName": "ReactElement",
|
||||
"lineNumber": 126,
|
||||
"columnNumber": 27,
|
||||
"selfMs": 4147.347000000001,
|
||||
"samples": 3387
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "run",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 2896.0120000000024,
|
||||
"samples": 2303
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(idle)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1528.1939999999993,
|
||||
"samples": 1205
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(garbage collector)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1454.5849999999996,
|
||||
"samples": 1076
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=7eee8a21",
|
||||
"functionName": "getTaskName",
|
||||
"lineNumber": 80,
|
||||
"columnNumber": 26,
|
||||
"selfMs": 1229.8500000000001,
|
||||
"samples": 1028
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "runWithFiberInDEV",
|
||||
"lineNumber": 992,
|
||||
"columnNumber": 32,
|
||||
"selfMs": 982.0620000000005,
|
||||
"samples": 808
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "now",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 786.4740000000004,
|
||||
"samples": 634
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "validateProperty",
|
||||
"lineNumber": 2344,
|
||||
"columnNumber": 31,
|
||||
"selfMs": 754.6519999999999,
|
||||
"samples": 611
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "updatedAncestorInfoDev",
|
||||
"lineNumber": 1990,
|
||||
"columnNumber": 37,
|
||||
"selfMs": 746.4530000000003,
|
||||
"samples": 607
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=7eee8a21",
|
||||
"functionName": "jsxDEVImpl",
|
||||
"lineNumber": 167,
|
||||
"columnNumber": 25,
|
||||
"selfMs": 731.4129999999996,
|
||||
"samples": 598
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "react_stack_bottom_frame",
|
||||
"lineNumber": 18504,
|
||||
"columnNumber": 42,
|
||||
"selfMs": 652.937,
|
||||
"samples": 533
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/chunk-YX2XZWS3.js?v=7eee8a21",
|
||||
"functionName": "ReactElement",
|
||||
"lineNumber": 165,
|
||||
"columnNumber": 27,
|
||||
"selfMs": 580.7530000000002,
|
||||
"samples": 462
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "updateProperties",
|
||||
"lineNumber": 14785,
|
||||
"columnNumber": 31,
|
||||
"selfMs": 517.7620000000002,
|
||||
"samples": 424
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "createWorkInProgress",
|
||||
"lineNumber": 3630,
|
||||
"columnNumber": 35,
|
||||
"selfMs": 508.02600000000007,
|
||||
"samples": 409
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "ReactStrictModeWarnings.recordLegacyContextWarning",
|
||||
"lineNumber": 18468,
|
||||
"columnNumber": 67,
|
||||
"selfMs": 495.80399999999986,
|
||||
"samples": 409
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "commitUpdate",
|
||||
"lineNumber": 15886,
|
||||
"columnNumber": 27,
|
||||
"selfMs": 477.684,
|
||||
"samples": 370
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "beginWork",
|
||||
"lineNumber": 8415,
|
||||
"columnNumber": 24,
|
||||
"selfMs": 446.36999999999983,
|
||||
"samples": 356
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "reconcileChildrenArray",
|
||||
"lineNumber": 4935,
|
||||
"columnNumber": 39,
|
||||
"selfMs": 436.57999999999987,
|
||||
"samples": 350
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/src/components/ChatMessageRow/index.jsx",
|
||||
"functionName": "ChatMessageRow",
|
||||
"lineNumber": 195,
|
||||
"columnNumber": 38,
|
||||
"selfMs": 413.4399999999997,
|
||||
"samples": 344
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=7eee8a21",
|
||||
"functionName": "getComponentNameFromType",
|
||||
"lineNumber": 12,
|
||||
"columnNumber": 39,
|
||||
"selfMs": 408.42100000000005,
|
||||
"samples": 327
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "addObjectDiffToProperties",
|
||||
"lineNumber": 3080,
|
||||
"columnNumber": 40,
|
||||
"selfMs": 388.74499999999983,
|
||||
"samples": 298
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "setValueForStyles",
|
||||
"lineNumber": 2231,
|
||||
"columnNumber": 32,
|
||||
"selfMs": 388.087,
|
||||
"samples": 311
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "measure",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 382.2370000000001,
|
||||
"samples": 317
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 5231,
|
||||
"columnNumber": 23,
|
||||
"selfMs": 372.06799999999976,
|
||||
"samples": 277
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"functionName": "logComponentRender",
|
||||
"lineNumber": 3166,
|
||||
"columnNumber": 33,
|
||||
"selfMs": 342.3039999999999,
|
||||
"samples": 277
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:5173/src/context/SessionContext.jsx",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 331,
|
||||
"columnNumber": 20,
|
||||
"selfMs": 337.47,
|
||||
"samples": 275
|
||||
}
|
||||
]
|
||||
},
|
||||
"responsiveness": {
|
||||
"longTaskCount": 83,
|
||||
"topLongTasks": [
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 81802.5,
|
||||
"duration": 17746
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 54463,
|
||||
"duration": 6694
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 99677.39999999851,
|
||||
"duration": 6024
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 21612.599999997765,
|
||||
"duration": 5858
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 110758.5,
|
||||
"duration": 5803
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 62108,
|
||||
"duration": 5772
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 76151.89999999851,
|
||||
"duration": 5650
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 125421.19999999925,
|
||||
"duration": 5648
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 145249.3999999985,
|
||||
"duration": 5587
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 132005.09999999776,
|
||||
"duration": 5514
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 68337.5,
|
||||
"duration": 5496
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 118182.59999999776,
|
||||
"duration": 5266
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 40730,
|
||||
"duration": 5224
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 138897,
|
||||
"duration": 5148
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 151428.19999999925,
|
||||
"duration": 5134
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 106092.80000000075,
|
||||
"duration": 4665
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 32223.39999999851,
|
||||
"duration": 4328
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 4601.89999999851,
|
||||
"duration": 3794
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 8585.39999999851,
|
||||
"duration": 3021
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 16030.39999999851,
|
||||
"duration": 2773
|
||||
}
|
||||
],
|
||||
"frameCount": 152,
|
||||
"averageFrameMs": 1041.5151315789474,
|
||||
"p95FrameMs": 5816.499999999985,
|
||||
"p99FrameMs": 10849.5,
|
||||
"intervalDriftCount": 37,
|
||||
"averageIntervalDriftMs": 3979.5216216216418,
|
||||
"p95IntervalDriftMs": 13818.400000002235,
|
||||
"p99IntervalDriftMs": 29643.89999999851
|
||||
},
|
||||
"consoleMessages": [
|
||||
{
|
||||
"type": "debug",
|
||||
"text": "[vite] connecting...",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:5173/@vite/client",
|
||||
"line": 732,
|
||||
"column": 8,
|
||||
"lineNumber": 732,
|
||||
"columnNumber": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "debug",
|
||||
"text": "[vite] connected.",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:5173/@vite/client",
|
||||
"line": 826,
|
||||
"column": 11,
|
||||
"lineNumber": 826,
|
||||
"columnNumber": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:5173/node_modules/.vite/deps/react-dom_client.js?v=7eee8a21",
|
||||
"line": 20102,
|
||||
"column": 53,
|
||||
"lineNumber": 20102,
|
||||
"columnNumber": 53
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[socket] connecting to https://rover.otter.land",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:5173/src/lib/socket.js",
|
||||
"line": 7,
|
||||
"column": 8,
|
||||
"lineNumber": 7,
|
||||
"columnNumber": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "Failed to load resource: the server responded with a status of 404 ()",
|
||||
"location": {
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"line": 0,
|
||||
"column": 0,
|
||||
"lineNumber": 0,
|
||||
"columnNumber": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedRequests": [],
|
||||
"errorResponses": [
|
||||
{
|
||||
"url": "https://media.discordapp.net/attachments/1424887548622405672/1512626709692682340/cat2.PNG?ex=6a24c6e6&is=6a237566&hm=baeec4c8e666888d89711065b1dbcb391c3415f9c28d1917bdd286776b60c867&=&format=webp&quality=lossless",
|
||||
"status": 404
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/",
|
||||
"runDir": "perf/results/2026-06-12T05-34-37-713Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 6,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 30000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 21313.290308,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 1,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 1405,
|
||||
"LayoutObjects": 243,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 353,
|
||||
"Resources": 0,
|
||||
"ContextLifecycleStateObservers": 7,
|
||||
"V8PerContextDatas": 2,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 0,
|
||||
"ResourceFetchers": 1,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 2,
|
||||
"ArrayBufferContents": 1,
|
||||
"LayoutCount": 5,
|
||||
"RecalcStyleCount": 6,
|
||||
"LayoutDuration": 0.274398,
|
||||
"RecalcStyleDuration": 0.205702,
|
||||
"DevToolsCommandDuration": 0.000402,
|
||||
"ScriptDuration": 22.219943,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 31.430573,
|
||||
"TaskOtherDuration": 8.730128,
|
||||
"ThreadTime": 31.88879,
|
||||
"ProcessTime": 36.72,
|
||||
"JSHeapUsedSize": 5442836,
|
||||
"JSHeapTotalSize": 9699328,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 21283.254568,
|
||||
"NavigationStart": 21280.58028
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 32642,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "add:WebSocket:message",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:auxclick",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:click",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:contextmenu",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:copy",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:cut",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drag",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragend",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragenter",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragexit",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragleave",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragstart",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drop",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:gotpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:input",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keydown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keypress",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keyup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:lostpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mousedown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mousemove",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseout",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:paste",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointercancel",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointerdown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointermove",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointerout",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointerover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointerup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:reset",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:submit",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:touchcancel",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:touchend",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:touchstart",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:touchmove",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:wheel",
|
||||
"count": 2
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"key": "timeout:5000",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "interval:1000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:1299",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:1507",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 17,
|
||||
"intervals": 2,
|
||||
"timeoutFires": 9,
|
||||
"intervalFires": 42,
|
||||
"webSocketMessageCount": 0,
|
||||
"webSocketBytes": 0,
|
||||
"webSocketEvents": [],
|
||||
"mutationsTotal": {
|
||||
"records": 6000,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6000,
|
||||
"text": 0
|
||||
},
|
||||
"mutationTargets": [
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:name",
|
||||
"count": 8054
|
||||
},
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:name",
|
||||
"count": 8054
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:type",
|
||||
"count": 4027
|
||||
},
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:type",
|
||||
"count": 4027
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:name",
|
||||
"count": 8
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:name",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.chat-composer-input:attributes:name",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:name",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:type",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "Text:characterData:",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:type",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:value",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 26663,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26667,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26670,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26676,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26679,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26682,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26684,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26689,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26696,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26700,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26704,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26711,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26715,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26719,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26726,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26730,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26732,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26737,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26744,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 26748,
|
||||
"records": 6,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 6,
|
||||
"text": 0
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 1678,
|
||||
"average": 19.307985697258644,
|
||||
"p95": 16.700000000000728,
|
||||
"p99": 16.80000000000291
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 8,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 625.4000000022352,
|
||||
"duration": 2057,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2705.400000002235,
|
||||
"duration": 1120,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3825.900000002235,
|
||||
"duration": 400,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4491.699999999255,
|
||||
"duration": 196,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4237.300000000745,
|
||||
"duration": 158,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4931.5,
|
||||
"duration": 115,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4828.699999999255,
|
||||
"duration": 101,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 25127.300000000745,
|
||||
"duration": 67,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": [
|
||||
"connect_error undefined websocket error undefined",
|
||||
"connect_error undefined websocket error undefined",
|
||||
"connect_error undefined websocket error undefined",
|
||||
"connect_error undefined websocket error undefined",
|
||||
"connect_error undefined websocket error undefined",
|
||||
"connect_error undefined websocket error undefined",
|
||||
"connect_error undefined websocket error undefined"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,652 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/",
|
||||
"runDir": "perf/results/2026-06-12T05-37-11-679Z",
|
||||
"elapsedMs": 48144,
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
|
||||
"title": "Roomba Rover",
|
||||
"finalUrl": "http://127.0.0.1:4173/",
|
||||
"environment": {
|
||||
"cpuThrottle": 8,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true
|
||||
},
|
||||
"metrics": {
|
||||
"afterLoad": {
|
||||
"Timestamp": 21442.422344,
|
||||
"Documents": 3,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 333,
|
||||
"Nodes": 364,
|
||||
"LayoutCount": 5,
|
||||
"RecalcStyleCount": 7,
|
||||
"LayoutDuration": 0.567739,
|
||||
"RecalcStyleDuration": 0.239019,
|
||||
"ScriptDuration": 5.57054,
|
||||
"TaskDuration": 7.681989,
|
||||
"JSHeapUsedSize": 3910380,
|
||||
"JSHeapTotalSize": 6815744
|
||||
},
|
||||
"end": {
|
||||
"Timestamp": 21481.571566,
|
||||
"Documents": 3,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 3020,
|
||||
"Nodes": 424,
|
||||
"LayoutCount": 10,
|
||||
"RecalcStyleCount": 20,
|
||||
"LayoutDuration": 0.71593,
|
||||
"RecalcStyleDuration": 0.592888,
|
||||
"ScriptDuration": 25.51692,
|
||||
"TaskDuration": 45.394233,
|
||||
"JSHeapUsedSize": 7257516,
|
||||
"JSHeapTotalSize": 8486912
|
||||
},
|
||||
"delta": {
|
||||
"Timestamp": 39.14922200000001,
|
||||
"Documents": 0,
|
||||
"Frames": 0,
|
||||
"JSEventListeners": 2687,
|
||||
"Nodes": 60,
|
||||
"LayoutCount": 5,
|
||||
"RecalcStyleCount": 13,
|
||||
"LayoutDuration": 0.14819099999999996,
|
||||
"RecalcStyleDuration": 0.353869,
|
||||
"ScriptDuration": 19.946379999999998,
|
||||
"TaskDuration": 37.712244,
|
||||
"JSHeapUsedSize": 3347136,
|
||||
"JSHeapTotalSize": 1671168
|
||||
}
|
||||
},
|
||||
"cpuSummary": {
|
||||
"totalProfileMs": 36856.206,
|
||||
"sampleCount": 27449,
|
||||
"topSelfTime": [
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(program)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 16008.739999999993,
|
||||
"samples": 10998
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "postMessage",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1400.7229999999997,
|
||||
"samples": 1076
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(idle)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 1159.0759999999996,
|
||||
"samples": 921
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "lt",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 7349,
|
||||
"selfMs": 939.4450000000005,
|
||||
"samples": 747
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "ad",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 7898,
|
||||
"selfMs": 678.3770000000004,
|
||||
"samples": 534
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "Nc",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 94591,
|
||||
"selfMs": 648.5520000000006,
|
||||
"samples": 533
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "(anonymous)",
|
||||
"lineNumber": 17,
|
||||
"columnNumber": 58982,
|
||||
"selfMs": 609.3419999999993,
|
||||
"samples": 479
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "requestAnimationFrame",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 569.4970000000001,
|
||||
"samples": 461
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "C9",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 94304,
|
||||
"selfMs": 480.7910000000001,
|
||||
"samples": 384
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "Z4",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 28258,
|
||||
"selfMs": 403.8930000000001,
|
||||
"samples": 305
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "qa",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 23434,
|
||||
"selfMs": 401.37100000000004,
|
||||
"samples": 328
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "queueMicrotask",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 378.9960000000001,
|
||||
"samples": 289
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "B6",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 34145,
|
||||
"selfMs": 375.95699999999994,
|
||||
"samples": 307
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "a2",
|
||||
"lineNumber": 9,
|
||||
"columnNumber": 71,
|
||||
"selfMs": 349.62499999999983,
|
||||
"samples": 295
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "c5",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 34654,
|
||||
"selfMs": 345.6060000000002,
|
||||
"samples": 274
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "K4",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 79484,
|
||||
"selfMs": 314.27200000000005,
|
||||
"samples": 240
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "we",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 73874,
|
||||
"selfMs": 306.5540000000002,
|
||||
"samples": 251
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "C1",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 40723,
|
||||
"selfMs": 284.553,
|
||||
"samples": 221
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "Eh",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 95724,
|
||||
"selfMs": 280.467,
|
||||
"samples": 229
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "Kl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 117211,
|
||||
"selfMs": 277.51900000000006,
|
||||
"samples": 215
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "U8",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 7096,
|
||||
"selfMs": 276.48700000000014,
|
||||
"samples": 222
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "z2",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 49302,
|
||||
"selfMs": 276.39199999999994,
|
||||
"samples": 206
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "Kt",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 48095,
|
||||
"selfMs": 273.0230000000001,
|
||||
"samples": 220
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "(garbage collector)",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 268.44300000000004,
|
||||
"samples": 207
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "d1",
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 70367,
|
||||
"selfMs": 268.08499999999987,
|
||||
"samples": 219
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "zl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 80827,
|
||||
"selfMs": 263.652,
|
||||
"samples": 209
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "now",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 259.23800000000006,
|
||||
"samples": 210
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "Fl",
|
||||
"lineNumber": 8,
|
||||
"columnNumber": 101199,
|
||||
"selfMs": 229.16799999999992,
|
||||
"samples": 178
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"functionName": "$",
|
||||
"lineNumber": 1,
|
||||
"columnNumber": 10516,
|
||||
"selfMs": 228.36400000000006,
|
||||
"samples": 178
|
||||
},
|
||||
{
|
||||
"url": "",
|
||||
"functionName": "removeAttribute",
|
||||
"lineNumber": -1,
|
||||
"columnNumber": -1,
|
||||
"selfMs": 224.73000000000005,
|
||||
"samples": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
"responsiveness": {
|
||||
"longTaskCount": 19,
|
||||
"topLongTasks": [
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 577.8000000007451,
|
||||
"duration": 2326
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 2934,
|
||||
"duration": 1390
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 4326.39999999851,
|
||||
"duration": 671
|
||||
},
|
||||
{
|
||||
"name": "unknown",
|
||||
"startTime": 8756.5,
|
||||
"duration": 354
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 5309,
|
||||
"duration": 284
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 5012.89999999851,
|
||||
"duration": 204
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 5968.5,
|
||||
"duration": 199
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 28664.800000000745,
|
||||
"duration": 144
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 5824.800000000745,
|
||||
"duration": 142
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 29231.199999999255,
|
||||
"duration": 118
|
||||
},
|
||||
{
|
||||
"name": "unknown",
|
||||
"startTime": 9170.599999997765,
|
||||
"duration": 107
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 29349.599999997765,
|
||||
"duration": 95
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 28837.89999999851,
|
||||
"duration": 89
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 27044.800000000745,
|
||||
"duration": 80
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 9278.300000000745,
|
||||
"duration": 73
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 30803.39999999851,
|
||||
"duration": 70
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 5632.39999999851,
|
||||
"duration": 60
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 9110.800000000745,
|
||||
"duration": 59
|
||||
},
|
||||
{
|
||||
"name": "self",
|
||||
"startTime": 9434.800000000745,
|
||||
"duration": 53
|
||||
}
|
||||
],
|
||||
"frameCount": 2103,
|
||||
"averageFrameMs": 22.205515929624347,
|
||||
"p95FrameMs": 16.799999999999272,
|
||||
"p99FrameMs": 100,
|
||||
"intervalDriftCount": 155,
|
||||
"averageIntervalDriftMs": 52.86322580645642,
|
||||
"p95IntervalDriftMs": 113.59999999776483,
|
||||
"p99IntervalDriftMs": 2354.099999997765
|
||||
},
|
||||
"consoleMessages": [
|
||||
{
|
||||
"type": "info",
|
||||
"text": "[socket] connecting to http://127.0.0.1:4173",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63768,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63768
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "WebSocket connection to 'ws://127.0.0.1:4173/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 200",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 0,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"text": "connect_error undefined websocket error undefined",
|
||||
"location": {
|
||||
"url": "http://127.0.0.1:4173/assets/index-DhJId5qi.js",
|
||||
"line": 11,
|
||||
"column": 63988,
|
||||
"lineNumber": 11,
|
||||
"columnNumber": 63988
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedRequests": [],
|
||||
"errorResponses": []
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/",
|
||||
"runDir": "perf/results/2026-06-12T06-07-57-124Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 6,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 10000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 23292.158609,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 3,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 398,
|
||||
"LayoutObjects": 1945,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 2266,
|
||||
"Resources": 18,
|
||||
"ContextLifecycleStateObservers": 13,
|
||||
"V8PerContextDatas": 4,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 0,
|
||||
"ResourceFetchers": 3,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 2,
|
||||
"ArrayBufferContents": 3,
|
||||
"LayoutCount": 12,
|
||||
"RecalcStyleCount": 30,
|
||||
"LayoutDuration": 0.928481,
|
||||
"RecalcStyleDuration": 0.991796,
|
||||
"DevToolsCommandDuration": 0.000323,
|
||||
"ScriptDuration": 6.326745,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 11.079249,
|
||||
"TaskOtherDuration": 2.831904,
|
||||
"ThreadTime": 11.911973,
|
||||
"ProcessTime": 14.42,
|
||||
"JSHeapUsedSize": 8961292,
|
||||
"JSHeapTotalSize": 12058624,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 23282.130574,
|
||||
"NavigationStart": 23279.964199
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 20101.5,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "add:window:online",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "add:document:visibilitychange",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "remove:window:online",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:document:visibilitychange",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:audio:canplay",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ended",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:error",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:pause",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:play",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:stalled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:audio:waiting",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:auxclick",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:click",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:contextmenu",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:copy",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:cut",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drag",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragend",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragenter",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragexit",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragleave",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragstart",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drop",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:gotpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:input",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keydown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keypress",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keyup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:lostpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mousedown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mousemove",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseout",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mouseup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:paste",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointercancel",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointerdown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:pointermove",
|
||||
"count": 2
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "interval:250",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:60000",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "timeout:12000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "interval:200",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:1000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:4000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:10000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:3000",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 10,
|
||||
"intervals": 12,
|
||||
"timeoutFires": 4,
|
||||
"intervalFires": 45,
|
||||
"webSocketMessageCount": 285,
|
||||
"webSocketBytes": 5610397,
|
||||
"webSocketEvents": [
|
||||
{
|
||||
"event": "log:entry",
|
||||
"count": 150,
|
||||
"bytes": 35714
|
||||
},
|
||||
{
|
||||
"event": "sensorFrame",
|
||||
"count": 76,
|
||||
"bytes": 142196
|
||||
},
|
||||
{
|
||||
"event": "session:sync",
|
||||
"count": 18,
|
||||
"bytes": 306294
|
||||
},
|
||||
{
|
||||
"event": "commandAck",
|
||||
"count": 12,
|
||||
"bytes": 1128
|
||||
},
|
||||
{
|
||||
"event": "",
|
||||
"count": 4,
|
||||
"bytes": 4992972
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:frame",
|
||||
"count": 3,
|
||||
"bytes": 291
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"CQG",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"-s",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
},
|
||||
{
|
||||
"event": "mode",
|
||||
"count": 1,
|
||||
"bytes": 27
|
||||
},
|
||||
{
|
||||
"event": "controlGranted",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "alert",
|
||||
"count": 1,
|
||||
"bytes": 128
|
||||
},
|
||||
{
|
||||
"event": "alert:new",
|
||||
"count": 1,
|
||||
"bytes": 165
|
||||
},
|
||||
{
|
||||
"event": "auth:role",
|
||||
"count": 1,
|
||||
"bytes": 31
|
||||
},
|
||||
{
|
||||
"event": "rovers",
|
||||
"count": 1,
|
||||
"bytes": 5453
|
||||
},
|
||||
{
|
||||
"event": "chat:init",
|
||||
"count": 1,
|
||||
"bytes": 77613
|
||||
},
|
||||
{
|
||||
"event": "overseer:memory",
|
||||
"count": 1,
|
||||
"bytes": 520
|
||||
},
|
||||
{
|
||||
"event": "log:init",
|
||||
"count": 1,
|
||||
"bytes": 44230
|
||||
},
|
||||
{
|
||||
"event": "barcode:state",
|
||||
"count": 1,
|
||||
"bytes": 1627
|
||||
},
|
||||
{
|
||||
"event": "kinect:pointCloudFrame",
|
||||
"count": 1,
|
||||
"bytes": 262
|
||||
},
|
||||
{
|
||||
"event": "kinect:colorFrame",
|
||||
"count": 1,
|
||||
"bytes": 169
|
||||
},
|
||||
{
|
||||
"event": "430[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "431[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 194
|
||||
},
|
||||
{
|
||||
"event": "432[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "roverHostStats",
|
||||
"count": 1,
|
||||
"bytes": 766
|
||||
},
|
||||
{
|
||||
"event": "2",
|
||||
"count": 1,
|
||||
"bytes": 1
|
||||
},
|
||||
{
|
||||
"event": "433[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:status",
|
||||
"count": 1,
|
||||
"bytes": 79
|
||||
},
|
||||
{
|
||||
"event": "WALL-E",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
}
|
||||
],
|
||||
"mutationsTotal": {
|
||||
"records": 4729,
|
||||
"added": 175,
|
||||
"removed": 11,
|
||||
"attrs": 4524,
|
||||
"text": 19
|
||||
},
|
||||
"mutationTargets": [
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:name",
|
||||
"count": 1866
|
||||
},
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:type",
|
||||
"count": 933
|
||||
},
|
||||
{
|
||||
"key": "div.h-full:attributes:style",
|
||||
"count": 580
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:d",
|
||||
"count": 292
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:fill",
|
||||
"count": 290
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:name",
|
||||
"count": 242
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:type",
|
||||
"count": 121
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:name",
|
||||
"count": 28
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:name",
|
||||
"count": 28
|
||||
},
|
||||
{
|
||||
"key": "Text:characterData:",
|
||||
"count": 19
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.chat-composer-input:attributes:name",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:name",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:name",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:type",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"key": "section.panel-section.border:attributes:style",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"key": "input.min-w-0.flex-1:attributes:name",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:type",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "header.flex.items-center:attributes:style",
|
||||
"count": 9
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:type",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:stroke",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.flex:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.min-h-9:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:value",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "span.inline-flex.items-center:attributes:class",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:value",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:playsinline",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:autoplay",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:disableremoteplayback",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "div.rounded-sm.px-2:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "div.flex.flex-col:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:placeholder",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.button-dark.min-w-0:attributes:disabled",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "div.fixed.bottom-2:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.flex:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "div.mobile-touch-control.flex:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:value",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.button-dark.text-sm:attributes:disabled",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:autoplay",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:disableremoteplayback",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 7974,
|
||||
"records": 241,
|
||||
"added": 123,
|
||||
"removed": 6,
|
||||
"attrs": 110,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 11575,
|
||||
"records": 147,
|
||||
"added": 11,
|
||||
"removed": 2,
|
||||
"attrs": 132,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 12385,
|
||||
"records": 131,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 127,
|
||||
"text": 4
|
||||
},
|
||||
{
|
||||
"at": 12981,
|
||||
"records": 119,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 119,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18332,
|
||||
"records": 117,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 116,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 17140,
|
||||
"records": 111,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 111,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13371,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13636,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 13804,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 14271,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15643,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15909,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18503,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15238,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15283,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15415,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15495,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 15947,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 16169,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 16317,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 86,
|
||||
"average": 206.1918604651163,
|
||||
"p95": 566.5999999999995,
|
||||
"p99": 3399.8999999999996
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 61,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 5290.5,
|
||||
"duration": 2689,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 365.4000000022352,
|
||||
"duration": 1807,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 2182.900000002235,
|
||||
"duration": 1038,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 10371.10000000149,
|
||||
"duration": 709,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 9852,
|
||||
"duration": 519,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 8202.60000000149,
|
||||
"duration": 394,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3221.699999999255,
|
||||
"duration": 385,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3882.5,
|
||||
"duration": 356,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 11221.5,
|
||||
"duration": 353,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 18042.900000002235,
|
||||
"duration": 292,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 12110.900000002235,
|
||||
"duration": 276,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 12738.800000000745,
|
||||
"duration": 243,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 16946.5,
|
||||
"duration": 195,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 11575.5,
|
||||
"duration": 186,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3615.60000000149,
|
||||
"duration": 185,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 14671.699999999255,
|
||||
"duration": 160,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4239.199999999255,
|
||||
"duration": 130,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 14837.5,
|
||||
"duration": 110,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 15717.60000000149,
|
||||
"duration": 108,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 17352.400000002235,
|
||||
"duration": 107,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": []
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 680 KiB |
@@ -0,0 +1,900 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4173/",
|
||||
"runDir": "perf/results/2026-06-12T06-09-03-973Z-root-runtime",
|
||||
"environment": {
|
||||
"cpuThrottle": 8,
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"isMobile": true,
|
||||
"sampleMs": 30000
|
||||
},
|
||||
"metrics": {
|
||||
"Timestamp": 23379.874681,
|
||||
"AudioHandlers": 0,
|
||||
"AudioWorkletProcessors": 0,
|
||||
"Documents": 3,
|
||||
"Frames": 1,
|
||||
"JSEventListeners": 683,
|
||||
"LayoutObjects": 1975,
|
||||
"MediaKeySessions": 0,
|
||||
"MediaKeys": 0,
|
||||
"Nodes": 2710,
|
||||
"Resources": 20,
|
||||
"ContextLifecycleStateObservers": 20,
|
||||
"V8PerContextDatas": 4,
|
||||
"WorkerGlobalScopes": 0,
|
||||
"UACSSResources": 0,
|
||||
"RTCPeerConnections": 4,
|
||||
"ResourceFetchers": 3,
|
||||
"AdSubframes": 0,
|
||||
"DetachedScriptStates": 2,
|
||||
"ArrayBufferContents": 12,
|
||||
"LayoutCount": 49,
|
||||
"RecalcStyleCount": 77,
|
||||
"LayoutDuration": 1.49198,
|
||||
"RecalcStyleDuration": 1.886372,
|
||||
"DevToolsCommandDuration": 0.000349,
|
||||
"ScriptDuration": 14.419283,
|
||||
"V8CompileDuration": 0,
|
||||
"TaskDuration": 29.584723,
|
||||
"TaskOtherDuration": 11.786739,
|
||||
"ThreadTime": 32.430498,
|
||||
"ProcessTime": 38.63,
|
||||
"JSHeapUsedSize": 7496328,
|
||||
"JSHeapTotalSize": 13107200,
|
||||
"FirstMeaningfulPaint": 0,
|
||||
"DomContentLoaded": 23349.78059,
|
||||
"NavigationStart": 23346.788167
|
||||
},
|
||||
"snapshot": {
|
||||
"elapsedMs": 38566.699999999255,
|
||||
"listeners": [
|
||||
{
|
||||
"key": "add:input:invalid",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "add:window:online",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:document:visibilitychange",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:audio:canplay",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:audio:ended",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:audio:error",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:audio:pause",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:audio:play",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:audio:stalled",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "add:audio:waiting",
|
||||
"count": 6
|
||||
},
|
||||
{
|
||||
"key": "remove:window:online",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "remove:document:visibilitychange",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "remove:audio:play",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:audio:pause",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:audio:ended",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:audio:error",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:audio:waiting",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:audio:canplay",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "remove:audio:stalled",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "add:WebSocket:message",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:auxclick",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:click",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:contextmenu",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:copy",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:cut",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drag",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragend",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragenter",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragexit",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragleave",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragover",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:dragstart",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:drop",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:gotpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:input",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keydown",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keypress",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:keyup",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:lostpointercapture",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "add:div#root:mousedown",
|
||||
"count": 2
|
||||
}
|
||||
],
|
||||
"timers": [
|
||||
{
|
||||
"key": "timeout:0",
|
||||
"count": 5
|
||||
},
|
||||
{
|
||||
"key": "interval:250",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "interval:200",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "timeout:2000",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "timeout:12000",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "interval:60000",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "timeout:15000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "timeout:4000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "interval:3000",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "interval:1000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "interval:2000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:10000",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:1282",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "timeout:1257",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"timeouts": 19,
|
||||
"intervals": 15,
|
||||
"timeoutFires": 10,
|
||||
"intervalFires": 121,
|
||||
"webSocketMessageCount": 533,
|
||||
"webSocketBytes": 11199188,
|
||||
"webSocketEvents": [
|
||||
{
|
||||
"event": "log:entry",
|
||||
"count": 308,
|
||||
"bytes": 71889
|
||||
},
|
||||
{
|
||||
"event": "sensorFrame",
|
||||
"count": 90,
|
||||
"bytes": 168271
|
||||
},
|
||||
{
|
||||
"event": "commandAck",
|
||||
"count": 36,
|
||||
"bytes": 3396
|
||||
},
|
||||
{
|
||||
"event": "session:sync",
|
||||
"count": 33,
|
||||
"bytes": 560916
|
||||
},
|
||||
{
|
||||
"event": "",
|
||||
"count": 14,
|
||||
"bytes": 10130394
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:frame",
|
||||
"count": 10,
|
||||
"bytes": 967
|
||||
},
|
||||
{
|
||||
"event": "mode",
|
||||
"count": 2,
|
||||
"bytes": 54
|
||||
},
|
||||
{
|
||||
"event": "controlGranted",
|
||||
"count": 2,
|
||||
"bytes": 82
|
||||
},
|
||||
{
|
||||
"event": "alert",
|
||||
"count": 2,
|
||||
"bytes": 256
|
||||
},
|
||||
{
|
||||
"event": "alert:new",
|
||||
"count": 2,
|
||||
"bytes": 330
|
||||
},
|
||||
{
|
||||
"event": "auth:role",
|
||||
"count": 2,
|
||||
"bytes": 62
|
||||
},
|
||||
{
|
||||
"event": "rovers",
|
||||
"count": 2,
|
||||
"bytes": 10906
|
||||
},
|
||||
{
|
||||
"event": "chat:init",
|
||||
"count": 2,
|
||||
"bytes": 155226
|
||||
},
|
||||
{
|
||||
"event": "overseer:memory",
|
||||
"count": 2,
|
||||
"bytes": 1040
|
||||
},
|
||||
{
|
||||
"event": "log:init",
|
||||
"count": 2,
|
||||
"bytes": 89130
|
||||
},
|
||||
{
|
||||
"event": "barcode:state",
|
||||
"count": 2,
|
||||
"bytes": 3254
|
||||
},
|
||||
{
|
||||
"event": "kinect:pointCloudFrame",
|
||||
"count": 2,
|
||||
"bytes": 524
|
||||
},
|
||||
{
|
||||
"event": "kinect:colorFrame",
|
||||
"count": 2,
|
||||
"bytes": 338
|
||||
},
|
||||
{
|
||||
"event": "roverSnapshot:status",
|
||||
"count": 2,
|
||||
"bytes": 156
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"a3i",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"Bp",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
},
|
||||
{
|
||||
"event": "430[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "431[{\"succes",
|
||||
"count": 1,
|
||||
"bytes": 194
|
||||
},
|
||||
{
|
||||
"event": "2",
|
||||
"count": 1,
|
||||
"bytes": 1
|
||||
},
|
||||
{
|
||||
"event": "432[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "433[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "WALL-E",
|
||||
"count": 1,
|
||||
"bytes": 40
|
||||
},
|
||||
{
|
||||
"event": "0{\"sid\":\"3kq",
|
||||
"count": 1,
|
||||
"bytes": 106
|
||||
},
|
||||
{
|
||||
"event": "40{\"sid\":\"BX",
|
||||
"count": 1,
|
||||
"bytes": 32
|
||||
},
|
||||
{
|
||||
"event": "436[{\"error\"",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "437[{\"error\"",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "438[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 133
|
||||
},
|
||||
{
|
||||
"event": "439[{\"url\":\"",
|
||||
"count": 1,
|
||||
"bytes": 145
|
||||
},
|
||||
{
|
||||
"event": "Freaky",
|
||||
"count": 1,
|
||||
"bytes": 41
|
||||
},
|
||||
{
|
||||
"event": "roverHostStats",
|
||||
"count": 1,
|
||||
"bytes": 767
|
||||
}
|
||||
],
|
||||
"mutationsTotal": {
|
||||
"records": 6037,
|
||||
"added": 199,
|
||||
"removed": 35,
|
||||
"attrs": 5767,
|
||||
"text": 36
|
||||
},
|
||||
"mutationTargets": [
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:name",
|
||||
"count": 2648
|
||||
},
|
||||
{
|
||||
"key": "input.accent-emerald-400:attributes:type",
|
||||
"count": 1324
|
||||
},
|
||||
{
|
||||
"key": "div.h-full:attributes:style",
|
||||
"count": 512
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:name",
|
||||
"count": 338
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:d",
|
||||
"count": 262
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:fill",
|
||||
"count": 256
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:type",
|
||||
"count": 169
|
||||
},
|
||||
{
|
||||
"key": "Text:characterData:",
|
||||
"count": 36
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:name",
|
||||
"count": 32
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:name",
|
||||
"count": 28
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:name",
|
||||
"count": 22
|
||||
},
|
||||
{
|
||||
"key": "section.panel-section.border:attributes:style",
|
||||
"count": 20
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.chat-composer-input:attributes:name",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "header.flex.items-center:attributes:style",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "path.[object.SVGAnimatedString]:attributes:stroke",
|
||||
"count": 18
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:name",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:type",
|
||||
"count": 14
|
||||
},
|
||||
{
|
||||
"key": "input.accent-cyan-500:attributes:type",
|
||||
"count": 11
|
||||
},
|
||||
{
|
||||
"key": "input.min-w-0.flex-1:attributes:name",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"key": "input:attributes:type",
|
||||
"count": 7
|
||||
},
|
||||
{
|
||||
"key": "div.rounded-sm.px-2:attributes:class",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "div.fixed.bottom-2:attributes:class",
|
||||
"count": 4
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:value",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "span.inline-flex.items-center:attributes:class",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.flex:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.min-h-9:attributes:disabled",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "button.mobile-touch-control.flex:attributes:class",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "div.mobile-touch-control.flex:attributes:class",
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"key": "input#replay-sources-mobile-portrait-title.field-input.min-w-0:attributes:placeholder",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:playsinline",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:autoplay",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "video.h-full.w-full:attributes:disableremoteplayback",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:autoplay",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "audio:attributes:disableremoteplayback",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.flex-1:attributes:value",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "span.inline-block.rounded:attributes:style",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"key": "div.flex.flex-col:attributes:class",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.button-dark.min-w-0:attributes:disabled",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "input.field-input.w-full:attributes:value",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"key": "button.button-dark.text-sm:attributes:disabled",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"mutationBursts": [
|
||||
{
|
||||
"at": 10936,
|
||||
"records": 241,
|
||||
"added": 123,
|
||||
"removed": 6,
|
||||
"attrs": 110,
|
||||
"text": 2
|
||||
},
|
||||
{
|
||||
"at": 27789,
|
||||
"records": 177,
|
||||
"added": 9,
|
||||
"removed": 17,
|
||||
"attrs": 145,
|
||||
"text": 6
|
||||
},
|
||||
{
|
||||
"at": 17412,
|
||||
"records": 133,
|
||||
"added": 2,
|
||||
"removed": 2,
|
||||
"attrs": 118,
|
||||
"text": 11
|
||||
},
|
||||
{
|
||||
"at": 31614,
|
||||
"records": 111,
|
||||
"added": 2,
|
||||
"removed": 1,
|
||||
"attrs": 104,
|
||||
"text": 4
|
||||
},
|
||||
{
|
||||
"at": 37740,
|
||||
"records": 100,
|
||||
"added": 0,
|
||||
"removed": 1,
|
||||
"attrs": 99,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 38360,
|
||||
"records": 99,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 99,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 17915,
|
||||
"records": 59,
|
||||
"added": 11,
|
||||
"removed": 2,
|
||||
"attrs": 45,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 19317,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 20163,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 20363,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 23753,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 24543,
|
||||
"records": 51,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 51,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 31831,
|
||||
"records": 51,
|
||||
"added": 11,
|
||||
"removed": 2,
|
||||
"attrs": 37,
|
||||
"text": 1
|
||||
},
|
||||
{
|
||||
"at": 18579,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 18763,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 19129,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 19374,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 21234,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 22011,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
},
|
||||
{
|
||||
"at": 22767,
|
||||
"records": 47,
|
||||
"added": 0,
|
||||
"removed": 0,
|
||||
"attrs": 47,
|
||||
"text": 0
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"count": 181,
|
||||
"average": 185.53591160220995,
|
||||
"p95": 349.90000000000146,
|
||||
"p99": 1849.9000000000015
|
||||
},
|
||||
"longTasks": {
|
||||
"count": 165,
|
||||
"top": [
|
||||
{
|
||||
"startTime": 7151.60000000149,
|
||||
"duration": 3795,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 459.6000000014901,
|
||||
"duration": 2544,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 3019.199999999255,
|
||||
"duration": 1426,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 29673.900000002235,
|
||||
"duration": 1066,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 14211,
|
||||
"duration": 972,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 28901.800000000745,
|
||||
"duration": 772,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 13490.699999999255,
|
||||
"duration": 720,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 16782.400000002235,
|
||||
"duration": 639,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 11240.199999999255,
|
||||
"duration": 552,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 31092.5,
|
||||
"duration": 524,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4445.60000000149,
|
||||
"duration": 495,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 5394.800000000745,
|
||||
"duration": 459,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 27348.199999999255,
|
||||
"duration": 445,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 24835.10000000149,
|
||||
"duration": 363,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 4963.800000000745,
|
||||
"duration": 272,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 37513.60000000149,
|
||||
"duration": 228,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 17919.5,
|
||||
"duration": 215,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 27801.60000000149,
|
||||
"duration": 193,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 17425.10000000149,
|
||||
"duration": 179,
|
||||
"name": "self"
|
||||
},
|
||||
{
|
||||
"startTime": 31885.900000002235,
|
||||
"duration": 176,
|
||||
"name": "self"
|
||||
}
|
||||
]
|
||||
},
|
||||
"consoleErrors": []
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 703 KiB |