Compare commits
425 Commits
19c7e01b2a
...
staging-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8413093d43 | ||
|
|
1a8a1736bf | ||
|
|
51c7d172b3 | ||
|
|
6999ae5680 | ||
|
|
0c3f74e7de | ||
|
|
d2703ff84b | ||
|
|
1a9e0825fc | ||
|
|
284a861927 | ||
|
|
02b4e6b5b4 | ||
|
|
60084c292e | ||
|
|
fa22a080b9 | ||
|
|
70f0af98f6 | ||
|
|
cd4df316ae | ||
|
|
ff55dce746 | ||
|
|
5a86c03f74 | ||
| 1c2df46ac4 | |||
| 8839620e63 | |||
| c03126e8f8 | |||
|
|
10ef36859d | ||
|
|
4acb75f2bd | ||
|
|
77e2fb2537 | ||
|
|
c7bbb97a68 | ||
|
|
6d1c360c02 | ||
|
|
3b73eb3bd1 | ||
|
|
6ffcc056ad | ||
|
|
742f680d0d | ||
|
|
c872f1c6b0 | ||
|
|
bc5a40f143 | ||
|
|
c2bd3f6273 | ||
|
|
343dee3576 | ||
|
|
ebcafd3c6d | ||
|
|
5231b5ca4b | ||
|
|
1195456bee | ||
|
|
48de6b9821 | ||
|
|
cd4a17fe31 | ||
|
|
d3a5b3e6ef | ||
|
|
3c4c6c7389 | ||
|
|
876f728aa2 | ||
|
|
950a6dabd8 | ||
|
|
1d9589a186 | ||
|
|
b13fa7dc05 | ||
|
|
069f6c3ec7 | ||
|
|
5a27b79b51 | ||
|
|
72453c80bf | ||
| 14800ffb1e | |||
| e2f36d01bc | |||
| 55b231b456 | |||
|
|
b4b2607df1 | ||
|
|
ac9ba4776c | ||
|
|
85aca0d022 | ||
|
|
80c74b2d1a | ||
|
|
d28f224ad5 | ||
|
|
f2a808ed13 | ||
|
|
4ef420651d | ||
|
|
65ce66a541 | ||
|
|
deae53b721 | ||
|
|
f459e83861 | ||
|
|
badab99242 | ||
|
|
84124ba1b1 | ||
|
|
2ad0d2072d | ||
|
|
ff1632dcda | ||
|
|
531b8c1d09 | ||
|
|
a8128cef8d | ||
|
|
3baffb2a69 | ||
|
|
06bdf999a6 | ||
|
|
76ff1f4d4f | ||
|
|
2360b4147c | ||
|
|
37874ff58e | ||
|
|
aef13155fc | ||
|
|
1d4f104524 | ||
|
|
11ec4b4816 | ||
|
|
2bd899848d | ||
|
|
18a6e8d24c | ||
|
|
13c686a8a1 | ||
|
|
7a172c0306 | ||
|
|
7fc04fcf20 | ||
|
|
a40ea61415 | ||
| eba517d34d | |||
|
|
38257492bd | ||
| 93592c984d | |||
|
|
7a08bc0b2b | ||
|
|
25e8cac613 | ||
|
|
02eaea85d8 | ||
|
|
6c433d642d | ||
|
|
7aed3e09e8 | ||
|
|
e0e6ab0de6 | ||
|
|
7a1cd8a6f6 | ||
|
|
9407d500c8 | ||
| 9f1dd7def1 | |||
|
|
480f188d86 | ||
| e2bd366bb3 | |||
|
|
f80c8a0481 | ||
| 7e996fffa1 | |||
|
|
d14e25c29f | ||
|
|
1ed7ab9776 | ||
|
|
dd8867b52f | ||
|
|
3668eb2829 | ||
|
|
e751dfc1b2 | ||
|
|
6c3bbbf72b | ||
|
|
9dcb45a017 | ||
|
|
b9069433b1 | ||
|
|
739f6a08da | ||
|
|
2fc8b64964 | ||
|
|
6e133b6b59 | ||
|
|
01e3e02a62 | ||
|
|
85af70e2ee | ||
| b21d9bef87 | |||
|
|
26b89dae76 | ||
|
|
b2a2ef70a4 | ||
|
|
8286e00eb3 | ||
|
|
c7487c9763 | ||
|
|
e910d0a8a7 | ||
|
|
a3b9608887 | ||
|
|
09002cfe22 | ||
|
|
8a3b5c031f | ||
|
|
b441515f89 | ||
|
|
b7c1632bb8 | ||
| a7d40fb138 | |||
|
|
8f4ec83104 | ||
|
|
fba4ab13cf | ||
|
|
5ba1a256fe | ||
|
|
5ee0ef4d58 | ||
|
|
5a77a03ac0 | ||
|
|
5349c2408a | ||
| 9ea4fb32f4 | |||
|
|
9e1673ef7f | ||
|
|
44a7b2a8ab | ||
|
|
aa24505314 | ||
|
|
d0bf878555 | ||
|
|
2e5be9816e | ||
|
|
87ecccff9e | ||
| d60a44b438 | |||
|
|
9413bb6403 | ||
|
|
28bcddb957 | ||
|
|
1737e93c68 | ||
|
|
90fdbdea70 | ||
|
|
90ffadf2ea | ||
|
|
f6c9080cea | ||
|
|
8e40faad75 | ||
|
|
4978d44ba2 | ||
|
|
6fefed2909 | ||
| 1a48266cde | |||
|
|
639d39108a | ||
|
|
185ed4e3d8 | ||
|
|
8240b9af3c | ||
|
|
deb66c9cb7 | ||
|
|
8a49a3d04e | ||
|
|
cdccb8138c | ||
|
|
09a817f02d | ||
|
|
4edcf066ca | ||
|
|
0d49b67c7b | ||
|
|
24bf72ef69 | ||
|
|
8459061968 | ||
|
|
b3f1c35995 | ||
|
|
27f27b1503 | ||
|
|
f108abd7ae | ||
|
|
2be8fe65d8 | ||
|
|
a0c1628461 | ||
|
|
06615a3541 | ||
|
|
328b2a3ee8 | ||
|
|
5123287ef7 | ||
|
|
13e3b76c88 | ||
|
|
27502c6997 | ||
|
|
6b467525cf | ||
|
|
b1b0e85db7 | ||
|
|
e84dd7cb91 | ||
| 94d94fb7a2 | |||
|
|
e67b4fecc4 | ||
|
|
f7539dc9b6 | ||
|
|
5632068ca8 | ||
|
|
99df7a097b | ||
|
|
cc17c3fb42 | ||
|
|
d51919ec69 | ||
|
|
ad80b9d8c3 | ||
|
|
3c4495c066 | ||
|
|
4765a18224 | ||
|
|
6bd11be8b5 | ||
|
|
f294828409 | ||
|
|
953271eeee | ||
|
|
70f3cef03a | ||
|
|
c9a5a4dec9 | ||
|
|
37cc35bb1b | ||
|
|
25b758304d | ||
|
|
2385466b1a | ||
|
|
37ad4fd2ad | ||
|
|
35569e6ec0 | ||
|
|
536eb0deb1 | ||
|
|
a390c4711a | ||
|
|
86605bda05 | ||
|
|
d6cdfcf31a | ||
|
|
a592b270af | ||
|
|
4be9be34e0 | ||
|
|
cb7b097ce0 | ||
|
|
53a0010e47 | ||
|
|
6d36023222 | ||
|
|
7c1dbeac27 | ||
|
|
9386b073ba | ||
|
|
cf09845431 | ||
|
|
d65193d7d3 | ||
|
|
0e0e91d1f8 | ||
|
|
a59165c31e | ||
|
|
df2768c6fc | ||
|
|
109c92a33a | ||
|
|
ca11cbbc79 | ||
|
|
6584b63c36 | ||
|
|
88cac6c1e9 | ||
|
|
ef39040919 | ||
|
|
ca275c45de | ||
|
|
fee6035de0 | ||
|
|
c42962f6da | ||
|
|
7c047a16b7 | ||
|
|
a3b34ef74b | ||
| 8dc3066f06 | |||
| 04fd3c523b | |||
|
|
6f88d0726b | ||
|
|
53ea704e57 | ||
|
|
4253518ceb | ||
|
|
1c4382e655 | ||
|
|
5b1454adf6 | ||
|
|
6d3dbf497e | ||
|
|
0526945114 | ||
|
|
400a59f06a | ||
|
|
48826590de | ||
|
|
61cf06b4c7 | ||
|
|
f4a644dc05 | ||
|
|
fc847a17cd | ||
|
|
6bb4aaf3ba | ||
|
|
beca9756ea | ||
|
|
54ed1db6cd | ||
|
|
abaae7f360 | ||
|
|
8ca1ea8e78 | ||
|
|
a0426b2fee | ||
|
|
4fd8bd7534 | ||
|
|
b6046e63c5 | ||
|
|
84d76f0436 | ||
|
|
9664c59523 | ||
|
|
265f34b8aa | ||
|
|
0d6e7e6381 | ||
| 4144198e4b | |||
|
|
e5d3b9236c | ||
|
|
53c2371c45 | ||
|
|
48fb59b862 | ||
|
|
64744d1d93 | ||
|
|
a1d83e731a | ||
| f9ecdaec96 | |||
|
|
e8a7b2c7ab | ||
|
|
3e855af8d5 | ||
|
|
9672d30de4 | ||
|
|
78d3559e7b | ||
|
|
b8956ebf72 | ||
|
|
4bda2f1aae | ||
|
|
9897c2a991 | ||
|
|
af31c60be8 | ||
|
|
2b89969a96 | ||
|
|
88d0f8ffb8 | ||
|
|
58966646c2 | ||
|
|
c28de5def9 | ||
|
|
34d652acbb | ||
|
|
cc72968583 | ||
|
|
34db1439fa | ||
|
|
980ea9a6a5 | ||
|
|
d864402de2 | ||
|
|
d59b878906 | ||
|
|
ac840b684f | ||
|
|
547ebdb000 | ||
|
|
4675c0cb21 | ||
|
|
149e35c1c4 | ||
|
|
1897ffddd9 | ||
|
|
87e40a631c | ||
|
|
ca78bb4ed4 | ||
|
|
c9ce39b4b2 | ||
|
|
c7f48b2f4a | ||
|
|
9509dd539b | ||
|
|
7a5e6082eb | ||
|
|
67b533146a | ||
|
|
589879729d | ||
| 5d7c5eb4a6 | |||
| ae21a8ca39 | |||
| 369b63097e | |||
| 25a84b8758 | |||
| 2f30112c66 | |||
| 9483f7c27a | |||
| 33d55c4324 | |||
|
|
bd2299233d | ||
|
|
815b195600 | ||
|
|
2493777a42 | ||
| f9a20ac39b | |||
|
|
a02f539729 | ||
|
|
6b0da2f7cd | ||
|
|
e40b0bd188 | ||
|
|
f10cb78022 | ||
|
|
a875546133 | ||
|
|
1d871133f7 | ||
|
|
1692ba0e9d | ||
|
|
1fd4e101e6 | ||
|
|
f86df9c173 | ||
|
|
89b55ce266 | ||
|
|
f5bff0b139 | ||
|
|
b67e34127a | ||
|
|
04b175df51 | ||
|
|
3a87297b41 | ||
|
|
dd9ff2f4b2 | ||
|
|
d7cb97aa73 | ||
|
|
7361047b48 | ||
|
|
cf176ea2db | ||
|
|
2f467dfee2 | ||
|
|
03dd3eefb5 | ||
|
|
13af3fb071 | ||
|
|
470e47fefa | ||
|
|
8002b180b1 | ||
|
|
a3c75462c9 | ||
|
|
1998fc0652 | ||
|
|
6ee3d00802 | ||
|
|
cf46424f50 | ||
|
|
f49a542ddf | ||
|
|
b6be88d01f | ||
|
|
2a105edf04 | ||
|
|
159238f4f8 | ||
|
|
868cdd9132 | ||
|
|
85396e804d | ||
|
|
3407612ea9 | ||
|
|
d8b6785659 | ||
|
|
dd3a20ed00 | ||
|
|
87529b0d3f | ||
|
|
8d62ff0b1f | ||
|
|
ed1548ea81 | ||
|
|
beada8f174 | ||
| 3ec34cb12a | |||
|
|
e8784cdedc | ||
|
|
0a323d7b3c | ||
|
|
74853431e1 | ||
|
|
df919975af | ||
|
|
72a756bfbf | ||
|
|
f0d22e698b | ||
|
|
66b4d43fee | ||
|
|
5ecee06e58 | ||
|
|
9d5e30ea83 | ||
|
|
08452e06cc | ||
|
|
ab5494f4ad | ||
|
|
2e9bb9e920 | ||
|
|
9684bc3569 | ||
|
|
15e6cfb866 | ||
|
|
21fc552f40 | ||
|
|
cfb6c3409f | ||
|
|
a1247010ca | ||
|
|
b0d1ca7a80 | ||
|
|
c7974c7aa9 | ||
|
|
8cf8fcdf82 | ||
|
|
49c20d8e40 | ||
|
|
777558182d | ||
|
|
091f3eb33d | ||
|
|
41e6eab343 | ||
|
|
125e6bef76 | ||
|
|
0479b37982 | ||
|
|
6c8bf8474e | ||
|
|
04d282f790 | ||
|
|
3488a888de | ||
|
|
3c3f6bfdb4 | ||
|
|
11a2bc57a7 | ||
|
|
91cdda8961 | ||
|
|
d77a6a96f1 | ||
|
|
0d3e181458 | ||
|
|
4201ba2c6c | ||
|
|
fd918ad002 | ||
|
|
02ae34dbd0 | ||
|
|
c6868b63bc | ||
|
|
5a4383b6ec | ||
|
|
ac6b568bdc | ||
|
|
6400deddbf | ||
|
|
3a59974277 | ||
|
|
fd7dfb7eda | ||
|
|
7be3f59613 | ||
|
|
df5ad3afe2 | ||
|
|
0b122d8669 | ||
|
|
b0d7db3102 | ||
|
|
b2fb7035e0 | ||
|
|
ede46facf1 | ||
| d4f81339ef | |||
|
|
12be806f89 | ||
|
|
0f4f53b9e5 | ||
|
|
13b34ca5b9 | ||
|
|
ed82bd9fe1 | ||
|
|
b1386ba701 | ||
|
|
9dd08dc2ae | ||
|
|
6548773a76 | ||
|
|
fc2c7e7928 | ||
|
|
e90fbccde0 | ||
|
|
55dec88909 | ||
|
|
570a767636 | ||
|
|
90ddd5812e | ||
|
|
145083cfcc | ||
|
|
8f6d294995 | ||
|
|
1ea3723849 | ||
|
|
0086a47938 | ||
|
|
e6cdb3b840 | ||
|
|
dfe45bdbb2 | ||
|
|
e42bea8edf | ||
|
|
9cc237fb5b | ||
|
|
ab60d2b504 | ||
|
|
478a8b0189 | ||
|
|
3c6106d06a | ||
|
|
8d05f43594 | ||
| f5180767b1 | |||
| f3d75b9ba5 | |||
| 304df327e3 | |||
| 801b46b95f | |||
| bc7a9d96da | |||
| 1f273d9229 | |||
|
|
60638cd1e3 | ||
|
|
c139496af9 | ||
|
|
e0447c551a | ||
|
|
81ad77567a | ||
|
|
cba66e86df | ||
|
|
12eb68abdf | ||
| 0670f1248a | |||
| 69c01d605f | |||
| 1090aa056b | |||
| 2378a278f2 | |||
|
|
280e7a4132 | ||
|
|
6a0a4e0489 | ||
| a54beaffad | |||
|
|
064ede9a75 | ||
|
|
e43552373c | ||
|
|
b9c8c20347 | ||
|
|
971b0797df |
6
.gitignore
vendored
@@ -1,4 +1,8 @@
|
||||
custom.nix
|
||||
role-state.nix
|
||||
*.iso
|
||||
*.zip
|
||||
*.pma
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
custom.nix
|
||||
role-state.nix
|
||||
*.iso
|
||||
*.zip
|
||||
*.pma
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
@@ -1 +1,244 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#1E3A5F"/><text x="32" y="28" font-family="sans-serif" font-size="12" font-weight="bold" fill="#F7931A" text-anchor="middle">BIP</text><text x="32" y="46" font-family="sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">110</text></svg>
|
||||
<svg width="256" height="256" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="256" height="256" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,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
|
||||
"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 18 KiB |
7
app/icons/bisq.svg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#F7931A"/><circle cx="32" cy="32" r="22" fill="none" stroke="white" stroke-width="2"/><text x="32" y="44" font-family="sans-serif" font-size="30" font-weight="bold" fill="white" text-anchor="middle">₿</text></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="1024" height="1024" viewBox="-34 -34 580 580"><filter id="b"><feColorMatrix in="SourceAlpha" result="matrixOut" type="saturate" values=".1"/><feGaussianBlur in="matrixOut" result="blur-out" stdDeviation="6"/><feColorMatrix in="blur-out" result="color-out" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.35 0"/><feBlend in="SourceGraphic" in2="color-out"/></filter><circle cx="255" cy="255" r="200" fill="#fff"/><radialGradient id="a" cx="277.49" cy="196.441" r="34.397" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#f9aa4b"/><stop offset="1" style="stop-color:#f7931a"/></radialGradient><path fill="url(#a)" d="m254.647 174.6-13.983 56.08c15.855 3.951 64.735 20.071 72.656-11.656 8.248-33.096-42.817-40.472-58.673-44.424" filter="url(#b)"/><radialGradient id="c" cx="261.915" cy="284.567" r="39.838" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#f9aa4b"/><stop offset="1" style="stop-color:#f7931a"/></radialGradient><path fill="url(#c)" d="m233.608 258.984-15.425 61.832c19.04 4.729 77.769 23.584 86.448-11.296 9.072-36.376-51.984-45.784-71.023-50.536" filter="url(#b)"/><radialGradient id="d" cx="256.028" cy="256.003" r="255.988" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#f9aa4b"/><stop offset="1" style="stop-color:#f7931a"/></radialGradient><path fill="url(#d)" d="M317.871 7.656c-137.12-34.192-276.024 49.28-310.2 186.44-34.208 137.136 49.256 276.048 186.36 310.24 137.16 34.199 276.063-49.265 310.256-186.408 34.192-137.152-49.264-276.08-186.416-310.272m50.936 211.872c-3.688 24.936-17.512 37.008-35.864 41.24 25.2 13.12 38.024 33.239 25.809 68.12-15.16 43.319-51.176 46.976-99.072 37.912l-11.624 46.584-28.088-7 11.472-45.96a1076 1076 0 0 1-22.384-5.809l-11.512 46.177-28.056-7 11.624-46.673c-6.561-1.68-13.225-3.464-20.024-5.168l-36.552-9.111 13.943-32.152s20.696 5.504 20.416 5.096c7.952 1.969 11.48-3.216 12.872-6.672l18.368-73.64.048-.2 13.104-52.568c.344-5.968-1.712-13.496-13.088-16.336.439-.296-20.4-5.072-20.4-5.072l7.472-30 38.736 9.673-.032.144c5.824 1.448 11.824 2.824 17.937 4.216L245.423 89.2l28.072 7-11.28 45.224c7.536 1.721 15.12 3.456 22.504 5.297l11.2-44.929 28.088 7-11.504 46.145c35.464 12.215 61.401 30.527 56.304 64.591" filter="url(#b)"/></svg>
|
||||
|
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 2.3 KiB |
@@ -1 +1,177 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#F7931A"/><text x="32" y="44" font-family="sans-serif" font-size="36" font-weight="bold" fill="white" text-anchor="middle">₿</text></svg>
|
||||
<svg width="512" height="512" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="512" height="512" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,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
|
||||
"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 13 KiB |
@@ -1 +1,63 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#51B13E"/><text x="32" y="44" font-family="sans-serif" font-size="28" font-weight="bold" fill="white" text-anchor="middle">PAY</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xml:space="preserve"
|
||||
width="1024"
|
||||
height="1024"
|
||||
version="1.1"
|
||||
id="svg7"
|
||||
sodipodi:docname="BTCPay Server Icon_Version2 (1) (1).svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.360203"
|
||||
inkscape:cx="512.21117"
|
||||
inkscape:cy="512.21117"
|
||||
inkscape:window-width="2346"
|
||||
inkscape:window-height="1355"
|
||||
inkscape:window-x="26"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg7" />
|
||||
<defs
|
||||
id="defs1">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</defs>
|
||||
<path
|
||||
d="M 395.38615,1007.3428 A 57.385688,57.385688 0 0 1 337.99353,949.95705 V 86.24698 a 57.39955,57.39955 0 0 1 114.78524,0 v 863.71007 a 57.385688,57.385688 0 0 1 -57.39262,57.38575"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path2" /><path
|
||||
d="M 395.42245,1007.3558 A 57.39955,57.39955 0 0 1 370.7771,898.10115 L 716.85161,733.85888 361.3445,471.95004 a 57.385688,57.385688 0 0 1 -12.15634,-80.2568 57.385688,57.385688 0 0 1 80.236,-12.15634 l 432.75601,318.83017 a 57.371827,57.371827 0 0 1 -9.43953,98.04775 L 419.99156,1001.7974 a 57.177769,57.177769 0 0 1 -24.56911,5.5584"
|
||||
style="fill:#51b13e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path3" /><path
|
||||
d="M 395.43261,667.85437 A 57.385688,57.385688 0 0 1 361.34773,564.25518 L 716.84791,302.33942 370.78033,138.09021 A 57.385688,57.385688 0 0 1 343.53599,61.638322 c 13.59791,-28.6374 47.83527,-40.842254 76.45187,-27.237411 L 852.74387,239.77654 a 57.385688,57.385688 0 0 1 9.4326,98.05468 l -432.74908,318.8371 a 57.177769,57.177769 0 0 1 -33.99478,11.18605"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path4" /><path
|
||||
d="M 452.77605,396.7388 V 639.46363 L 617.42723,518.15666 Z"
|
||||
style="fill:#1e7a44;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path5" /><path
|
||||
d="M 452.77896,574.75277 H 337.99334 V 294.96882 h 114.78562 z"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93065"
|
||||
id="path6" /><path
|
||||
d="M 395.38615,28.855342 A 57.385688,57.385688 0 0 0 337.99353,86.24796 V 808.52449 H 452.77877 V 86.24796 A 57.385688,57.385688 0 0 0 395.38615,28.855342"
|
||||
style="fill:#cedc21;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.93064"
|
||||
id="path7" />
|
||||
|
||||
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 22 KiB |
@@ -1 +1,72 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#3B82F6"/><text x="32" y="44" font-family="sans-serif" font-size="36" fill="white" text-anchor="middle">⚡</text></svg>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 85.3 85.3" style="enable-background:new 0 0 85.3 85.3;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#083766;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
.st2{fill:none;stroke:#1250A9;stroke-width:1.9869;stroke-miterlimit:10;}
|
||||
.st3{fill:none;stroke:#1859D1;stroke-width:1.9869;stroke-miterlimit:10;}
|
||||
.st4{fill:none;stroke:#1853FF;stroke-width:1.9869;stroke-miterlimit:10;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M84.7,41.6L84.7,41.6l-3.6-2.2c0-0.4-0.1-0.7-0.1-1l3.1-2.9c0.3-0.3,0.4-0.7,0.4-1.1c-0.1-0.4-0.4-0.8-0.8-0.9
|
||||
L79.8,32c-0.1-0.3-0.2-0.7-0.3-1l2.5-3.4c0.3-0.3,0.3-0.8,0.1-1.2c-0.2-0.4-0.5-0.7-0.9-0.8L77,24.9c-0.2-0.3-0.3-0.6-0.5-0.9
|
||||
l1.8-3.8c0.2-0.4,0.1-0.8-0.1-1.2c-0.2-0.4-0.6-0.6-1.1-0.5l-4.2,0.1c-0.2-0.3-0.4-0.5-0.7-0.8l1-4.1c0.1-0.4,0-0.9-0.3-1.2
|
||||
c-0.3-0.3-0.7-0.4-1.2-0.3l-4.1,1c-0.3-0.2-0.5-0.4-0.8-0.7l0.2-4.2c0-0.4-0.2-0.8-0.6-1.1C66,7,65.5,6.9,65.2,7.1l-3.8,1.7
|
||||
c-0.3-0.2-0.6-0.3-0.9-0.5l-0.7-4.2c-0.1-0.4-0.4-0.8-0.8-0.9c-0.4-0.2-0.9-0.1-1.2,0.1l-3.4,2.5c-0.3-0.1-0.7-0.2-1-0.3l-1.5-3.9
|
||||
c-0.1-0.4-0.5-0.7-0.9-0.8c-0.4-0.1-0.9,0.1-1.1,0.4L47,4.3c-0.3,0-0.7-0.1-1-0.1l-2.2-3.6C43.5,0.2,43.1,0,42.7,0
|
||||
c-0.4,0-0.8,0.2-1.1,0.6l-2.2,3.6c-0.3,0-0.7,0.1-1,0.1l-2.9-3.1c-0.3-0.3-0.7-0.4-1.1-0.4c-0.4,0.1-0.8,0.4-0.9,0.8L32,5.5
|
||||
c-0.3,0.1-0.7,0.2-1,0.3l-3.4-2.5c-0.3-0.3-0.8-0.3-1.2-0.1c-0.4,0.2-0.7,0.5-0.7,0.9l-0.7,4.2c-0.3,0.2-0.6,0.3-0.9,0.5l-3.8-1.7
|
||||
C19.8,6.9,19.3,7,19,7.2c-0.4,0.2-0.6,0.6-0.6,1.1l0.1,4.2c-0.3,0.2-0.5,0.4-0.8,0.7l-4.1-1c-0.4-0.1-0.9,0-1.2,0.3
|
||||
c-0.3,0.3-0.4,0.7-0.3,1.2l1,4.1c-0.2,0.3-0.4,0.5-0.7,0.8l-4.2-0.1c-0.4,0-0.8,0.2-1.1,0.5c-0.2,0.4-0.3,0.8-0.1,1.2L8.9,24
|
||||
c-0.2,0.3-0.3,0.6-0.5,0.9l-4.1,0.7c-0.4,0.1-0.8,0.4-0.9,0.8c-0.2,0.4-0.1,0.8,0.1,1.2l2.5,3.4c-0.1,0.3-0.2,0.7-0.3,1l-3.9,1.5
|
||||
c-0.4,0.1-0.7,0.5-0.8,0.9c-0.1,0.4,0.1,0.9,0.4,1.1l3.1,2.9c0,0.3-0.1,0.7-0.1,1l-3.6,2.2h0C0.2,41.8,0,42.2,0,42.7
|
||||
c0,0.4,0.2,0.8,0.6,1l3.6,2.2c0,0.3,0.1,0.7,0.1,1l-3.1,2.9c-0.3,0.3-0.5,0.7-0.4,1.1c0.1,0.4,0.4,0.8,0.8,0.9l3.9,1.5
|
||||
c0.1,0.3,0.2,0.7,0.3,1l-2.5,3.4c-0.2,0.3-0.3,0.8-0.1,1.2c0.1,0.4,0.5,0.7,0.9,0.7l4.1,0.7c0.2,0.3,0.3,0.6,0.5,0.9l-1.7,3.8
|
||||
c-0.2,0.4-0.1,0.8,0.1,1.2c0.2,0.4,0.6,0.6,1.1,0.5l4.2-0.1c0.2,0.3,0.4,0.5,0.7,0.8l-1,4.1c-0.1,0.4,0,0.8,0.3,1.1
|
||||
c0.3,0.3,0.7,0.4,1.2,0.3l4.1-1c0.3,0.2,0.5,0.4,0.8,0.7L18.4,77c0,0.4,0.2,0.8,0.6,1.1c0.3,0.2,0.8,0.3,1.2,0.1l3.8-1.7
|
||||
c0.3,0.2,0.6,0.3,0.9,0.5l0.7,4.1c0.1,0.4,0.4,0.8,0.7,0.9c0.4,0.2,0.8,0.1,1.2-0.1l3.4-2.5c0.3,0.1,0.7,0.2,1,0.3l1.5,3.9
|
||||
c0.1,0.4,0.5,0.7,0.9,0.8c0.4,0.1,0.9-0.1,1.1-0.4l2.9-3.1c0.4,0,0.7,0.1,1,0.1l2.2,3.6c0.2,0.4,0.6,0.6,1.1,0.6
|
||||
c0.4,0,0.8-0.2,1.1-0.6l2.2-3.6c0.3,0,0.7-0.1,1-0.1l2.9,3.1c0.3,0.3,0.7,0.5,1.1,0.4c0.4-0.1,0.8-0.4,0.9-0.8l1.5-3.9
|
||||
c0.3-0.1,0.7-0.2,1-0.3l3.4,2.5c0.4,0.2,0.8,0.3,1.2,0.1c0.4-0.2,0.7-0.5,0.7-0.9l0.7-4.1c0.3-0.2,0.6-0.3,0.9-0.5l3.8,1.7
|
||||
c0.4,0.2,0.8,0.1,1.2-0.1c0.4-0.2,0.6-0.6,0.5-1.1l-0.2-4.2c0.3-0.2,0.5-0.4,0.8-0.7l4.1,1c0.4,0.1,0.9,0,1.2-0.3
|
||||
c0.3-0.3,0.4-0.7,0.3-1.1l-1-4.1c0.2-0.3,0.4-0.5,0.7-0.8l4.2,0.1c0.4,0,0.8-0.2,1.1-0.5c0.2-0.4,0.3-0.8,0.1-1.2l-1.7-3.8
|
||||
c0.2-0.3,0.3-0.6,0.5-0.9l4.1-0.7c0.4-0.1,0.8-0.3,0.9-0.7c0.2-0.4,0.1-0.9-0.1-1.2l-2.4-3.4c0.1-0.3,0.2-0.7,0.3-1l3.9-1.5
|
||||
c0.4-0.2,0.7-0.5,0.8-0.9c0.1-0.4-0.1-0.8-0.4-1.1L81.1,47c0-0.3,0.1-0.7,0.1-1l3.6-2.2c0.4-0.2,0.6-0.6,0.6-1
|
||||
C85.3,42.2,85.1,41.8,84.7,41.6z M62.4,66.6c-1.1,0.9-2.4,1.8-3.6,2.6c-4.7,2.9-10.3,4.5-16.3,4.5c-3.4,0-6.7-0.5-9.8-1.6
|
||||
c-0.1,0-0.2-0.1-0.4-0.1c-1.1-0.4-2.1-0.8-3.2-1.2v0C28.1,70.3,27,69.7,26,69c-1.2-0.8-2.4-1.6-3.5-2.5c-0.9-0.7-1.7-1.5-2.5-2.3
|
||||
l-0.1,0l-0.1,0c-1-1.1-2-2.2-2.9-3.4c0,0,0.1,0,0.1,0c-3.4-4.6-5.5-10.1-6-16.1h0c0-0.2,0-0.3,0-0.5c0-0.2,0-0.3,0-0.4
|
||||
c0-0.1,0-0.3,0-0.4c0-0.3,0-0.5,0-0.8v-0.1l0,0c0-0.8,0-1.6,0.1-2.4c0-0.2,0-0.4,0.1-0.7h0l0.2-0.1c0.1-1.2,0.3-2.4,0.6-3.5
|
||||
c0.4-1.6,0.8-3.2,1.4-4.8c0.5-1.3,1.1-2.6,1.8-3.9L15,27c2.3-4,5.5-7.5,9.2-10.1c0.1-0.1,0.3-0.2,0.4-0.3c4.4-3.1,9.6-5,15.3-5.4
|
||||
c0.8-0.1,1.7-0.1,2.5-0.1c0.8,0,1.5,0,2.2,0.1c12.3,0.9,22.6,8.8,26.9,19.8c0.6,1.6,1.1,3.2,1.5,4.8c0.5,2.2,0.7,4.4,0.7,6.7
|
||||
C73.9,52.1,69.4,60.9,62.4,66.6z"/>
|
||||
<circle class="st1" cx="42.7" cy="42.7" r="33.5"/>
|
||||
<g>
|
||||
<ellipse class="st2" cx="42.7" cy="42.8" rx="27.2" ry="13.6"/>
|
||||
|
||||
<ellipse transform="matrix(0.8957 -0.4447 0.4447 0.8957 -14.4551 23.4064)" class="st3" cx="42.7" cy="42.5" rx="13.6" ry="27.2"/>
|
||||
<path class="st4" d="M30.6,67.1c6.7,3.3,17.6-4.9,24.2-18.3s6.6-27-0.1-30.4"/>
|
||||
|
||||
<ellipse transform="matrix(0.4447 -0.8957 0.8957 0.4447 -14.6426 61.9687)" class="st4" cx="42.7" cy="42.8" rx="27.2" ry="13.6"/>
|
||||
<path class="st3" d="M30.6,18.2c6.7-3.3,17.6,4.9,24.2,18.3s6.6,27-0.1,30.4S37.2,62,30.5,48.6"/>
|
||||
<path class="st2" d="M69.8,42.8c0,7.5-12.2,13.6-27.2,13.6s-27.2-6.1-27.2-13.6"/>
|
||||
</g>
|
||||
<g id="Layer_x0020_1_3_">
|
||||
<g id="_1421344023328_3_">
|
||||
<path class="st0" d="M50.1,40.8L50.1,40.8c0.3-2.2-1.3-3.3-3.6-4.1l0.7-2.9l-1.8-0.4l-0.7,2.8c-0.5-0.1-0.9-0.2-1.4-0.3l0.7-2.9
|
||||
l-1.8-0.4l-0.7,2.9c-0.4-0.1-0.8-0.2-1.1-0.3v0L38,34.6l-0.5,1.9c0,0,1.3,0.3,1.3,0.3c0.7,0.2,0.8,0.6,0.8,1l-0.8,3.3
|
||||
c0,0,0.1,0,0.2,0.1c-0.1,0-0.1,0-0.2,0l-1.2,4.7c-0.1,0.2-0.3,0.5-0.8,0.4c-0.1,0-1.3-0.3-1.3-0.3l-0.9,2l2.3,0.6
|
||||
c0.4,0.1,0.8,0.2,1.3,0.3l-0.7,3l1.8,0.4l0.7-2.9c0.5,0.1,0.9,0.3,1.4,0.4l-0.7,2.9l1.8,0.4l0.7-2.9c3,0.6,5.3,0.3,6.3-2.4
|
||||
c0.8-2.2,0-3.5-1.6-4.3C49,43.1,49.9,42.4,50.1,40.8z M46.1,46.5c-0.5,2.2-4.3,1-5.5,0.7l1-3.9C42.8,43.6,46.6,44.2,46.1,46.5z
|
||||
M46.6,40.8c-0.5,2-3.6,1-4.6,0.7l0.9-3.5C43.9,38.2,47.2,38.7,46.6,40.8z"/>
|
||||
<path class="st0" d="M50.1,40.8L50.1,40.8c0.3-2.2-1.3-3.3-3.6-4.1l0.7-2.9l-1.8-0.4l-0.7,2.8c-0.5-0.1-0.9-0.2-1.4-0.3l0.7-2.9
|
||||
l-1.8-0.4l-0.7,2.9c-0.4-0.1-0.8-0.2-1.1-0.3v0L38,34.6l-0.5,1.9c0,0,1.3,0.3,1.3,0.3c0.7,0.2,0.8,0.6,0.8,1l-0.8,3.3
|
||||
c0,0,0.1,0,0.2,0.1c-0.1,0-0.1,0-0.2,0l-1.2,4.7c-0.1,0.2-0.3,0.5-0.8,0.4c-0.1,0-1.3-0.3-1.3-0.3l-0.9,2l2.3,0.6
|
||||
c0.4,0.1,0.8,0.2,1.3,0.3l-0.7,3l1.8,0.4l0.7-2.9c0.5,0.1,0.9,0.3,1.4,0.4l-0.7,2.9l1.8,0.4l0.7-2.9c3,0.6,5.3,0.3,6.3-2.4
|
||||
c0.8-2.2,0-3.5-1.6-4.3C49,43.1,49.9,42.4,50.1,40.8z M46.1,46.5c-0.5,2.2-4.3,1-5.5,0.7l1-3.9C42.8,43.6,46.6,44.2,46.1,46.5z
|
||||
M46.6,40.8c-0.5,2-3.6,1-4.6,0.7l0.9-3.5C43.9,38.2,47.2,38.7,46.6,40.8z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 218 B After Width: | Height: | Size: 6.3 KiB |
11
app/icons/element-calling.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Logo Mark">
|
||||
<rect width="47.86" height="48" rx="23.93" fill="#0DBD8B"/>
|
||||
<g id="Union">
|
||||
<path d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z" fill="white"/>
|
||||
<path d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z" fill="white"/>
|
||||
<path d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z" fill="white"/>
|
||||
<path d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#8B5CF6"/><text x="32" y="44" font-family="sans-serif" font-size="28" font-weight="bold" fill="white" text-anchor="middle">N</text></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Background circle -->
|
||||
<circle cx="128" cy="128" r="120" fill="#5B21B6"/>
|
||||
<!-- Outer signal arc (top-left) -->
|
||||
<path d="M60 128 A68 68 0 0 1 128 60" stroke="#A78BFA" stroke-width="14" stroke-linecap="round" fill="none"/>
|
||||
<!-- Outer signal arc (top-right) -->
|
||||
<path d="M128 60 A68 68 0 0 1 196 128" stroke="#A78BFA" stroke-width="14" stroke-linecap="round" fill="none"/>
|
||||
<!-- Middle signal arc (top-left) -->
|
||||
<path d="M82 128 A46 46 0 0 1 128 82" stroke="#C4B5FD" stroke-width="12" stroke-linecap="round" fill="none"/>
|
||||
<!-- Middle signal arc (top-right) -->
|
||||
<path d="M128 82 A46 46 0 0 1 174 128" stroke="#C4B5FD" stroke-width="12" stroke-linecap="round" fill="none"/>
|
||||
<!-- Inner signal arc (top-left) -->
|
||||
<path d="M104 128 A24 24 0 0 1 128 104" stroke="#EDE9FE" stroke-width="10" stroke-linecap="round" fill="none"/>
|
||||
<!-- Inner signal arc (top-right) -->
|
||||
<path d="M128 104 A24 24 0 0 1 152 128" stroke="#EDE9FE" stroke-width="10" stroke-linecap="round" fill="none"/>
|
||||
<!-- Center antenna dot -->
|
||||
<circle cx="128" cy="128" r="14" fill="#F5F3FF"/>
|
||||
<!-- Antenna mast -->
|
||||
<rect x="122" y="128" width="12" height="52" rx="4" fill="#DDD6FE"/>
|
||||
<!-- Base platform -->
|
||||
<rect x="96" y="176" width="64" height="12" rx="6" fill="#7C3AED"/>
|
||||
<rect x="108" y="188" width="40" height="10" rx="5" fill="#6D28D9"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 235 B After Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#0DBD8B"/><text x="32" y="44" font-family="sans-serif" font-size="28" fill="white" text-anchor="middle">EC</text></svg>
|
||||
|
Before Width: | Height: | Size: 217 B |
@@ -1 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#7B3FE4"/><polygon points="36,8 20,36 30,36 28,56 44,28 34,28" fill="white"/></svg>
|
||||
<svg width="280px" height="280px" style="background-color:#ffffff00" version="1.1" viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Ellipse" d="m7 140c0-74 60-134 134-134s134 60 134 134-60 134-134 134-134-60-134-134z" fill="#7e1af7"/>
|
||||
<path d="m161 52c-8 21-16 43-25 65 0 0 0 3 3 3h65s0 2 2 3l-96 106c-2-2-2-3-2-5l33-72v-6h-67v-6l81-89h5z" fill="#fff"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 181 B After Width: | Height: | Size: 382 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#2D3348"/><rect x="14" y="38" width="8" height="12" rx="1" fill="#5E35B1"/><rect x="24" y="30" width="8" height="20" rx="1" fill="#7C4DFF"/><rect x="34" y="22" width="8" height="28" rx="1" fill="#B388FF"/><rect x="44" y="14" width="8" height="36" rx="1" fill="#E1BEE7"/></svg>
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.2" baseProfile="tiny-ps" viewBox="0 0 241 241" xml:space="preserve"><title>Mempool Holdings S.A. de C.V.</title><g><g><path fill="#2E3349" d="M241.37,211.23c0,16.56-13.43,29.99-29.99,29.99H30.36c-16.56,0-29.99-13.43-29.99-29.99V30.21 c0-16.56,13.43-29.99,29.99-29.99h181.02c16.56,0,29.99,13.43,29.99,29.99V211.23z"></path><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="120.8689" y1="68.6556" x2="120.8689" y2="301.1491"><stop offset="0" stop-color="#AE61FF"></stop><stop offset="1" stop-color="#13EFD8"></stop></linearGradient><path fill="url(#SVGID_1_)" d="M0.32,120.99v90.24c0,16.56,13.49,29.99,30.14,29.99h180.82c16.64,0,30.13-13.43,30.13-29.99 v-90.24H0.32z"></path></g><g><path fill="#FFFFFF" fill-opacity="0.3" d="M212.72,209c0,3.7-2.53,6.7-5.65,6.7h-31.24c-3.12,0-5.65-3-5.65-6.7V32.44c0-3.7,2.53-6.7,5.65-6.7h31.24 c3.12,0,5.65,3,5.65,6.7V209z"></path></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 1.0 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#0082C9"/><ellipse cx="32" cy="34" rx="18" ry="12" fill="white" opacity="0.9"/><ellipse cx="22" cy="36" rx="10" ry="8" fill="white"/><ellipse cx="42" cy="36" rx="10" ry="8" fill="white"/></svg>
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><rect width="32" height="32" rx="5" ry="5" fill="#0082c9"/><g transform="matrix(.12 0 0 .12 .64 8.32)" fill="none" stroke="#fff" stroke-width="22"><circle cx="40" cy="64" r="26"/><circle cx="216" cy="64" r="26"/><circle cx="128" cy="64" r="46"/></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 291 B After Width: | Height: | Size: 353 B |
13
app/icons/passwords.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Shield -->
|
||||
<path d="M128 20L32 60v68c0 55.2 40.8 106.8 96 120 55.2-13.2 96-64.8 96-120V60L128 20z" fill="#1565C0"/>
|
||||
<path d="M128 32L44 68v60c0 49.6 36 95.6 84 108V32z" fill="#1E88E5"/>
|
||||
<!-- Key head (circle) -->
|
||||
<circle cx="128" cy="108" r="32" fill="#FFC107"/>
|
||||
<circle cx="128" cy="108" r="16" fill="#1565C0"/>
|
||||
<!-- Key shaft -->
|
||||
<rect x="122" y="136" width="12" height="56" rx="2" fill="#FFC107"/>
|
||||
<!-- Key teeth -->
|
||||
<rect x="134" y="160" width="16" height="8" rx="2" fill="#FFC107"/>
|
||||
<rect x="134" y="176" width="12" height="8" rx="2" fill="#FFC107"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -1 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="34" rx="4" fill="#3584E4" stroke="#fff" stroke-width="2"/><rect x="12" y="18" width="40" height="22" rx="2" fill="#1A1A2E"/><rect x="26" y="46" width="12" height="4" rx="1" fill="#aaa"/><rect x="20" y="50" width="24" height="3" rx="1.5" fill="#888"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Monitor body -->
|
||||
<rect x="28" y="40" width="200" height="140" rx="12" fill="#1A73E8"/>
|
||||
<!-- Screen -->
|
||||
<rect x="40" y="52" width="176" height="108" rx="4" fill="#E8F0FE"/>
|
||||
<!-- Screen content - window bars -->
|
||||
<rect x="52" y="64" width="100" height="12" rx="2" fill="#4285F4"/>
|
||||
<rect x="52" y="84" width="152" height="8" rx="2" fill="#DADCE0"/>
|
||||
<rect x="52" y="100" width="152" height="8" rx="2" fill="#DADCE0"/>
|
||||
<rect x="52" y="116" width="120" height="8" rx="2" fill="#DADCE0"/>
|
||||
<!-- Cursor arrow -->
|
||||
<path d="M168 88l24 56-12 4-8-18-14 16-6-6 14-16-18-8z" fill="#34A853"/>
|
||||
<!-- Stand -->
|
||||
<rect x="108" y="180" width="40" height="20" fill="#1A73E8"/>
|
||||
<!-- Base -->
|
||||
<rect x="80" y="200" width="96" height="12" rx="6" fill="#1A73E8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 864 B |
@@ -1 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#16A34A"/><text x="32" y="43" font-family="sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="middle">RTL</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="610px" height="524px" viewBox="0 0 610 524" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
|
||||
<title>RTL-Logo-Single</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<polygon id="path-1" points="0.451852397 0.2573 52.6803 0.2573 52.6803 52 0.451852397 52"></polygon>
|
||||
</defs>
|
||||
<g id="RTL-Logo-Single" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="BY_-_RTL_logo_wht" transform="translate(58.000000, 54.000000)">
|
||||
<g id="Group-3" transform="translate(0.000000, 0.737000)" fill="#FFFFFE">
|
||||
<path d="M360.6201,52.8608 C362.5721,57.4638 363.3541,61.1008 363.2471,63.1018 C355.6871,58.5468 344.3161,54.6748 338.1131,53.5648 C334.0751,52.8408 347.2931,49.6338 360.6201,52.8608 M371.6051,222.1978 C373.9431,221.7028 376.2931,221.2298 378.6451,220.7688 C381.1041,220.2858 383.5691,219.8228 386.0431,219.3858 C384.0591,218.1358 382.0681,216.8978 380.0641,215.6788 C354.6091,200.1908 327.4751,187.0398 300.6651,174.3118 C283.7451,166.2788 266.5131,158.2938 248.9661,151.0968 C264.2721,130.9078 283.8701,113.7738 304.8171,99.0968 C304.8351,99.0848 304.9201,99.0258 305.0521,98.9328 C309.6371,105.1898 315.4331,109.4478 318.3391,111.0478 C324.0201,114.1778 331.1751,117.1028 337.7531,117.9968 C350.6891,119.7558 361.3701,119.4848 373.1871,117.4478 C374.2521,117.2648 375.3251,117.1348 376.3931,116.9568 C383.9711,115.6898 388.8201,115.4978 393.2591,115.4148 C399.1381,115.3048 402.0501,116.5498 402.7421,116.7298 C404.8421,117.2728 406.6271,118.1718 407.6191,119.3778 C408.7711,120.8458 410.0021,122.2308 411.3211,123.4988 C415.2771,127.4358 419.2051,129.6158 424.9041,130.2408 C432.2391,131.0448 437.4311,128.1758 441.1021,123.4128 C442.8361,121.1638 442.0011,117.6318 441.8111,116.9388 C440.8481,113.3958 438.6251,108.2418 437.0161,103.4438 C436.5991,102.2038 434.5781,98.3918 432.1461,95.8048 C431.3431,94.9488 430.5351,94.0948 429.7281,93.2418 C425.9511,89.2468 422.1441,85.2818 418.3701,81.2898 C403.3111,65.3718 386.9861,50.7158 370.1561,36.7028 C368.4121,35.2508 366.6651,33.7948 364.9151,32.3418 C363.6461,31.2868 362.3721,30.2378 361.1001,29.1868 C360.4531,28.6528 359.8071,28.1198 359.1581,27.5868 C357.7891,26.4608 356.4161,25.3428 355.0401,24.2258 C354.4971,23.7858 353.9521,23.3488 353.4081,22.9098 C352.5511,22.2198 350.5951,20.5728 348.9481,19.2538 C354.4071,13.8118 358.8121,10.4238 366.6311,5.3008 C367.7521,4.5668 373.7921,1.3448 373.5791,0.5268 C373.4511,0.0328 363.5341,0.1448 352.1711,1.6088 C348.1791,2.1228 325.8041,5.4268 310.4411,8.7748 C294.0871,12.3378 276.8181,17.3188 260.9121,22.3258 C214.9671,36.7898 171.0961,56.3918 130.9221,83.0318 C107.8341,98.3418 86.2221,115.2368 65.3621,133.4398 C55.6411,141.9208 45.9791,150.5378 36.5451,159.3928 C35.0671,160.7798 33.6011,162.1788 32.1351,163.5788 C34.1441,163.9548 36.1521,164.3478 38.1561,164.7598 C54.7691,168.1718 71.2001,172.7318 87.1751,177.2978 C106.7131,182.8818 126.4791,188.9018 145.5921,196.2918 C134.7011,206.6528 124.0761,217.2688 113.9611,228.5028 C94.9141,249.6538 77.1581,271.9988 60.5891,295.1368 C38.9991,325.2848 20.5681,357.7988 4.5021,391.3058 C2.9801,394.4758 1.4761,397.6528 0.0001,400.8388 C2.9721,398.4598 5.9621,396.1018 8.9741,393.7648 C34.9841,373.5938 62.4081,354.9428 89.7891,336.9238 C128.3171,311.5688 169.6651,290.6218 212.5711,273.7668 C264.2991,253.4468 317.1251,233.7338 371.6051,222.1978" id="Fill-1"></path>
|
||||
</g>
|
||||
<g id="Group-24" transform="translate(172.000000, 352.737000)">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<g id="Clip-23"></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 3.8 KiB |
111
app/icons/sparrow.svg
Normal file
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Created with Vectornator (http://vectornator.io/) -->
|
||||
<svg stroke-miterlimit="10" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" version="1.1" viewBox="8.789 8.791 32.422 32.418" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Untitled">
|
||||
<path d="M38.8519 23.6353L38.3818 24.2217L41.2105 25.0446L41.2105 24.8132L39.3844 23.4968L38.8519 23.6353Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M37.9843 25.2648L38.7188 25.2376L40.119 25.1551L41.2106 25.0446L38.3819 24.2217L37.5381 23.9764L37.9843 25.2648Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M38.8519 23.6353L38.3818 24.2217L37.5381 23.9764L38.0403 23.846L38.8519 23.6353Z" fill="#ababab" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M37.9843 25.2648L33.5662 25.2648L36.0929 24.3388L37.9843 25.2648Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M38.0403 23.846L37.145 23.3355L36.8724 23.18L38.7803 21.9829L39.3844 23.4968L38.8519 23.6353L38.0403 23.846Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M37.0092 23.5521L36.7613 23.8298L36.3913 23.3554L36.3959 23.3517L37.0092 23.5521Z" fill="#ffffff" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.8724 23.1801L37.1451 23.3355L37.0092 23.5521L36.3959 23.3517L36.6823 23.1455L36.8724 23.1801Z" fill="#ffffff" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M38.7803 21.9829L36.8724 23.18L36.2058 20.8919L38.7665 21.949L38.7803 21.9829Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.7613 23.8298L37.0092 23.5521L37.1451 23.3355L38.0403 23.846L37.5381 23.9764L36.7172 23.8792L36.7613 23.8298Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.3913 23.3554L36.7613 23.8298L36.7172 23.8792L35.9653 23.6626L36.3913 23.3554Z" fill="#808080" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.7172 23.8792L37.5381 23.9764L36.103 24.3351L36.069 24.3278L36.0681 24.3271L33.4046 23.7318L35.9653 23.6626L36.7172 23.8792Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.3958 23.3517L36.3913 23.3554L36.1535 23.0497L36.6823 23.1455L36.3958 23.3517Z" fill="#efefef" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.1535 23.0497L36.3913 23.3554L35.9653 23.6626L35.9139 23.2419L36.1535 23.0497Z" fill="#d4d4d4" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.2058 20.8919L36.8724 23.1801L36.6823 23.1454L36.1535 23.0497L35.6779 22.9627L36.1342 20.8956L36.2058 20.8919Z" fill="#d8d8d8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.1342 20.8956L35.6779 22.9627L34.4972 20.9745L36.1342 20.8956Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.103 24.3351L37.5381 23.9764L37.9843 25.2648L36.0929 24.3388L36.103 24.3351Z" fill="#d8d8d8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M36.0928 24.3388L33.5661 25.2648L33.5092 25.2648L33.3183 23.734L33.4046 23.7318L36.0681 24.3271L36.069 24.3278L36.0928 24.3388Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.5092 25.2648L35.7366 27.2015L35.3005 27.5949L35.2785 27.6664L35.2638 27.6745L31.5591 26.1025L33.5092 25.2648Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M34.4972 20.9745L35.6779 22.9627L33.3202 23.7046L34.4972 20.9745Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M35.2748 27.6789L34.8397 29.0874L34.0712 31.1582L31.3848 29.8823L35.2639 27.6745L35.2748 27.6789Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M35.2638 27.6745L31.3847 29.8823L31.5591 26.1025L35.2638 27.6745Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.2366 23.6942L31.0303 23.4143L32.3322 22.3697L32.705 22.0706L34.4972 20.9744L33.3202 23.7045L33.2366 23.6942Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M34.0712 31.1582L33.9959 31.2459L33.995 31.2459L29.0407 31.307L29.0398 31.307L29.0306 31.2916L29.0003 31.2399L31.3848 29.8823L34.0712 31.1582Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.9949 31.2459L32.3276 33.1745L30.6869 33.9126L29.0407 31.307L33.9949 31.2459Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.5661 25.2648L37.9842 25.2648L37.112 25.8822L36.5152 26.4988L35.7366 27.2015L33.5092 25.2648L33.5661 25.2648Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.3183 23.734L33.5092 25.2648L31.5591 26.1024L31.0303 23.4143L33.2366 23.6942L33.3155 23.7119L33.3183 23.734Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M33.3183 23.734L33.4046 23.7318L33.3156 23.712L33.3183 23.734Z" fill="#3a3a3a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M35.6779 22.9627L36.1535 23.0497L35.9139 23.2419L35.9653 23.6626L33.4046 23.7318L33.3156 23.7119L33.2366 23.6942L33.3202 23.7046L35.6779 22.9627Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<g opacity="1">
|
||||
<path d="M34.0372 36.0387C34.0712 36.1212 34.0023 36.4077 34.0023 36.4077L33.5773 36.8313L33.6938 36.4622L33.8655 36.1594L33.6598 35.7359L32.671 35.3941L32.9253 35.7904L33.1998 36.4077L32.8904 36.6L32.4305 37.0795L32.5682 36.628L32.8904 36.2972C32.8904 36.2972 32.705 35.9834 32.671 35.8729C32.6361 35.7631 32.0394 35.3661 32.0394 35.3661L31.8677 35.1186L32.8106 35.0124L32.8115 35.0124L34.14 35.3116L34.14 35.8729L33.9683 35.5038L33.3706 35.3388L33.8655 35.6526C33.8655 35.6526 34.0023 35.9554 34.0372 36.0387" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M32.8106 35.0124L31.8677 35.1186L30.2958 34.3251L32.4994 34.9425L32.8106 35.0124Z" fill="#7f7f7f" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.9016 15.918L32.4305 17.3559L30.3894 21.9646L29.6981 23.5248L29.9561 20.9605L30.0994 19.5358L30.2352 18.1869L30.2958 18.2348L30.2728 18.1354L31.9016 15.918Z" fill="#585858" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M32.4305 17.3559L31.9016 20.8647L31.0303 23.4143L30.3894 21.9645L32.4305 17.3559Z" fill="#545454" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.9016 20.8647L32.3322 22.3697L31.0303 23.4143L31.9016 20.8647Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.9016 15.918L30.2728 18.1354L29.89 16.4808L29.89 16.4801L29.935 14.4225C30.3986 14.7769 30.6943 15.0111 30.7219 15.0539C30.8724 15.2851 31.9016 15.918 31.9016 15.918" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.8677 35.1186L30.8935 34.9145L29.5613 34.4467L30.2958 34.3251L31.8677 35.1186Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.5592 26.1024L31.3848 29.8823L27.8912 29.3739L27.1521 28.1289L31.5142 26.123L31.5592 26.1024Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.4894 26.0891L31.5141 26.123L27.1521 28.1289L29.3363 26.07L29.3896 26.0199L31.4894 26.0891Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.3848 29.8823L29.0003 31.2399L27.8912 29.374L31.3848 29.8823Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.7898 36.0659L30.9275 35.5428L31.8677 35.1186L32.0394 35.3661L31.1331 35.6806L31.0303 36.0107L31.202 36.4077L30.7898 36.0659Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.3894 21.9645L31.0303 23.4143L29.6981 23.5248L30.3894 21.9645Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.5282 24.895L29.6981 23.5248L31.0303 23.4143L29.3987 25.9462L29.5282 24.895Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.6869 33.9126L30.2958 34.3251L29.5613 34.4467L28.6496 34.0754L30.6869 33.9126Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.159 37.7234L29.7669 37.162L29.2179 36.804L30.7181 37.0648L30.9615 37.2445C30.9615 37.2445 31.236 37.5584 31.2709 37.6961C31.3048 37.8339 31.3737 38.1477 31.3737 38.2582C31.3737 38.368 30.8247 38.7098 30.8247 38.7098L31.0992 38.396L30.9275 37.7234L30.2269 37.327C30.2269 37.327 30.5703 38.038 30.6043 38.1477C30.6392 38.2582 30.4675 38.5168 30.3298 38.5448C30.1976 38.5713 29.7936 38.8564 29.7679 38.874L30.2958 38.2582L30.159 37.7234Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.7181 37.0648L29.2179 36.804L29.127 36.1167L29.1353 36.1211L29.5952 36.3252L30.3298 36.7768L30.7181 37.0648Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.2352 18.1869L30.0994 19.5358L22.4568 15.5216L20.4039 14.2052L26.5673 16.175L30.181 18.1449L30.1828 18.1456L30.2352 18.1869Z" fill="#9c9c9c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.181 18.1449L26.5673 16.175L22.5045 13.9599L20.9189 12.4372L20.2533 11.7653L20.2533 11.3137L20.6444 11.0935L22.353 11.9966L30.181 18.1449Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M30.0993 19.5358L29.9561 20.9605L23.1912 17.2491L23.1224 17.2344L20.9189 15.918L20.816 15.2741L21.1383 15.0811L22.4567 15.5216L30.0993 19.5358Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.935 14.4225L29.89 16.4801L29.89 16.4808L29.8881 16.5773L22.4788 10.1284L29.935 14.4225Z" fill="#9c9c9c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.935 14.4225L22.4788 10.1284L21.6874 9.07799L21.9417 8.79141L22.5596 8.95646C22.5596 8.95646 27.8866 12.8571 29.935 14.4225" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.5247 10.1904L22.4789 10.1284L29.8882 16.5773L29.89 16.4808L30.2728 18.1353L30.2352 18.1869L30.1829 18.1457L30.181 18.1449L22.3531 11.9966L21.0218 10.5042L20.884 9.97009L21.3789 9.63932L22.5247 10.1904Z" fill="#585858" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.6871 23.5197L23.9258 19.2617L23.1564 18.6171L22.7598 18.069L22.7607 18.069L29.9561 20.9605L29.6981 23.5248L29.6871 23.5197Z" fill="#545454" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.6981 23.5248L29.5282 24.895L26.0605 21.9771L29.6871 23.5197L29.6981 23.5248Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.6871 23.5197L26.0605 21.9771L24.1452 20.3851L23.5485 19.658L23.5135 19.1792L23.9258 19.2617L29.6871 23.5197Z" fill="#3a3a3a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M31.4894 26.0891L29.3896 26.0199L29.3987 25.9462L31.0303 23.4143L31.5592 26.1024L31.5141 26.123L31.4894 26.0891Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.3896 26.0199L29.3364 26.07L29.2399 26.067L29.3896 26.0199Z" fill="#a3a3a3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.3896 26.0199L29.2399 26.067L23.1637 25.8792L24.8265 22.8566L29.3896 26.0199Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.3363 26.07L27.152 28.129L24.237 29.1773L24.0626 29.0491L22.7451 28.9386L22.1823 28.6955L25.8198 27.1434L29.2399 26.0671L29.3363 26.07Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.2399 26.0671L25.8199 27.1434L23.1637 25.88L23.1637 25.8792L29.2399 26.0671Z" fill="#c8c8c8" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.9985 36.4902L28.118 35.5996L29.127 36.1167L29.2179 36.804L28.8268 37.2998L28.7579 37.7786L29.1353 38.3407L28.5862 38.01L28.4357 37.3661L28.6551 36.997L28.9985 36.4902Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.1261 36.1108L29.127 36.1167L28.118 35.5996L28.0234 34.832L29.1261 36.1108Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6147 34.0769L28.5743 34.071L29.0398 31.307L29.0407 31.307L30.6869 33.9126L28.6496 34.0754L28.6211 34.0776L28.6156 34.0769L28.6147 34.0769Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.0398 31.307L28.5743 34.071L22.8819 33.268L29.0398 31.307Z" fill="#a2a2a2" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.0398 31.307L22.8819 33.268L26.2331 30.0856L29.0398 31.307Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M29.0398 31.307L26.2331 30.0856L27.8912 29.374L29.0003 31.2399L28.9984 31.2407L29.0306 31.2916L29.0398 31.307Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6211 34.0776L28.6496 34.0754L29.5613 34.4467L28.0234 34.832L28.6211 34.0776Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6211 34.0776L28.0234 34.832L28.118 35.5996L28.0234 35.5038L27.2889 34.9425L28.6211 34.0776Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.6211 34.0776L27.2889 34.9425L26.8639 35.0361L25.9411 34.0864L28.5735 34.0769L28.6147 34.0769L28.6156 34.0769L28.6211 34.0776Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M28.5743 34.071L28.5734 34.0769L25.9411 34.0864L25.9383 34.0864L23.2858 34.0953L23.2849 34.0953L22.8819 33.268L28.5743 34.071Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M27.1521 28.129L27.8912 29.374L26.2331 30.0856L27.1521 28.129Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M26.0862 35.1288L26.8639 35.036C26.8639 35.036 24.7972 38.9285 24.3619 39.7816L26.0862 35.1288Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.5045 13.9599L26.5673 16.175L20.4039 14.2052L20.1156 13.6711L20.2533 13.2858L20.7472 13.2858L22.5596 14.0129L22.5045 13.9599Z" fill="#7c7c7c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M26.2331 30.0856L22.8819 33.268L23.5135 31.1309L24.2232 29.2148L24.2407 29.2192L26.2331 30.0856Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M26.0862 35.1288L24.3619 39.7816C24.3142 39.8759 24.2867 39.9326 24.283 39.9437C24.2481 40.0535 23.5485 40.5603 23.5485 40.5603L22.848 40.5876L26.0862 35.1288Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M25.9383 34.0864L25.9411 34.0864L26.8638 35.0361L26.0861 35.1288L24.5565 35.3116L25.9383 34.0864Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M25.9383 34.0864L24.5565 35.3116L24.1076 35.0559L23.6164 34.7767L23.2849 34.0961L23.2858 34.0953L25.9383 34.0864Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M25.8199 27.1434L22.1823 28.6955L22.1814 28.6955L22.0445 28.6358L21.5368 28.1784L23.1637 25.88L25.8199 27.1434Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.4198 22.5745L24.8265 22.8566L21.0998 22.6128L19.7033 22.5214L19.6308 22.506L16.4063 19.8805L22.6845 21.415L24.4198 22.5745Z" fill="#bcbcbc" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.5565 35.3116L22.69 40.7334C22.5403 40.8661 22.3227 41.0399 22.2162 41.0399C22.0445 41.0399 21.1732 41.1769 21.1732 41.1769L20.8014 41.1865L24.5565 35.3116Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.5565 35.3116L20.8014 41.1865L19.986 41.2086L19.3121 41.1496L19.1809 40.9906L24.1076 35.0559L24.5565 35.3116Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.0552 20.3608L24.4197 22.5745L22.6844 21.415L19.0744 19.0024L24.0552 20.3608Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.2407 29.2192L24.2233 29.2148L24.237 29.1772L27.1521 28.1289L26.2331 30.0856L24.2407 29.2192Z" fill="#8d8d8d" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.1452 20.3852L26.0605 21.9771L29.5283 24.895L29.3988 25.9463L29.3896 26.02L24.8265 22.8567L24.4198 22.5745L24.0552 20.3608L24.1452 20.3852Z" fill="#595959" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.1076 35.0559L19.1809 40.9905L18.5777 41.0399L17.8092 40.8078L17.5347 40.2848L24.1076 35.0559Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.1076 35.0559L17.5347 40.2848L23.2849 34.0961L23.6164 34.7767L24.1076 35.0559Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.2858 34.0953L23.2849 34.0961L23.2849 34.0953L23.2858 34.0953Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.2849 34.0953L23.2849 34.0961L16.3603 39.3853L19.0036 36.8313L22.8819 33.268L23.2849 34.0953Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.2849 34.0961L17.5346 40.2848L16.869 40.3673L16.2373 40.0535L16.3062 39.4369L16.3585 39.3861L16.3603 39.3853L23.2849 34.0961Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M24.8265 22.8567L23.1637 25.8792L23.1628 25.8792L23.138 25.8674L21.0998 22.6128L24.8265 22.8567Z" fill="#8c8c8c" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.1628 25.8792L23.1637 25.8792L23.1637 25.88L21.5368 28.1784L21.4817 28.129L20.4039 28.157L19.9788 27.9368L19.5187 27.5677L19.1754 27.1434L19.2699 27.1132L23.1628 25.8792Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M23.138 25.8674L23.1628 25.8792L19.2698 27.1132L20.3441 24.5385L23.138 25.8674Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.8479 40.5876C22.8479 40.5876 22.7818 40.6524 22.69 40.7334L24.5565 35.3116L26.0861 35.1288L22.8479 40.5876Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M22.7102 18.0005L22.6762 17.2071L23.1224 17.2344L23.1913 17.2491L29.9561 20.9605L22.7606 18.0691L22.7598 18.0691L22.7102 18.0005Z" fill="#454545" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M19.0744 19.0024L22.6844 21.415L16.4063 19.8805L11.2674 17.3287L19.0734 19.0024L19.0744 19.0024Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M20.3442 24.5385L20.256 24.5385L14.6995 24.5385C14.6995 24.5385 12.7365 23.6626 12.7016 23.5794C12.6676 23.4969 12.3591 22.9628 12.3591 22.9628L12.4271 22.6048L17.9763 23.6118L18.0745 23.6295L20.3442 24.5385Z" fill="#d3d3d3" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M20.3442 24.5385L19.2699 27.1132L19.1754 27.1434L18.3381 27.0881L17.6724 26.8958L17.2464 26.526L17.0885 26.0104L20.256 24.5385L20.3442 24.5385Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M20.256 24.5385L17.0884 26.0104L17.0747 25.9647L15.2971 25.7717L14.8712 25.2376L14.6995 24.5385L20.256 24.5385Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M16.4063 19.8805L19.6307 22.506L19.5114 22.4802L11.24 19.1166L10.0381 18.3305L9.06393 17.3007L8.82343 16.673L8.99512 16.3703L9.86642 16.7666L11.2675 17.3287L16.4063 19.8805Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M17.9763 23.6117L12.4271 22.6047L10.8552 22.2356L19.2993 22.4353L19.5114 22.4802L19.6307 22.506L19.7033 22.5214L17.9763 23.6117Z" fill="#707070" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M17.9763 23.6117L19.7033 22.5214L21.0998 22.6128L23.1381 25.8674L20.3442 24.5385L18.0745 23.6294L17.9763 23.6117Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M19.0735 19.0024L11.2675 17.3287L9.45504 15.5216L9.38617 15.0538L9.62673 14.8226L12.4959 16.5625C12.4959 16.5625 16.2373 18.0278 16.443 18.1383C16.6487 18.248 18.1664 18.7549 18.1664 18.7549L19.0735 19.0024Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M19.2993 22.4353L11.463 20.7601L11.542 20.7704L9.59278 19.9608L8.99511 19.2617L8.78939 18.6724L9.06398 18.4411L11.3354 19.1792L11.24 19.1166L19.5114 22.4802L19.2993 22.4353Z" fill="#9a9a9a" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
<path d="M11.4621 20.7601L11.4631 20.7601L19.2993 22.4353L10.8552 22.2357L10.073 21.5918L9.79847 20.8367L9.90129 20.5502L11.4621 20.7601Z" fill="#484848" fill-rule="evenodd" opacity="1" stroke="none"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -1 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#0DBD8B"/><text x="32" y="44" font-family="sans-serif" font-size="30" font-weight="bold" fill="white" text-anchor="middle">[M]</text></svg>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#ffffff" d="M 30,2.0000001 V 30 h -1 -2 v 2 h 5 V -3.3333334e-8 L 27,0 v 2 z"/>
|
||||
<path fill="#ffffff" d="M 9.9515939,10.594002 V 12.138 h 0.043994 c 0.3845141,-0.563728 0.8932271,-1.031728 1.4869981,-1.368 0.580003,-0.322998 1.244999,-0.485 1.993002,-0.485 0.72,0 1.376999,0.139993 1.971998,0.42 0.595,0.279004 1.047001,0.771001 1.355002,1.477001 0.338003,-0.500001 0.795999,-0.941 1.376999,-1.323001 0.579999,-0.382998 1.265998,-0.574 2.059998,-0.574 0.602003,0 1.160002,0.074 1.674002,0.220006 0.514,0.148006 0.953998,0.382998 1.321999,0.706998 0.36601,0.322999 0.653001,0.746 0.859,1.268002 0.205001,0.521998 0.307994,1.15 0.307994,1.887001 v 7.632997 h -3.127 v -6.463997 c 0,-0.383002 -0.01512,-0.743002 -0.04399,-1.082003 -0.02079,-0.3072 -0.103219,-0.607113 -0.242003,-0.881998 -0.133153,-0.25081 -0.335962,-0.457777 -0.584001,-0.596002 -0.257008,-0.146003 -0.605998,-0.220006 -1.046997,-0.220006 -0.440002,0 -0.796003,0.085 -1.068,0.253002 -0.272013,0.170003 -0.485001,0.390002 -0.639001,0.662003 -0.159119,0.287282 -0.263585,0.601602 -0.307994,0.926997 -0.05197,0.346923 -0.07801,0.697217 -0.07801,1.048002 v 6.353999 h -3.128005 v -6.398 c 0,-0.338003 -0.0072,-0.673001 -0.02116,-1.004001 -0.01134,-0.313663 -0.07487,-0.623229 -0.187994,-0.915999 -0.107943,-0.276623 -0.300435,-0.512126 -0.550001,-0.673001 -0.25799,-0.168 -0.636,-0.253002 -1.134999,-0.253002 -0.198123,0.0083 -0.394383,0.04195 -0.584002,0.100006 -0.258368,0.07446 -0.498455,0.201827 -0.704999,0.373985 -0.227981,0.183987 -0.421999,0.449 -0.583997,0.794003 -0.161008,0.345978 -0.242003,0.797998 -0.242003,1.356998 v 6.618999 H 6.99942 V 10.590001 Z"/>
|
||||
<path fill="#ffffff" d="M 2,2.0000001 V 30 h 3 v 2 H 0 V 9.2650922e-8 L 5,0 v 2 z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 1.8 KiB |
@@ -1 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#7D4698"/><text x="32" y="44" font-family="sans-serif" font-size="30" font-weight="bold" fill="white" text-anchor="middle">TOR</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512px" height="512px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient x1="50%" y1="100%" x2="50%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#420C5D" offset="0%"></stop>
|
||||
<stop stop-color="#951AD1" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<path d="M25,29 C152.577777,29 256,131.974508 256,259 C256,386.025492 152.577777,489 25,489 L25,29 Z" id="path-2"></path>
|
||||
<filter x="-18.2%" y="-7.4%" width="129.4%" height="114.8%" filterUnits="objectBoundingBox" id="filter-3">
|
||||
<feOffset dx="-8" dy="0" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feGaussianBlur stdDeviation="10" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0.250980392 0 0 0 0 0.250980392 0 0 0 0 0.250980392 0 0 0 0.2 0" type="matrix" in="shadowBlurOuter1"></feColorMatrix>
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="tor-browser-icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="icon_512x512">
|
||||
<g id="Group">
|
||||
<g id="tb_icon/Stable">
|
||||
<g id="Stable">
|
||||
<circle id="background" fill="#F2E4FF" fill-rule="nonzero" cx="256" cy="256" r="246"></circle>
|
||||
<path d="M256.525143,465.439707 L256.525143,434.406609 C354.826191,434.122748 434.420802,354.364917 434.420802,255.992903 C434.420802,157.627987 354.826191,77.8701558 256.525143,77.5862948 L256.525143,46.5531962 C371.964296,46.8441537 465.446804,140.489882 465.446804,255.992903 C465.446804,371.503022 371.964296,465.155846 256.525143,465.439707 Z M256.525143,356.820314 C311.970283,356.529356 356.8487,311.516106 356.8487,255.992903 C356.8487,200.476798 311.970283,155.463547 256.525143,155.17259 L256.525143,124.146588 C329.115485,124.430449 387.881799,183.338693 387.881799,255.992903 C387.881799,328.654211 329.115485,387.562455 256.525143,387.846316 L256.525143,356.820314 Z M256.525143,201.718689 C286.266674,202.00255 310.3026,226.180407 310.3026,255.992903 C310.3026,285.812497 286.266674,309.990353 256.525143,310.274214 L256.525143,201.718689 Z M0,255.992903 C0,397.384044 114.60886,512 256,512 C397.384044,512 512,397.384044 512,255.992903 C512,114.60886 397.384044,0 256,0 C114.60886,0 0,114.60886 0,255.992903 Z" id="center" fill="url(#linearGradient-1)"></path>
|
||||
<g id="half" transform="translate(140.500000, 259.000000) scale(-1, 1) translate(-140.500000, -259.000000) ">
|
||||
<use fill="black" fill-opacity="1" filter="url(#filter-3)" xlink:href="#path-2"></use>
|
||||
<use fill="url(#linearGradient-1)" fill-rule="evenodd" xlink:href="#path-2"></use>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 2.9 KiB |
236
app/icons/update.svg
Normal file
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="128px"
|
||||
height="128px"
|
||||
viewBox="0 0 128 128"
|
||||
version="1.1"
|
||||
id="svg96"
|
||||
sodipodi:docname="Sovran_SystemsOS_Updater_Iconv3.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview98"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.2149125"
|
||||
inkscape:cx="9.0126153"
|
||||
inkscape:cy="64.430611"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1352"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer2" /><defs
|
||||
id="defs67"><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient936"><stop
|
||||
style="stop-color:#1e8e11;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop932" /><stop
|
||||
style="stop-color:#1bff00;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop934" /></linearGradient><linearGradient
|
||||
id="linearGradient1028"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1026" /></linearGradient><linearGradient
|
||||
id="linearGradient998"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop996" /></linearGradient><radialGradient
|
||||
id="radial0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.232034,-0.541475,-0.368794,-0.0298398,4.277749,118.95849)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#00ff39;stop-opacity:1;"
|
||||
id="stop2" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#004a19;stop-opacity:1;"
|
||||
id="stop4" /></radialGradient><radialGradient
|
||||
id="radial1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.107656,-0.225172,-0.327748,0.258343,373.87973,30.205086)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#43b60b;stop-opacity:1;"
|
||||
id="stop7" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#0b88ff;stop-opacity:0.00829875;"
|
||||
id="stop9" /></radialGradient><clipPath
|
||||
id="clip1"><path
|
||||
d="M 7 46 L 57 46 L 57 93 L 7 93 Z M 7 46 "
|
||||
id="path12" /></clipPath><clipPath
|
||||
id="clip2"><path
|
||||
d="M 32.25 46.957031 C 19.6875 46.96875 9.085938 56.636719 7.503906 69.53125 C 9.0625 82.445312 19.667969 92.144531 32.25 92.160156 C 44.816406 92.148438 55.414062 82.480469 57 69.585938 C 55.441406 56.671875 44.835938 46.972656 32.25 46.957031 Z M 32.25 46.957031 "
|
||||
id="path15" /></clipPath><radialGradient
|
||||
id="radial2"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.485163,-1.148584,-0.771115,-0.0632965,-47.124961,203.98857)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(92.941177%,20%,23.137255%);stop-opacity:1;"
|
||||
id="stop18" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(63.921571%,27.843139%,72.941178%);stop-opacity:1;"
|
||||
id="stop20" /></radialGradient><radialGradient
|
||||
id="radial3"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.225099,-0.477638,-0.685291,0.548001,725.67923,15.723794)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(10.980392%,44.313726%,84.705883%);stop-opacity:1;"
|
||||
id="stop23" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(20.784314%,51.764709%,89.411765%);stop-opacity:0.00829876;"
|
||||
id="stop25" /></radialGradient><linearGradient
|
||||
id="linear0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="22"
|
||||
y1="37"
|
||||
x2="62"
|
||||
y2="37"
|
||||
gradientTransform="matrix(1.4,0,0,1.4,-26.799973,2.491745)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(58.039218%,57.647061%,56.470591%);stop-opacity:1;"
|
||||
id="stop28" /><stop
|
||||
offset="0.0908155"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop30" /><stop
|
||||
offset="0.336093"
|
||||
style="stop-color:rgb(60.392159%,60.000002%,58.823532%);stop-opacity:1;"
|
||||
id="stop32" /><stop
|
||||
offset="0.844326"
|
||||
style="stop-color:rgb(76.47059%,75.294119%,72.941178%);stop-opacity:1;"
|
||||
id="stop34" /><stop
|
||||
offset="0.930505"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop36" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(75.294119%,74.901962%,73.725492%);stop-opacity:1;"
|
||||
id="stop38" /></linearGradient><radialGradient
|
||||
id="radial4"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="-172.560638"
|
||||
cy="28.569126"
|
||||
fx="-172.560638"
|
||||
fy="28.569126"
|
||||
r="15.85742"
|
||||
gradientTransform="matrix(1.560712,0,0,1.4252,300.69366,13.349996)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.358268;"
|
||||
id="stop41" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.0944882;"
|
||||
id="stop43" /></radialGradient><filter
|
||||
id="alpha"
|
||||
filterUnits="objectBoundingBox"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"><feColorMatrix
|
||||
type="matrix"
|
||||
in="SourceGraphic"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
id="feColorMatrix46" /></filter><mask
|
||||
id="mask0"><g
|
||||
filter="url(#alpha)"
|
||||
id="g51"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="128"
|
||||
height="128"
|
||||
style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"
|
||||
id="rect49" /></g></mask><clipPath
|
||||
id="clip3"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="192"
|
||||
height="152"
|
||||
id="rect54" /></clipPath><g
|
||||
id="surface382"
|
||||
clip-path="url(#clip3)"><path
|
||||
style=" stroke:none;fill-rule:nonzero;fill:rgb(27.058825%,21.176471%,21.568628%);fill-opacity:1;"
|
||||
d="M 40 59.957031 C 26.191406 59.957031 15 71.152344 15 84.957031 C 15.011719 85.996094 15.085938 86.777344 15.222656 87.804688 C 15.222656 75.957031 27.421875 65.96875 40 65.957031 C 52.597656 65.972656 64.777344 75.957031 64.777344 87.859375 C 64.917969 86.816406 64.992188 86.011719 65 84.957031 C 65 71.152344 53.808594 59.957031 40 59.957031 Z M 40 59.957031 "
|
||||
id="path57" /></g><radialGradient
|
||||
id="radial5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="40"
|
||||
cy="227"
|
||||
fx="40"
|
||||
fy="227"
|
||||
r="28"
|
||||
gradientTransform="matrix(0.575553,0,1.60551e-8,1.540703,8.977913,-280.78108)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:1;"
|
||||
id="stop60" /><stop
|
||||
offset="0.744626"
|
||||
style="stop-color:rgb(98.039216%,98.039216%,98.039216%);stop-opacity:1;"
|
||||
id="stop62" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(87.450981%,87.450981%,87.450981%);stop-opacity:1;"
|
||||
id="stop64" /></radialGradient>
|
||||
|
||||
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient936"
|
||||
id="linearGradient938"
|
||||
x1="-48.519272"
|
||||
y1="18.511358"
|
||||
x2="287.07454"
|
||||
y2="18.511358"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.1020247,0,0,1.1097375,37.198581,-10.424856)" /></defs><path
|
||||
style="fill:#f5f5f3;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 20,11.957031 h 88 c 4.41797,0 8,3.582031 8,8 V 108 c 0,4.41797 -3.58203,8 -8,8 H 20 c -4.417969,0 -8,-3.58203 -8,-8 V 19.957031 c 0,-4.417969 3.582031,-8 8,-8 z m 0,0"
|
||||
id="path69" /><path
|
||||
style="fill:url(#radial0);fill-rule:nonzero;stroke:none"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path71" /><path
|
||||
style="fill:none;fill-rule:nonzero;stroke:none;fill-opacity:1"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path73" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 1"
|
||||
transform="matrix(0.1816,0,0,0.1816,35.224187,79.037164)"><ellipse
|
||||
fill="#54c147"
|
||||
cx="168.64549"
|
||||
cy="10.117889"
|
||||
id="circle8314"
|
||||
rx="184.91634"
|
||||
ry="179.91556"
|
||||
style="fill:url(#linearGradient938);fill-opacity:1;stroke-width:1.71591" /><polygon
|
||||
fill="#ffffff"
|
||||
points="46.678,120.299 63.562,96.402 96.977,121.718 145.084,50.79 168.752,69.02 103.583,164.647 "
|
||||
id="polygon8316"
|
||||
transform="matrix(1.7395866,0,0,1.6925423,-18.737581,-172.19767)" /></g></svg>
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
@@ -1 +1,74 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#175DDC"/><path d="M32 10 L48 20 L48 36 C48 48 32 54 32 54 C32 54 16 48 16 36 L16 20 Z" fill="white" opacity="0.9"/><text x="32" y="40" font-family="sans-serif" font-size="18" font-weight="bold" fill="#175DDC" text-anchor="middle">VW</text></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg version="1.1" viewBox="0 0 256 256" id="svg383" sodipodi:docname="vaultwarden-icon.svg" inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)" width="256" height="256" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs id="defs387" />
|
||||
<sodipodi:namedview id="namedview385" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" showgrid="false" inkscape:zoom="3.3359375" inkscape:cx="128" inkscape:cy="128" inkscape:window-width="1874" inkscape:window-height="1056" inkscape:window-x="46" inkscape:window-y="24" inkscape:window-maximized="1" inkscape:current-layer="svg383" />
|
||||
<title id="title287">Vaultwarden Icon</title>
|
||||
<g id="logo" transform="matrix(2.4381018,0,0,2.4381018,128,128)">
|
||||
<g id="gear" mask="url(#holes)">
|
||||
<path d="m-31.1718-33.813208 26.496029 74.188883h9.3515399l26.49603-74.188883h-9.767164l-16.728866 47.588948q-1.662496 4.571864-2.805462 8.624198-1.142966 3.948427-1.870308 7.585137-.72734199-3.63671-1.8703079-7.689043-1.142966-4.052334-2.805462-8.728104l-16.624959-47.381136z" stroke="#000" stroke-width="4.51171" id="path289" />
|
||||
<circle transform="scale(-1,1)" r="43" fill="none" stroke="#000" stroke-width="9" id="circle291" />
|
||||
<g id="cogs" transform="scale(-1,1)">
|
||||
<polygon id="cog" points="51 0 46 -3 46 3" stroke="#000" stroke-linejoin="round" stroke-width="3" />
|
||||
<use transform="rotate(11.25)" xlink:href="#cog" id="use294" />
|
||||
<use transform="rotate(22.5)" xlink:href="#cog" id="use296" />
|
||||
<use transform="rotate(33.75)" xlink:href="#cog" id="use298" />
|
||||
<use transform="rotate(45)" xlink:href="#cog" id="use300" />
|
||||
<use transform="rotate(56.25)" xlink:href="#cog" id="use302" />
|
||||
<use transform="rotate(67.5)" xlink:href="#cog" id="use304" />
|
||||
<use transform="rotate(78.75)" xlink:href="#cog" id="use306" />
|
||||
<use transform="rotate(90)" xlink:href="#cog" id="use308" />
|
||||
<use transform="rotate(101.25)" xlink:href="#cog" id="use310" />
|
||||
<use transform="rotate(112.5)" xlink:href="#cog" id="use312" />
|
||||
<use transform="rotate(123.75)" xlink:href="#cog" id="use314" />
|
||||
<use transform="rotate(135)" xlink:href="#cog" id="use316" />
|
||||
<use transform="rotate(146.25)" xlink:href="#cog" id="use318" />
|
||||
<use transform="rotate(157.5)" xlink:href="#cog" id="use320" />
|
||||
<use transform="rotate(168.75)" xlink:href="#cog" id="use322" />
|
||||
<use transform="scale(-1)" xlink:href="#cog" id="use324" />
|
||||
<use transform="rotate(191.25)" xlink:href="#cog" id="use326" />
|
||||
<use transform="rotate(202.5)" xlink:href="#cog" id="use328" />
|
||||
<use transform="rotate(213.75)" xlink:href="#cog" id="use330" />
|
||||
<use transform="rotate(225)" xlink:href="#cog" id="use332" />
|
||||
<use transform="rotate(236.25)" xlink:href="#cog" id="use334" />
|
||||
<use transform="rotate(247.5)" xlink:href="#cog" id="use336" />
|
||||
<use transform="rotate(258.75)" xlink:href="#cog" id="use338" />
|
||||
<use transform="rotate(-90)" xlink:href="#cog" id="use340" />
|
||||
<use transform="rotate(-78.75)" xlink:href="#cog" id="use342" />
|
||||
<use transform="rotate(-67.5)" xlink:href="#cog" id="use344" />
|
||||
<use transform="rotate(-56.25)" xlink:href="#cog" id="use346" />
|
||||
<use transform="rotate(-45)" xlink:href="#cog" id="use348" />
|
||||
<use transform="rotate(-33.75)" xlink:href="#cog" id="use350" />
|
||||
<use transform="rotate(-22.5)" xlink:href="#cog" id="use352" />
|
||||
<use transform="rotate(-11.25)" xlink:href="#cog" id="use354" />
|
||||
</g>
|
||||
<g id="mounts" transform="scale(-1,1)">
|
||||
<polygon id="mount" points="0 -35 7 -42 -7 -42" stroke="#000" stroke-linejoin="round" stroke-width="6" />
|
||||
<use transform="rotate(72)" xlink:href="#mount" id="use358" />
|
||||
<use transform="rotate(144)" xlink:href="#mount" id="use360" />
|
||||
<use transform="rotate(216)" xlink:href="#mount" id="use362" />
|
||||
<use transform="rotate(-72)" xlink:href="#mount" id="use364" />
|
||||
</g>
|
||||
</g>
|
||||
<mask id="holes">
|
||||
<rect x="-60" y="-60" width="120" height="120" fill="#fff" id="rect368" />
|
||||
<circle id="hole" cy="-40" r="3" />
|
||||
<use transform="rotate(72)" xlink:href="#hole" id="use371" />
|
||||
<use transform="rotate(144)" xlink:href="#hole" id="use373" />
|
||||
<use transform="rotate(216)" xlink:href="#hole" id="use375" />
|
||||
<use transform="rotate(-72)" xlink:href="#hole" id="use377" />
|
||||
</mask>
|
||||
</g>
|
||||
<metadata id="metadata381">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:title>Vaultwarden Icon</dc:title>
|
||||
<dc:creator>
|
||||
<cc:Agent>
|
||||
<dc:title>Mathijs van Veluw</dc:title>
|
||||
</cc:Agent>
|
||||
</dc:creator>
|
||||
<dc:relation>Rust Logo</dc:relation>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 5.2 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#21759B"/><text x="32" y="44" font-family="serif" font-size="38" font-weight="bold" fill="white" text-anchor="middle">W</text></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="64px" height="64px" viewBox="0 0 64 64" enable-background="new 0 0 64 64" xml:space="preserve"><style>.style0{fill: #0073aa;}</style><g><g><path d="M4.548 31.999c0 10.9 6.3 20.3 15.5 24.706L6.925 20.827C5.402 24.2 4.5 28 4.5 31.999z M50.531 30.614c0-3.394-1.219-5.742-2.264-7.57c-1.391-2.263-2.695-4.177-2.695-6.439c0-2.523 1.912-4.872 4.609-4.872 c0.121 0 0.2 0 0.4 0.022C45.653 7.3 39.1 4.5 32 4.548c-9.591 0-18.027 4.921-22.936 12.4 c0.645 0 1.3 0 1.8 0.033c2.871 0 7.316-0.349 7.316-0.349c1.479-0.086 1.7 2.1 0.2 2.3 c0 0-1.487 0.174-3.142 0.261l9.997 29.735l6.008-18.017l-4.276-11.718c-1.479-0.087-2.879-0.261-2.879-0.261 c-1.48-0.087-1.306-2.349 0.174-2.262c0 0 4.5 0.3 7.2 0.349c2.87 0 7.317-0.349 7.317-0.349 c1.479-0.086 1.7 2.1 0.2 2.262c0 0-1.489 0.174-3.142 0.261l9.92 29.508l2.739-9.148 C49.628 35.7 50.5 33 50.5 30.614z M32.481 34.4l-8.237 23.934c2.46 0.7 5.1 1.1 7.8 1.1 c3.197 0 6.262-0.552 9.116-1.556c-0.072-0.118-0.141-0.243-0.196-0.379L32.481 34.4z M56.088 18.8 c0.119 0.9 0.2 1.8 0.2 2.823c0 2.785-0.521 5.916-2.088 9.832l-8.385 24.242c8.161-4.758 13.65-13.6 13.65-23.728 C59.451 27.2 58.2 22.7 56.1 18.83z M32 0c-17.645 0-32 14.355-32 32C0 49.6 14.4 64 32 64s32-14.355 32-32.001 C64 14.4 49.6 0 32 0z M32 62.533c-16.835 0-30.533-13.698-30.533-30.534C1.467 15.2 15.2 1.5 32 1.5 s30.534 13.7 30.5 30.532C62.533 48.8 48.8 62.5 32 62.533z" class="style0"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 1.5 KiB |
144
app/icons/zeus.svg
Normal file
@@ -0,0 +1,144 @@
|
||||
<svg width="160" height="160" version="1.1" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="160" height="160" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/jpeg;base64,/9j/4QDKRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAA
|
||||
XgEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAAB
|
||||
AAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAA
|
||||
AQAAAUCgAwAEAAAAAQAAAUCkBgADAAAAAQAAAAAAAAAAAAD/2wCEAAEBAQEBAQIBAQIDAgICAwQD
|
||||
AwMDBAUEBAQEBAUGBQUFBQUFBgYGBgYGBgYHBwcHBwcICAgICAkJCQkJCQkJCQkBAQEBAgICBAIC
|
||||
BAkGBQYJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCf/d
|
||||
AAQACv/AABEIAKAAoAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQ
|
||||
AAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYX
|
||||
GBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqS
|
||||
k5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz
|
||||
9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQID
|
||||
EQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RF
|
||||
RkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqy
|
||||
s7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/
|
||||
AP8AP/ooooA734ceM734f+MrLxVZ5b7LIC6D+OI8SJ26r098elft/wCHLyz1fT7fVtMcS21zGssT
|
||||
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
|
||||
25fimfNdFFFf3gfxCf/Z
|
||||
"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
326
app/sovran_systemsos_web/scripts/sovran-hub-backup.sh
Executable file
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Sovran Hub External Backup Script ────────────────────────────
|
||||
# Backs up Sovran_SystemsOS data to an external USB hard drive.
|
||||
# Designed for the Hub web UI (no GUI dependencies).
|
||||
#
|
||||
# Your Sovran Pro already backs up your data automatically to its
|
||||
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive).
|
||||
# This script creates an additional copy on an external USB drive —
|
||||
# storing your data in a third location for maximum protection.
|
||||
#
|
||||
# Usage:
|
||||
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
|
||||
# (or run with no env var to auto-detect the first external USB drive)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
||||
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
||||
MEDIA_ROOT="/run/media"
|
||||
MIN_FREE_GB=10
|
||||
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
||||
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
||||
|
||||
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
||||
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
||||
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
|
||||
|
||||
# ── Logging helpers ──────────────────────────────────────────────
|
||||
|
||||
log() {
|
||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
echo "$msg" | tee -a "$BACKUP_LOG"
|
||||
}
|
||||
|
||||
set_status() {
|
||||
echo "$1" > "$BACKUP_STATUS"
|
||||
}
|
||||
|
||||
fail() {
|
||||
log "ERROR: $*"
|
||||
set_status "FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Check whether a mount point is an internal drive ────────────
|
||||
|
||||
is_internal() {
|
||||
local mnt="$1"
|
||||
# Reject known internal mount points and their subdirectories
|
||||
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
||||
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Use lsblk to find the first genuine external USB drive ───────
|
||||
|
||||
find_external_drive() {
|
||||
local target=""
|
||||
# lsblk JSON output: NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE
|
||||
if command -v lsblk &>/dev/null; then
|
||||
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
||||
# Must be a partition or disk, and be removable/hotplug
|
||||
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
||||
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
||||
[[ -n "$mountpoint" ]] || continue
|
||||
|
||||
# Filter out internal labels
|
||||
local skip=0
|
||||
for lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$label" == "$lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
|
||||
# Filter out internal mount points
|
||||
is_internal "$mountpoint" && continue
|
||||
|
||||
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
||||
target="$mountpoint"
|
||||
break
|
||||
fi
|
||||
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
def flatten(devs):
|
||||
for d in devs:
|
||||
yield d
|
||||
for c in d.get('children', []):
|
||||
yield from flatten([c])
|
||||
for d in flatten(data.get('blockdevices', [])):
|
||||
print('\t'.join([
|
||||
d.get('type') or '',
|
||||
str(d.get('hotplug') or '0'),
|
||||
str(d.get('rm') or '0'),
|
||||
d.get('label') or '',
|
||||
d.get('mountpoint') or '',
|
||||
]))
|
||||
" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Fallback: walk /run/media/ if lsblk produced nothing
|
||||
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
||||
while IFS= read -r -d '' mnt; do
|
||||
is_internal "$mnt" && continue
|
||||
# Check label via lsblk on the device backing this mount
|
||||
local dev
|
||||
dev=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null || true)
|
||||
if [[ -n "$dev" ]]; then
|
||||
local lbl
|
||||
lbl=$(lsblk -n -o LABEL "$dev" 2>/dev/null || true)
|
||||
local skip=0
|
||||
for internal_lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$lbl" == "$internal_lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
fi
|
||||
if mountpoint -q "$mnt" 2>/dev/null; then
|
||||
target="$mnt"
|
||||
break
|
||||
fi
|
||||
done < <(find "$MEDIA_ROOT" -mindepth 2 -maxdepth 2 -type d -print0 2>/dev/null)
|
||||
fi
|
||||
|
||||
echo "$target"
|
||||
}
|
||||
|
||||
# ── Detect the configured system role ───────────────────────────
|
||||
#
|
||||
# Priority:
|
||||
# 1. Hub config JSON (/var/lib/sovran-hub/config.json) — "role" key
|
||||
# 2. role-state.nix (/etc/nixos/role-state.nix) — grep for true flag
|
||||
# 3. Default: server_plus_desktop
|
||||
|
||||
detect_role() {
|
||||
local role="server_plus_desktop"
|
||||
|
||||
# 1. Try the Hub config JSON
|
||||
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
||||
local r
|
||||
r=$(python3 -c \
|
||||
"import json,sys; d=json.load(open(sys.argv[1])); print(d.get('role',''))" \
|
||||
"$HUB_CONFIG_JSON" 2>/dev/null || true)
|
||||
if [[ -n "$r" ]]; then
|
||||
echo "$r"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Fall back to parsing role-state.nix
|
||||
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
||||
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||
role="desktop"
|
||||
elif grep -q 'roles\.node = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||
role="node"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$role"
|
||||
}
|
||||
|
||||
# ── Initialise log file ──────────────────────────────────────────
|
||||
|
||||
: > "$BACKUP_LOG"
|
||||
set_status "RUNNING"
|
||||
|
||||
log "=== Sovran_SystemsOS External Hub Backup ==="
|
||||
log "Starting backup process…"
|
||||
|
||||
# ── Detect system role ───────────────────────────────────────────
|
||||
|
||||
ROLE="$(detect_role)"
|
||||
case "$ROLE" in
|
||||
desktop) ROLE_LABEL="Desktop Only" ;;
|
||||
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
|
||||
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
|
||||
*) ROLE_LABEL="$ROLE" ;;
|
||||
esac
|
||||
log "Detected role: $ROLE_LABEL"
|
||||
|
||||
# ── Detect target drive ──────────────────────────────────────────
|
||||
|
||||
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
||||
TARGET="$BACKUP_TARGET"
|
||||
# Safety: never allow internal drives even if explicitly passed
|
||||
if is_internal "$TARGET"; then
|
||||
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
||||
fi
|
||||
log "Using specified backup target: $TARGET"
|
||||
else
|
||||
log "Auto-detecting external USB drives…"
|
||||
TARGET="$(find_external_drive)"
|
||||
if [[ -z "$TARGET" ]]; then
|
||||
fail "No external USB drive detected. " \
|
||||
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
|
||||
fi
|
||||
log "Detected external drive: $TARGET"
|
||||
fi
|
||||
|
||||
# ── Verify mount point ───────────────────────────────────────────
|
||||
|
||||
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
|
||||
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
|
||||
|
||||
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
|
||||
|
||||
FREE_KB=$(df -k --output=avail "$TARGET" | tail -1)
|
||||
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
|
||||
log "Free space on drive: ${FREE_GB} GB"
|
||||
(( FREE_GB >= MIN_FREE_GB )) || \
|
||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)."
|
||||
|
||||
# ── Create timestamped backup directory ─────────────────────────
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
log "Backup destination: $BACKUP_DIR"
|
||||
|
||||
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
||||
if [[ -d /etc/nixos ]]; then
|
||||
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 1 failed while copying /etc/nixos"
|
||||
log "Stage 1 complete."
|
||||
else
|
||||
log "WARNING: /etc/nixos not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 2/4: Secrets ───────────────────────────────────────"
|
||||
mkdir -p "$BACKUP_DIR/secrets"
|
||||
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
||||
# /var/lib/domains is still backed up if present (hub state)
|
||||
for SRC in /var/lib/domains; do
|
||||
if [[ -e "$SRC" ]]; then
|
||||
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy $SRC — continuing."
|
||||
else
|
||||
log " (not found: $SRC — skipping)"
|
||||
fi
|
||||
done
|
||||
else
|
||||
for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do
|
||||
if [[ -e "$SRC" ]]; then
|
||||
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy $SRC — continuing."
|
||||
else
|
||||
log " (not found: $SRC — skipping)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Hub state files from /var/lib/secrets/ (backed up for all roles)
|
||||
if [[ -d /var/lib/secrets ]]; then
|
||||
mkdir -p "$BACKUP_DIR/secrets/hub-state"
|
||||
rsync -a --info=progress2 /var/lib/secrets/ "$BACKUP_DIR/secrets/hub-state/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy /var/lib/secrets — continuing."
|
||||
else
|
||||
log " (not found: /var/lib/secrets — skipping)"
|
||||
fi
|
||||
|
||||
log "Stage 2 complete."
|
||||
|
||||
# ── Stage 3/4: Home directory ────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
||||
if [[ -d /home ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
--exclude='.cache/' \
|
||||
--exclude='.local/share/Trash/' \
|
||||
--exclude='*/Trash/' \
|
||||
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 3 failed while copying /home"
|
||||
log "Stage 3 complete."
|
||||
else
|
||||
log "WARNING: /home not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Stage 4/4: Wallet and node data ─────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────"
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "Skipping Stage 4 (LND wallet data) — not applicable for Desktop Only role."
|
||||
elif [[ -d /var/lib/lnd ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
--exclude='logs/' \
|
||||
/var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib/lnd"
|
||||
log "Stage 4 complete."
|
||||
else
|
||||
log "WARNING: /var/lib/lnd not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Generate manifest ────────────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "Generating BACKUP_MANIFEST.txt …"
|
||||
{
|
||||
echo "Sovran_SystemsOS Backup Manifest"
|
||||
echo "Generated: $(date)"
|
||||
echo "Hostname: $(hostname)"
|
||||
echo "Role: $ROLE_LABEL"
|
||||
echo "Target: $TARGET"
|
||||
echo ""
|
||||
echo "Contents:"
|
||||
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
|
||||
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "All Finished! Your data is now backed up to a third location."
|
||||
log "Please eject the drive safely before removing it from your Sovran Pro."
|
||||
set_status "SUCCESS"
|
||||
@@ -1,519 +0,0 @@
|
||||
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
||||
v7 — Status-only dashboard + Tech Support */
|
||||
"use strict";
|
||||
|
||||
const POLL_INTERVAL_SERVICES = 5000;
|
||||
const POLL_INTERVAL_UPDATES = 1800000;
|
||||
const UPDATE_POLL_INTERVAL = 2000;
|
||||
const REBOOT_CHECK_INTERVAL = 5000;
|
||||
const SUPPORT_TIMER_INTERVAL = 1000;
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"infrastructure",
|
||||
"bitcoin-base",
|
||||
"bitcoin-apps",
|
||||
"communication",
|
||||
"apps",
|
||||
"nostr",
|
||||
"support",
|
||||
];
|
||||
|
||||
const STATUS_LOADING_STATES = new Set([
|
||||
"reloading", "activating", "deactivating", "maintenance",
|
||||
]);
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
|
||||
let _servicesCache = [];
|
||||
let _categoryLabels = {};
|
||||
let _updateLog = "";
|
||||
let _updatePollTimer = null;
|
||||
let _updateLogOffset = 0;
|
||||
let _serverWasDown = false;
|
||||
let _updateFinished = false;
|
||||
let _supportTimerInt = null;
|
||||
let _supportEnabledAt = null;
|
||||
let _cachedExternalIp = null;
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────
|
||||
|
||||
const $tilesArea = document.getElementById("tiles-area");
|
||||
const $updateBtn = document.getElementById("btn-update");
|
||||
const $updateBadge = document.getElementById("update-badge");
|
||||
const $refreshBtn = document.getElementById("btn-refresh");
|
||||
const $internalIp = document.getElementById("ip-internal");
|
||||
const $externalIp = document.getElementById("ip-external");
|
||||
|
||||
const $modal = document.getElementById("update-modal");
|
||||
const $modalSpinner = document.getElementById("modal-spinner");
|
||||
const $modalStatus = document.getElementById("modal-status");
|
||||
const $modalLog = document.getElementById("modal-log");
|
||||
const $btnReboot = document.getElementById("btn-reboot");
|
||||
const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
|
||||
const $credsModal = document.getElementById("creds-modal");
|
||||
const $credsTitle = document.getElementById("creds-modal-title");
|
||||
const $credsBody = document.getElementById("creds-body");
|
||||
const $credsCloseBtn = document.getElementById("creds-close-btn");
|
||||
|
||||
const $supportModal = document.getElementById("support-modal");
|
||||
const $supportBody = document.getElementById("support-body");
|
||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function tileId(svc) { return svc.unit + "::" + svc.name; }
|
||||
|
||||
function statusClass(status) {
|
||||
if (!status) return "unknown";
|
||||
if (status === "active") return "active";
|
||||
if (status === "inactive") return "inactive";
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "disabled") return "disabled";
|
||||
if (STATUS_LOADING_STATES.has(status)) return "loading";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function statusText(status, enabled) {
|
||||
if (!enabled) return "disabled";
|
||||
if (!status || status === "unknown") return "unknown";
|
||||
return status;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||||
}
|
||||
|
||||
function linkify(str) {
|
||||
return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return h + "h " + m + "m " + s + "s";
|
||||
if (m > 0) return m + "m " + s + "s";
|
||||
return s + "s";
|
||||
}
|
||||
|
||||
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
const res = await fetch(path, options || {});
|
||||
if (!res.ok) throw new Error(res.status + " " + res.statusText);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Render: initial build ─────────────────────────────────────────
|
||||
|
||||
function buildTiles(services, categoryLabels) {
|
||||
_servicesCache = services;
|
||||
var grouped = {};
|
||||
for (var i = 0; i < services.length; i++) {
|
||||
var cat = services[i].category || "other";
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(services[i]);
|
||||
}
|
||||
$tilesArea.innerHTML = "";
|
||||
var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
||||
Object.keys(grouped).forEach(function(k) {
|
||||
if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k);
|
||||
});
|
||||
for (var j = 0; j < orderedKeys.length; j++) {
|
||||
var catKey = orderedKeys[j];
|
||||
var entries = grouped[catKey];
|
||||
if (!entries || entries.length === 0) continue;
|
||||
var label = categoryLabels[catKey] || catKey;
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section";
|
||||
section.dataset.category = catKey;
|
||||
section.innerHTML = '<div class="section-header">' + escHtml(label) + '</div><hr class="section-divider" /><div class="tiles-grid" data-cat="' + escHtml(catKey) + '"></div>';
|
||||
var grid = section.querySelector(".tiles-grid");
|
||||
for (var k = 0; k < entries.length; k++) {
|
||||
grid.appendChild(buildTile(entries[k]));
|
||||
}
|
||||
$tilesArea.appendChild(section);
|
||||
}
|
||||
if ($tilesArea.children.length === 0) {
|
||||
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function buildTile(svc) {
|
||||
var isSupport = svc.type === "support";
|
||||
var sc = statusClass(svc.status);
|
||||
var st = statusText(svc.status, svc.enabled);
|
||||
var dis = !svc.enabled;
|
||||
var hasCreds = svc.has_credentials && svc.enabled;
|
||||
|
||||
var tile = document.createElement("div");
|
||||
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
||||
tile.dataset.unit = svc.unit;
|
||||
tile.dataset.tileId = tileId(svc);
|
||||
if (dis) tile.title = svc.name + " is not enabled in custom.nix";
|
||||
|
||||
if (isSupport) {
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">🛟</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="support-status-label">Click to manage</span></div>';
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() { openSupportModal(); });
|
||||
return tile;
|
||||
}
|
||||
|
||||
var infoBtn = hasCreds ? '<button class="tile-info-btn" data-unit="' + escHtml(svc.unit) + '" title="Connection info">i</button>' : "";
|
||||
tile.innerHTML = infoBtn + '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">⚙</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + escHtml(st) + '</span></div>';
|
||||
|
||||
var infoBtnEl = tile.querySelector(".tile-info-btn");
|
||||
if (infoBtnEl) {
|
||||
infoBtnEl.addEventListener("click", function(e) {
|
||||
e.stopPropagation();
|
||||
openCredsModal(svc.unit, svc.name);
|
||||
});
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
|
||||
// ── Render: live update ───────────────────────────────────────────
|
||||
|
||||
function updateTiles(services) {
|
||||
_servicesCache = services;
|
||||
for (var i = 0; i < services.length; i++) {
|
||||
var svc = services[i];
|
||||
if (svc.type === "support") continue;
|
||||
var id = CSS.escape(tileId(svc));
|
||||
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
||||
if (!tile) continue;
|
||||
var sc = statusClass(svc.status);
|
||||
var st = statusText(svc.status, svc.enabled);
|
||||
var dot = tile.querySelector(".status-dot");
|
||||
var text = tile.querySelector(".status-text");
|
||||
if (dot) dot.className = "status-dot " + sc;
|
||||
if (text) text.textContent = st;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Service polling ───────────────────────────────────────────────
|
||||
|
||||
var _firstLoad = true;
|
||||
|
||||
async function refreshServices() {
|
||||
try {
|
||||
var services = await apiFetch("/api/services");
|
||||
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
||||
else { updateTiles(services); }
|
||||
} catch (err) { console.warn("Failed to fetch services:", err); }
|
||||
}
|
||||
|
||||
// ── Network IPs ───────────────────────────────────────────────────
|
||||
|
||||
async function loadNetwork() {
|
||||
try {
|
||||
var data = await apiFetch("/api/network");
|
||||
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
|
||||
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
|
||||
_cachedExternalIp = data.external_ip || "unavailable";
|
||||
} catch (_) {
|
||||
if ($internalIp) $internalIp.textContent = "—";
|
||||
if ($externalIp) $externalIp.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update check ──────────────────────────────────────────────────
|
||||
|
||||
async function checkUpdates() {
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/check");
|
||||
var hasUpdates = !!data.available;
|
||||
if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates);
|
||||
if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Credentials info modal ────────────────────────────────────────
|
||||
|
||||
async function openCredsModal(unit, name) {
|
||||
if (!$credsModal) return;
|
||||
if ($credsTitle) $credsTitle.textContent = name + " — Connection Info";
|
||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||
$credsModal.classList.add("open");
|
||||
try {
|
||||
var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
|
||||
if (!data.credentials || data.credentials.length === 0) {
|
||||
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
|
||||
return;
|
||||
}
|
||||
var html = "";
|
||||
for (var i = 0; i < data.credentials.length; i++) {
|
||||
var cred = data.credentials[i];
|
||||
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||
var displayValue = linkify(cred.value);
|
||||
var qrBlock = "";
|
||||
if (cred.qrcode) {
|
||||
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
||||
}
|
||||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
|
||||
}
|
||||
$credsBody.innerHTML = html;
|
||||
$credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
var target = document.getElementById(btn.dataset.target);
|
||||
if (target) {
|
||||
navigator.clipboard.writeText(target.textContent).then(function() {
|
||||
btn.textContent = "Copied!";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500);
|
||||
}).catch(function() {});
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
$credsBody.innerHTML = '<p class="creds-empty">Could not load credentials.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }
|
||||
|
||||
// ── Tech Support modal ────────────────────────────────────────────
|
||||
|
||||
async function openSupportModal() {
|
||||
if (!$supportModal) return;
|
||||
$supportModal.classList.add("open");
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); }
|
||||
else { renderSupportInactive(); }
|
||||
} catch (err) {
|
||||
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSupportInactive() {
|
||||
stopSupportTimer();
|
||||
var ip = _cachedExternalIp || "loading…";
|
||||
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big">🛟</div><h3 class="support-heading">Need help from Sovran Systems?</h3><p class="support-desc">This will temporarily give Sovran Systems secure SSH access to your machine so we can diagnose and fix issues for you.</p><div class="support-info-box"><div class="support-info-row"><span class="support-info-label">Your External IP</span><span class="support-info-value" id="support-ext-ip">' + escHtml(ip) + '</span></div><p class="support-info-hint">Give this IP to your Sovran Systems technician when asked.</p></div><div class="support-steps"><p class="support-steps-title">What happens when you click Enable:</p><ol><li>A Sovran Systems SSH key is added to this machine</li><li>You give us your External IP shown above</li><li>We connect and help you remotely</li><li>When done, you click <strong>End Support Session</strong> to remove the key</li></ol></div><button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button><p class="support-fine-print">You can end the session at any time. The access key will be completely removed.</p></div>';
|
||||
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
|
||||
}
|
||||
|
||||
function renderSupportActive() {
|
||||
var ip = _cachedExternalIp || "loading…";
|
||||
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big support-active-icon">🔓</div><h3 class="support-heading support-active-heading">Support Access is Active</h3><p class="support-desc">Sovran Systems can currently connect to your machine via SSH.</p><div class="support-info-box support-active-box"><div class="support-info-row"><span class="support-info-label">Your External IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div><div class="support-info-row"><span class="support-info-label">Session Duration</span><span class="support-info-value" id="support-timer">—</span></div></div><p class="support-active-note">When your support session is complete, click the button below to <strong>immediately remove</strong> the access key.</p><button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button></div>';
|
||||
document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
|
||||
startSupportTimer();
|
||||
}
|
||||
|
||||
function renderSupportRemoved(verified) {
|
||||
stopSupportTimer();
|
||||
var icon = verified ? "✅" : "⚠️";
|
||||
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot your machine to be sure.";
|
||||
var vclass = verified ? "verified-gone" : "verify-warning";
|
||||
var vlabel = verified ? "✓ Removed — No access" : "⚠ Verify by rebooting";
|
||||
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big">' + icon + '</div><h3 class="support-heading">Support Session Ended</h3><p class="support-desc">' + escHtml(msg) + '</p><div class="support-verify-box"><span class="support-verify-label">SSH Key Status:</span><span class="support-verify-value ' + vclass + '">' + vlabel + '</span></div><button class="btn support-btn-done" id="btn-support-done">Done</button></div>';
|
||||
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
|
||||
}
|
||||
|
||||
async function enableSupport() {
|
||||
var btn = document.getElementById("btn-support-enable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/enable", { method: "POST" });
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportEnabledAt = status.enabled_at;
|
||||
renderSupportActive();
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||
alert("Failed to enable support access. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSupport() {
|
||||
var btn = document.getElementById("btn-support-disable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; }
|
||||
try {
|
||||
var result = await apiFetch("/api/support/disable", { method: "POST" });
|
||||
renderSupportRemoved(result.verified);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; }
|
||||
alert("Failed to disable support access. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function startSupportTimer() {
|
||||
stopSupportTimer();
|
||||
updateSupportTimer();
|
||||
_supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL);
|
||||
}
|
||||
|
||||
function stopSupportTimer() {
|
||||
if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; }
|
||||
}
|
||||
|
||||
function updateSupportTimer() {
|
||||
var el = document.getElementById("support-timer");
|
||||
if (!el || !_supportEnabledAt) return;
|
||||
var elapsed = (Date.now() / 1000) - _supportEnabledAt;
|
||||
el.textContent = formatDuration(Math.max(0, elapsed));
|
||||
}
|
||||
|
||||
function closeSupportModal() {
|
||||
if ($supportModal) $supportModal.classList.remove("open");
|
||||
stopSupportTimer();
|
||||
}
|
||||
|
||||
// ── Update modal ──────────────────────────────────────────────────
|
||||
|
||||
function openUpdateModal() {
|
||||
if (!$modal) return;
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
_serverWasDown = false;
|
||||
_updateFinished = false;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
$modal.classList.add("open");
|
||||
startUpdate();
|
||||
}
|
||||
|
||||
function closeUpdateModal() {
|
||||
if (!$modal) return;
|
||||
$modal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
}
|
||||
|
||||
function appendLog(text) {
|
||||
if (!text) return;
|
||||
_updateLog += text;
|
||||
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
|
||||
}
|
||||
|
||||
function startUpdate() {
|
||||
fetch("/api/updates/run", { method: "POST" })
|
||||
.then(function(response) {
|
||||
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
startUpdatePoll();
|
||||
})
|
||||
.catch(function(err) {
|
||||
appendLog("[Error: failed to start update — " + err + "]\n");
|
||||
onUpdateDone(false);
|
||||
});
|
||||
}
|
||||
|
||||
function startUpdatePoll() {
|
||||
pollUpdateStatus();
|
||||
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopUpdatePoll() {
|
||||
if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; }
|
||||
}
|
||||
|
||||
async function pollUpdateStatus() {
|
||||
if (_updateFinished) return;
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
||||
if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; }
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
if (data.running) return;
|
||||
_updateFinished = true;
|
||||
stopUpdatePoll();
|
||||
if (data.result === "success") onUpdateDone(true);
|
||||
else onUpdateDone(false);
|
||||
} catch (err) {
|
||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateDone(success) {
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
if (success) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
}
|
||||
}
|
||||
|
||||
function saveErrorReport() {
|
||||
var blob = new Blob([_updateLog], { type: "text/plain" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Reboot ────────────────────────────────────────────────────────
|
||||
|
||||
function doReboot() {
|
||||
if ($modal) $modal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
fetch("/api/reboot", { method: "POST" }).catch(function() {});
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
function waitForServerReboot() {
|
||||
fetch("/api/config", { cache: "no-store" })
|
||||
.then(function(res) {
|
||||
if (res.ok) window.location.reload();
|
||||
else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
})
|
||||
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
|
||||
}
|
||||
|
||||
// ── Event listeners ───────────────────────────────────────────────
|
||||
|
||||
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
|
||||
if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); });
|
||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||
|
||||
if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); });
|
||||
if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); });
|
||||
if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); });
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
var cfg = await apiFetch("/api/config");
|
||||
if (cfg.category_order) {
|
||||
for (var i = 0; i < cfg.category_order.length; i++) {
|
||||
_categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1];
|
||||
}
|
||||
}
|
||||
var badge = document.getElementById("role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
} catch (_) {}
|
||||
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
137
app/sovran_systemsos_web/static/css/base.css
Normal file
@@ -0,0 +1,137 @@
|
||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||
Dark theme matching the Adwaita dark aesthetic
|
||||
v6 — Status-only tiles (no controls) */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #1e1e2e;
|
||||
--surface-color: #2a2a3c;
|
||||
--card-color: #313244;
|
||||
--border-color: #45475a;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #a6adc8;
|
||||
--text-dim: #6c7086;
|
||||
--accent-color: #89b4fa;
|
||||
--green: #2ec27e;
|
||||
--yellow: #e5a50a;
|
||||
--red: #e01b24;
|
||||
--grey: #888888;
|
||||
--radius-card: 18px;
|
||||
--radius-btn: 8px;
|
||||
--shadow-card: 0 2px 8px rgba(0,0,0,0.4);
|
||||
--shadow-hover: 0 6px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Login page ──────────────────────────────────────────────────── */
|
||||
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background-color: rgba(224, 27, 36, 0.12);
|
||||
border: 1px solid var(--red);
|
||||
color: #f87171;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-error.visible {
|
||||
display: block;
|
||||
}
|
||||
86
app/sovran_systemsos_web/static/css/buttons.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* ── Buttons ────────────────────────────────────────────────────── */
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Update System button: BLUE by default */
|
||||
.btn-update {
|
||||
background-color: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-update:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Update System button: GREEN when updates are available */
|
||||
.btn-update.has-updates {
|
||||
background-color: #2ec27e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-update.has-updates:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
display: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--yellow);
|
||||
border-radius: 50%;
|
||||
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.update-badge.visible {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.35); }
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
173
app/sovran_systemsos_web/static/css/domain-setup.css
Normal file
@@ -0,0 +1,173 @@
|
||||
/* ── Domain setup modal ──────────────────────────────────────────── */
|
||||
|
||||
domain-narrow-dialog {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
domain-field-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
domain-field-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #12121c;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
domain-field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
domain-field-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Port Requirements modal ─────────────────────────────────────── */
|
||||
|
||||
.domain-narrow-dialog {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.domain-setup-intro {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.domain-setup-intro ol {
|
||||
padding-left: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.domain-setup-intro li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.domain-field-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.domain-field-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #12121c;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.domain-field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.domain-field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.domain-field-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.port-req-intro {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.port-req-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.port-req-internal-ip {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.port-req-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.port-req-table th {
|
||||
text-align: left;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.port-req-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.port-req-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.port-req-port {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.port-req-proto {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.port-req-desc {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.port-req-status {
|
||||
font-weight: 600;
|
||||
}
|
||||
143
app/sovran_systemsos_web/static/css/features.css
Normal file
@@ -0,0 +1,143 @@
|
||||
/* ── Feature Manager styles ──────────────────────────────────────── */
|
||||
|
||||
.feature-manager-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.feature-subcategory {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-subcategory-header {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.feature-cards-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.feature-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-card-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feature-card-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.feature-card-status {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.feature-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feature-toggle-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.feature-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.feature-toggle-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.feature-toggle.active .feature-toggle-slider {
|
||||
background-color: var(--green);
|
||||
}
|
||||
|
||||
.feature-toggle.active .feature-toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.feature-domain-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.feature-domain-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-domain-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.feature-domain-label--checking {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.feature-domain-label--ok {
|
||||
color: var(--green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-domain-label--warn {
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-domain-label--error {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
73
app/sovran_systemsos_web/static/css/header.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||
|
||||
.header-bar {
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 80px;
|
||||
width: auto;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-bar .title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||
|
||||
.ip-bar {
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ip-bar .ip-label {
|
||||
color: var(--text-dim);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.ip-bar .ip-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ip-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
190
app/sovran_systemsos_web/static/css/layout.css
Normal file
@@ -0,0 +1,190 @@
|
||||
/* ── Main content ───────────────────────────────────────────────── */
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 270px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: var(--surface-color);
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar: Tech Support button ───────────────────────────────── */
|
||||
|
||||
.sidebar-support-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-support-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
border-style: solid;
|
||||
background-color: #35354a;
|
||||
}
|
||||
|
||||
.sidebar-support-btn + .sidebar-support-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sidebar-support-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-support-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-support-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-support-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar: Upgrade button (Node role) ────────────────────────── */
|
||||
|
||||
.sidebar-upgrade-btn {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(137, 180, 250, 0.06);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sidebar-upgrade-btn:hover {
|
||||
background-color: rgba(137, 180, 250, 0.14);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.sidebar-upgrade-btn .sidebar-support-hint {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── Upgrade modal ──────────────────────────────────────────────── */
|
||||
|
||||
.upgrade-dialog {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.upgrade-info-box {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.upgrade-info-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upgrade-info-list {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upgrade-info-list a {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.upgrade-rebuild-note {
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── Tiles area ─────────────────────────────────────────────────── */
|
||||
|
||||
#tiles-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px 48px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Category sections ──────────────────────────────────────────── */
|
||||
|
||||
.category-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
432
app/sovran_systemsos_web/static/css/modals.css
Normal file
@@ -0,0 +1,432 @@
|
||||
/* ── Update modal ────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0,0,0,0.65);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2.5px solid var(--border-color);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-spinner.spinning {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.modal-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background-color: #12121c;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Reboot = GREEN */
|
||||
.modal-footer .btn-reboot,
|
||||
button.btn-reboot {
|
||||
background-color: #2ec27e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-footer .btn-reboot:hover:not(:disabled),
|
||||
button.btn-reboot:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: var(--yellow);
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background-color: #c98d08;
|
||||
}
|
||||
|
||||
.btn-close-modal {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-close-modal:hover:not(:disabled) {
|
||||
background-color: #5a5c72;
|
||||
}
|
||||
|
||||
/* ── Credentials info modal ──────────────────────────────────────── */
|
||||
|
||||
.creds-dialog {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 700px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||
animation: creds-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes creds-fade-in {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.creds-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 28px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.creds-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.creds-title-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creds-close-btn {
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.3rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.creds-close-btn:hover {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.creds-body {
|
||||
padding: 24px 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.creds-loading {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.creds-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.creds-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.creds-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.creds-value-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.creds-value {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
background-color: #12121c;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.creds-copy-btn {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.creds-copy-btn:hover {
|
||||
background-color: #5a5c72;
|
||||
}
|
||||
|
||||
.creds-copy-btn.copied {
|
||||
background-color: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.creds-empty {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* ── Credential links ────────────────────────────────────────────── */
|
||||
|
||||
.creds-link {
|
||||
color: #b8f0c0;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.creds-link:hover {
|
||||
text-decoration: underline;
|
||||
color: #defce6;
|
||||
}
|
||||
|
||||
/* ── Matrix action buttons ───────────────────────────────────────── */
|
||||
|
||||
.matrix-actions-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 18px 0 14px;
|
||||
}
|
||||
|
||||
.matrix-actions-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.matrix-action-btn {
|
||||
background-color: var(--accent-color);
|
||||
color: #0f0f19;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.matrix-action-btn:hover {
|
||||
background-color: #a8c8ff;
|
||||
}
|
||||
|
||||
.matrix-form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.matrix-form-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.matrix-form-input {
|
||||
width: 100%;
|
||||
background-color: #12121c;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.matrix-form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.matrix-form-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.matrix-form-checkbox-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.matrix-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.matrix-form-submit {
|
||||
background-color: var(--accent-color);
|
||||
color: #0f0f19;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.matrix-form-submit:hover:not(:disabled) {
|
||||
background-color: #a8c8ff;
|
||||
}
|
||||
|
||||
.matrix-form-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.matrix-form-back {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.matrix-form-back:hover {
|
||||
background-color: #5a5c72;
|
||||
}
|
||||
|
||||
.matrix-form-result {
|
||||
margin-top: 14px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.matrix-form-result.success {
|
||||
background-color: rgba(74, 222, 128, 0.12);
|
||||
border: 1px solid var(--green);
|
||||
color: var(--green);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.matrix-form-result.error {
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid #ef4444;
|
||||
color: #f87171;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── QR code in credentials modal ────────────────────────────────── */
|
||||
|
||||
.creds-qr-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.creds-qr-img {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 12px;
|
||||
border: 4px solid #fff;
|
||||
background-color: #fff;
|
||||
image-rendering: pixelated;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.creds-qr-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
816
app/sovran_systemsos_web/static/css/onboarding.css
Normal file
@@ -0,0 +1,816 @@
|
||||
/* ── Onboarding wizard ──────────────────────────────────────────── */
|
||||
|
||||
.onboarding-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-color);
|
||||
padding: 24px 16px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.onboarding-shell {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
|
||||
.onboarding-progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.onboarding-progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
/* Step indicator dots */
|
||||
|
||||
.onboarding-steps-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.onboarding-step-dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--card-color);
|
||||
border: 2px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.active {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.completed {
|
||||
background-color: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.onboarding-step-connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background-color: var(--border-color);
|
||||
min-width: 24px;
|
||||
max-width: 80px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
/* Panel wrapper and panels */
|
||||
|
||||
.onboarding-panel-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.onboarding-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
animation: panel-fade-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes panel-fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Hero / welcome section */
|
||||
|
||||
.onboarding-hero {
|
||||
text-align: center;
|
||||
padding: 16px 0 4px;
|
||||
}
|
||||
|
||||
.onboarding-logo {
|
||||
margin-bottom: 16px;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.onboarding-logo-img {
|
||||
height: 90px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.onboarding-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.onboarding-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
|
||||
.onboarding-card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Body text */
|
||||
|
||||
.onboarding-body-text {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.onboarding-body-text--dim {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Role row */
|
||||
|
||||
.onboarding-role-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.onboarding-role-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.onboarding-role-badge {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(137, 180, 250, 0.12);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(137, 180, 250, 0.3);
|
||||
}
|
||||
|
||||
/* Step header */
|
||||
|
||||
.onboarding-step-header {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.onboarding-step-icon {
|
||||
font-size: 2rem;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.onboarding-step-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.onboarding-step-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Footer / navigation */
|
||||
|
||||
.onboarding-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.onboarding-btn-next {
|
||||
/* inherits from .btn.btn-primary */
|
||||
}
|
||||
|
||||
.onboarding-btn-back {
|
||||
/* inherits from .btn.btn-close-modal */
|
||||
}
|
||||
|
||||
/* Domain configuration (Step 2) */
|
||||
|
||||
.onboarding-domain-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-domain-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.onboarding-domain-group--email {
|
||||
/* email-specific variant */
|
||||
}
|
||||
|
||||
.onboarding-domain-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.onboarding-domain-label--sub {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.onboarding-domain-input,
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-domain-input:focus,
|
||||
.domain-field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-hint--inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Port forwarding (Step 3) */
|
||||
|
||||
.onboarding-port-note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.onboarding-port-ip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-port-ip-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.onboarding-port-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.onboarding-port-section-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.onboarding-port-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.onboarding-port-table thead th {
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-port-table td {
|
||||
padding: 8px 8px;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.onboarding-port-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.onboarding-port-totals {
|
||||
padding: 10px 14px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.onboarding-port-warn {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-port-details {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.onboarding-port-details-summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.onboarding-port-details-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-port-details-summary::before {
|
||||
content: '▶ ';
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.onboarding-port-details[open] .onboarding-port-details-summary::before {
|
||||
content: '▼ ';
|
||||
}
|
||||
|
||||
/* Credentials (Step 4) */
|
||||
|
||||
.onboarding-creds-notice {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(137, 180, 250, 0.08);
|
||||
border: 1px solid rgba(137, 180, 250, 0.25);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.onboarding-creds-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.onboarding-creds-category:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.onboarding-creds-category-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.onboarding-creds-service {
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.onboarding-creds-service-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.onboarding-cred-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.onboarding-cred-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-cred-value {
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.onboarding-cred-secret {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.onboarding-cred-hidden {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.onboarding-cred-real {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-cred-reveal-btn {
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(137, 180, 250, 0.1);
|
||||
border: 1px solid rgba(137, 180, 250, 0.25);
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-cred-reveal-btn:hover {
|
||||
background-color: rgba(137, 180, 250, 0.2);
|
||||
}
|
||||
|
||||
/* Completion checklist (Step 5) */
|
||||
|
||||
.onboarding-checklist {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.onboarding-checklist li {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Status & feedback */
|
||||
|
||||
.onboarding-loading {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.onboarding-error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--red);
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(224, 27, 36, 0.1);
|
||||
border: 1px solid rgba(224, 27, 36, 0.3);
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-save-status {
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.2em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.onboarding-save-status--ok {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.onboarding-save-status--error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.onboarding-save-status--info {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Password step (Step 2) ─────────────────────────────────────── */
|
||||
|
||||
.onboarding-password-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.onboarding-password-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.onboarding-password-input {
|
||||
flex: 1;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.88rem;
|
||||
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.onboarding-password-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-password-toggle {
|
||||
padding: 6px 10px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-password-toggle:hover {
|
||||
background-color: rgba(137, 180, 250, 0.12);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.onboarding-password-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.onboarding-password-warning {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
line-height: 1.5;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.onboarding-password-success {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(166, 227, 161, 0.1);
|
||||
border: 1px solid rgba(166, 227, 161, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--green);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding-password-optional {
|
||||
margin-top: 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onboarding-password-optional > summary::before {
|
||||
content: '▶ ';
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.onboarding-password-optional[open] > summary::before {
|
||||
content: '▼ ';
|
||||
}
|
||||
|
||||
/* ── Reboot overlay ─────────────────────────────────────────────── */
|
||||
|
||||
.reboot-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(15, 15, 25, 0.92);
|
||||
z-index: 999;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reboot-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reboot-card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
|
||||
animation: reboot-fade-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes reboot-fade-in {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.reboot-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 16px;
|
||||
animation: reboot-spin 2s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes reboot-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.reboot-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reboot-message {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reboot-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reboot-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color);
|
||||
animation: reboot-bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.reboot-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.reboot-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes reboot-bounce {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.reboot-submessage {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 14px 12px;
|
||||
}
|
||||
#tiles-area {
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
padding: 16px 12px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header-bar {
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-bar .title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.ip-bar {
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.tiles-grid {
|
||||
justify-content: center;
|
||||
}
|
||||
.service-tile {
|
||||
width: 140px;
|
||||
min-height: 130px;
|
||||
}
|
||||
.reboot-card {
|
||||
padding: 36px 28px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
.creds-dialog {
|
||||
margin: 0 12px;
|
||||
}
|
||||
.creds-qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
107
app/sovran_systemsos_web/static/css/security.css
Normal file
@@ -0,0 +1,107 @@
|
||||
/* ── Legacy security inline warning banner ───────────────────────── */
|
||||
|
||||
.security-inline-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c97a00;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-inline-icon {
|
||||
font-size: 1rem;
|
||||
color: #e69000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.security-inline-text {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.security-inline-link {
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #e69000;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c97a00;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
align-self: flex-start;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.security-inline-link:hover {
|
||||
background-color: rgba(180, 100, 0, 0.22);
|
||||
}
|
||||
|
||||
/* ── System change-password form extras ──────────────────────────── */
|
||||
|
||||
.sys-chpw-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sys-chpw-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sys-chpw-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pw-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pw-input-wrap .matrix-form-input {
|
||||
padding-right: 2.4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pw-toggle-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.pw-toggle-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pw-hint {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pw-credentials-note {
|
||||
font-size: 0.78rem;
|
||||
color: #c97a00;
|
||||
background-color: rgba(180, 100, 0, 0.10);
|
||||
border-left: 2px solid #c97a00;
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
376
app/sovran_systemsos_web/static/css/support.css
Normal file
@@ -0,0 +1,376 @@
|
||||
/* ── Tech Support modal ──────────────────────────────────────────── */
|
||||
|
||||
.support-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-icon-big {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.support-active-icon {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.support-heading {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-active-heading {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.support-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.support-active-note {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.support-info-box {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.support-active-box {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.support-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.support-info-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-info-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.88rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-info-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.support-steps {
|
||||
text-align: left;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.support-steps-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-steps ol {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.support-steps code {
|
||||
background-color: rgba(137, 180, 250, 0.12);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.support-btn-enable {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.support-btn-enable:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.support-btn-disable {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--red);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.support-btn-disable:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.support-btn-done {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.support-btn-done:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.support-btn-auditlog {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.support-btn-auditlog:hover:not(:disabled) {
|
||||
background-color: #5a5c72;
|
||||
}
|
||||
|
||||
.support-fine-print {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-verify-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background-color: var(--card-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.support-verify-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-verify-value {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-verify-value.verified-gone {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.support-verify-value.verify-warning {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ── Wallet protection ───────────────────────────────────────────── */
|
||||
|
||||
.support-wallet-box {
|
||||
text-align: left;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.support-wallet-protected {
|
||||
background-color: rgba(46, 194, 126, 0.06);
|
||||
border-color: rgba(46, 194, 126, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-unlocked {
|
||||
background-color: rgba(229, 165, 10, 0.06);
|
||||
border-color: rgba(229, 165, 10, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-warning {
|
||||
background-color: rgba(224, 27, 36, 0.06);
|
||||
border-color: rgba(224, 27, 36, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-wallet-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.support-wallet-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.support-wallet-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-wallet-paths {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.support-wallet-paths li {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.support-wallet-unlock-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.support-unlock-select {
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.support-btn-wallet-unlock {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--yellow);
|
||||
color: #1e1e2e;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-btn-wallet-unlock:hover:not(:disabled) {
|
||||
background-color: #c98d08;
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--green);
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
}
|
||||
|
||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||
|
||||
.support-audit-container {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.support-audit-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #12121c;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.support-audit-entry {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.3);
|
||||
}
|
||||
|
||||
.support-audit-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.support-audit-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ── Tech Support tile ───────────────────────────────────────────── */
|
||||
|
||||
.support-tile {
|
||||
border-color: var(--border-color);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.support-tile:hover {
|
||||
border-color: var(--accent-color);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* ── Manual Backup ───────────────────────────────────────────────── */
|
||||
|
||||
.support-backup-steps {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.support-backup-steps li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
366
app/sovran_systemsos_web/static/css/tiles.css
Normal file
@@ -0,0 +1,366 @@
|
||||
/* ── Service tile card (status-only) ─────────────────────────────── */
|
||||
|
||||
.service-tile {
|
||||
width: 160px;
|
||||
min-height: 130px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 12px 18px;
|
||||
gap: 0;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.service-tile:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: #6c7086;
|
||||
}
|
||||
|
||||
.service-tile.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-icon-fallback {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
max-width: 140px;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
min-height: 1.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tile-status {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tile-version {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--grey);
|
||||
}
|
||||
|
||||
.status-dot.active { background-color: var(--green); }
|
||||
.status-dot.inactive { background-color: var(--red); }
|
||||
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
|
||||
.status-dot.failed { background-color: var(--red); }
|
||||
.status-dot.disabled { background-color: var(--grey); }
|
||||
.status-dot.needs-attention { background-color: var(--yellow); }
|
||||
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
|
||||
|
||||
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
|
||||
|
||||
.tile-sync-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tile-sync-label {
|
||||
font-size: 0.72rem;
|
||||
color: #f5a623;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tile-sync-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tile-sync-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-sync-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #f5a623;
|
||||
border-radius: 3px;
|
||||
transition: width 0.6s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.tile-sync-percent {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #f5a623;
|
||||
white-space: nowrap;
|
||||
min-width: 2.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tile-sync-eta {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Service detail modal sections ───────────────────────────────── */
|
||||
|
||||
.svc-detail-section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.svc-detail-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.svc-detail-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.svc-detail-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.svc-detail-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Service detail: Domain ──────────────────────────────────────── */
|
||||
|
||||
.svc-detail-domain-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-domain-label--ok {
|
||||
color: var(--green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-domain-label--warn {
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-domain-label--error {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Service detail: Port table ──────────────────────────────────── */
|
||||
|
||||
.svc-detail-port-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.svc-detail-port-table th {
|
||||
text-align: left;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.svc-detail-port-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.svc-detail-port-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.svc-detail-port-table-port {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.svc-detail-port-table-proto {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.svc-detail-port-table-desc {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.svc-detail-port-table-status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.port-status-listening { color: var(--green); }
|
||||
.port-status-open { color: var(--yellow); }
|
||||
.port-status-closed { color: var(--red); }
|
||||
.port-status-unknown { color: var(--text-dim); }
|
||||
|
||||
/* ── Service detail: Troubleshoot box ────────────────────────────── */
|
||||
|
||||
.svc-detail-troubleshoot {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
background-color: rgba(229, 165, 10, 0.08);
|
||||
border: 1px solid rgba(229, 165, 10, 0.3);
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot strong {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot ol {
|
||||
margin-top: 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot code {
|
||||
background-color: rgba(137, 180, 250, 0.12);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Service detail: Domain configure button ─────────────────────── */
|
||||
|
||||
.svc-detail-domain-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Service detail: Addon feature toggle ────────────────────────── */
|
||||
|
||||
.svc-detail-addon-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.svc-detail-addon-status {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.addon-status--on {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.addon-status--off {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.feature-conflict-warning {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* ── Desktop launch buttons ──────────────────────────────────────── */
|
||||
|
||||
.svc-detail-launch-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.svc-detail-launch-btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
236
app/sovran_systemsos_web/static/icons/update.svg
Normal file
@@ -0,0 +1,236 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="128px"
|
||||
height="128px"
|
||||
viewBox="0 0 128 128"
|
||||
version="1.1"
|
||||
id="svg96"
|
||||
sodipodi:docname="Sovran_SystemsOS_Updater_Iconv3.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview98"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.2149125"
|
||||
inkscape:cx="9.0126153"
|
||||
inkscape:cy="64.430611"
|
||||
inkscape:window-width="3440"
|
||||
inkscape:window-height="1352"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer2" /><defs
|
||||
id="defs67"><linearGradient
|
||||
inkscape:collect="always"
|
||||
id="linearGradient936"><stop
|
||||
style="stop-color:#1e8e11;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop932" /><stop
|
||||
style="stop-color:#1bff00;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop934" /></linearGradient><linearGradient
|
||||
id="linearGradient1028"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1026" /></linearGradient><linearGradient
|
||||
id="linearGradient998"
|
||||
inkscape:swatch="solid"><stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop996" /></linearGradient><radialGradient
|
||||
id="radial0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.232034,-0.541475,-0.368794,-0.0298398,4.277749,118.95849)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#00ff39;stop-opacity:1;"
|
||||
id="stop2" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#004a19;stop-opacity:1;"
|
||||
id="stop4" /></radialGradient><radialGradient
|
||||
id="radial1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.107656,-0.225172,-0.327748,0.258343,373.87973,30.205086)"><stop
|
||||
offset="0"
|
||||
style="stop-color:#43b60b;stop-opacity:1;"
|
||||
id="stop7" /><stop
|
||||
offset="1"
|
||||
style="stop-color:#0b88ff;stop-opacity:0.00829875;"
|
||||
id="stop9" /></radialGradient><clipPath
|
||||
id="clip1"><path
|
||||
d="M 7 46 L 57 46 L 57 93 L 7 93 Z M 7 46 "
|
||||
id="path12" /></clipPath><clipPath
|
||||
id="clip2"><path
|
||||
d="M 32.25 46.957031 C 19.6875 46.96875 9.085938 56.636719 7.503906 69.53125 C 9.0625 82.445312 19.667969 92.144531 32.25 92.160156 C 44.816406 92.148438 55.414062 82.480469 57 69.585938 C 55.441406 56.671875 44.835938 46.972656 32.25 46.957031 Z M 32.25 46.957031 "
|
||||
id="path15" /></clipPath><radialGradient
|
||||
id="radial2"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="131.914749"
|
||||
cy="55.927143"
|
||||
fx="131.914749"
|
||||
fy="55.927143"
|
||||
r="160"
|
||||
gradientTransform="matrix(0.485163,-1.148584,-0.771115,-0.0632965,-47.124961,203.98857)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(92.941177%,20%,23.137255%);stop-opacity:1;"
|
||||
id="stop18" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(63.921571%,27.843139%,72.941178%);stop-opacity:1;"
|
||||
id="stop20" /></radialGradient><radialGradient
|
||||
id="radial3"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="525.587769"
|
||||
cy="638.591797"
|
||||
fx="525.587769"
|
||||
fy="638.591797"
|
||||
r="192"
|
||||
gradientTransform="matrix(-0.225099,-0.477638,-0.685291,0.548001,725.67923,15.723794)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(10.980392%,44.313726%,84.705883%);stop-opacity:1;"
|
||||
id="stop23" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(20.784314%,51.764709%,89.411765%);stop-opacity:0.00829876;"
|
||||
id="stop25" /></radialGradient><linearGradient
|
||||
id="linear0"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="22"
|
||||
y1="37"
|
||||
x2="62"
|
||||
y2="37"
|
||||
gradientTransform="matrix(1.4,0,0,1.4,-26.799973,2.491745)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(58.039218%,57.647061%,56.470591%);stop-opacity:1;"
|
||||
id="stop28" /><stop
|
||||
offset="0.0908155"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop30" /><stop
|
||||
offset="0.336093"
|
||||
style="stop-color:rgb(60.392159%,60.000002%,58.823532%);stop-opacity:1;"
|
||||
id="stop32" /><stop
|
||||
offset="0.844326"
|
||||
style="stop-color:rgb(76.47059%,75.294119%,72.941178%);stop-opacity:1;"
|
||||
id="stop34" /><stop
|
||||
offset="0.930505"
|
||||
style="stop-color:rgb(87.058824%,86.666667%,85.490197%);stop-opacity:1;"
|
||||
id="stop36" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(75.294119%,74.901962%,73.725492%);stop-opacity:1;"
|
||||
id="stop38" /></linearGradient><radialGradient
|
||||
id="radial4"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="-172.560638"
|
||||
cy="28.569126"
|
||||
fx="-172.560638"
|
||||
fy="28.569126"
|
||||
r="15.85742"
|
||||
gradientTransform="matrix(1.560712,0,0,1.4252,300.69366,13.349996)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.358268;"
|
||||
id="stop41" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:0.0944882;"
|
||||
id="stop43" /></radialGradient><filter
|
||||
id="alpha"
|
||||
filterUnits="objectBoundingBox"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"><feColorMatrix
|
||||
type="matrix"
|
||||
in="SourceGraphic"
|
||||
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"
|
||||
id="feColorMatrix46" /></filter><mask
|
||||
id="mask0"><g
|
||||
filter="url(#alpha)"
|
||||
id="g51"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="128"
|
||||
height="128"
|
||||
style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"
|
||||
id="rect49" /></g></mask><clipPath
|
||||
id="clip3"><rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="192"
|
||||
height="152"
|
||||
id="rect54" /></clipPath><g
|
||||
id="surface382"
|
||||
clip-path="url(#clip3)"><path
|
||||
style=" stroke:none;fill-rule:nonzero;fill:rgb(27.058825%,21.176471%,21.568628%);fill-opacity:1;"
|
||||
d="M 40 59.957031 C 26.191406 59.957031 15 71.152344 15 84.957031 C 15.011719 85.996094 15.085938 86.777344 15.222656 87.804688 C 15.222656 75.957031 27.421875 65.96875 40 65.957031 C 52.597656 65.972656 64.777344 75.957031 64.777344 87.859375 C 64.917969 86.816406 64.992188 86.011719 65 84.957031 C 65 71.152344 53.808594 59.957031 40 59.957031 Z M 40 59.957031 "
|
||||
id="path57" /></g><radialGradient
|
||||
id="radial5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="40"
|
||||
cy="227"
|
||||
fx="40"
|
||||
fy="227"
|
||||
r="28"
|
||||
gradientTransform="matrix(0.575553,0,1.60551e-8,1.540703,8.977913,-280.78108)"><stop
|
||||
offset="0"
|
||||
style="stop-color:rgb(100%,100%,100%);stop-opacity:1;"
|
||||
id="stop60" /><stop
|
||||
offset="0.744626"
|
||||
style="stop-color:rgb(98.039216%,98.039216%,98.039216%);stop-opacity:1;"
|
||||
id="stop62" /><stop
|
||||
offset="1"
|
||||
style="stop-color:rgb(87.450981%,87.450981%,87.450981%);stop-opacity:1;"
|
||||
id="stop64" /></radialGradient>
|
||||
|
||||
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#linearGradient936"
|
||||
id="linearGradient938"
|
||||
x1="-48.519272"
|
||||
y1="18.511358"
|
||||
x2="287.07454"
|
||||
y2="18.511358"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(1.1020247,0,0,1.1097375,37.198581,-10.424856)" /></defs><path
|
||||
style="fill:#f5f5f3;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 20,11.957031 h 88 c 4.41797,0 8,3.582031 8,8 V 108 c 0,4.41797 -3.58203,8 -8,8 H 20 c -4.417969,0 -8,-3.58203 -8,-8 V 19.957031 c 0,-4.417969 3.582031,-8 8,-8 z m 0,0"
|
||||
id="path69" /><path
|
||||
style="fill:url(#radial0);fill-rule:nonzero;stroke:none"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path71" /><path
|
||||
style="fill:none;fill-rule:nonzero;stroke:none;fill-opacity:1"
|
||||
d="m 20,85.957031 h 88 v -66 H 20 Z m 0,0"
|
||||
id="path73" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 1"
|
||||
transform="matrix(0.1816,0,0,0.1816,35.224187,79.037164)"><ellipse
|
||||
fill="#54c147"
|
||||
cx="168.64549"
|
||||
cy="10.117889"
|
||||
id="circle8314"
|
||||
rx="184.91634"
|
||||
ry="179.91556"
|
||||
style="fill:url(#linearGradient938);fill-opacity:1;stroke-width:1.71591" /><polygon
|
||||
fill="#ffffff"
|
||||
points="46.678,120.299 63.562,96.402 96.977,121.718 145.084,50.79 168.752,69.02 103.583,164.647 "
|
||||
id="polygon8316"
|
||||
transform="matrix(1.7395866,0,0,1.6925423,-18.737581,-172.19767)" /></g></svg>
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
31
app/sovran_systemsos_web/static/js/constants.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
||||
v7 — Status-only dashboard + Tech Support + Feature Manager */
|
||||
"use strict";
|
||||
|
||||
const POLL_INTERVAL_SERVICES = 5000;
|
||||
const POLL_INTERVAL_UPDATES = 1800000;
|
||||
const UPDATE_POLL_INTERVAL = 2000;
|
||||
const REBOOT_CHECK_INTERVAL = 5000;
|
||||
const SUPPORT_TIMER_INTERVAL = 1000;
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"infrastructure",
|
||||
"bitcoin-base",
|
||||
"bitcoin-apps",
|
||||
"communication",
|
||||
"apps",
|
||||
"nostr",
|
||||
];
|
||||
|
||||
const FEATURE_SUBCATEGORY_LABELS = {
|
||||
"infrastructure": "🔧 Infrastructure",
|
||||
"bitcoin": "₿ Bitcoin",
|
||||
"communication": "💬 Communication",
|
||||
"nostr": "📡 Nostr",
|
||||
};
|
||||
|
||||
const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"];
|
||||
|
||||
const STATUS_LOADING_STATES = new Set([
|
||||
"reloading", "activating", "deactivating", "maintenance",
|
||||
]);
|
||||
122
app/sovran_systemsos_web/static/js/events.js
Normal file
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
// ── Event listeners ───────────────────────────────────────────────
|
||||
|
||||
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
|
||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||
|
||||
// Rebuild modal
|
||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
||||
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
||||
|
||||
// Domain setup modal
|
||||
if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal);
|
||||
if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); });
|
||||
|
||||
// SSL Email modal
|
||||
if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal);
|
||||
if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal);
|
||||
if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); });
|
||||
|
||||
// Feature confirm modal
|
||||
if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm);
|
||||
if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm);
|
||||
if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); });
|
||||
|
||||
if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); });
|
||||
if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); });
|
||||
if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); });
|
||||
|
||||
// Upgrade modal
|
||||
if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeModal);
|
||||
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
||||
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
|
||||
|
||||
// ── Upgrade modal functions ───────────────────────────────────────
|
||||
|
||||
function openUpgradeModal() {
|
||||
if ($upgradeModal) $upgradeModal.classList.add("open");
|
||||
}
|
||||
|
||||
function closeUpgradeModal() {
|
||||
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
||||
}
|
||||
|
||||
async function doUpgradeToServer() {
|
||||
var confirmBtn = $upgradeConfirmBtn;
|
||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
||||
closeUpgradeModal();
|
||||
|
||||
// Reuse the rebuild modal to show progress
|
||||
_rebuildFeatureName = "Server + Desktop";
|
||||
_rebuildIsEnabling = true;
|
||||
openRebuildModal();
|
||||
|
||||
try {
|
||||
await apiFetch("/api/role/upgrade-to-server", { method: "POST" });
|
||||
} catch (err) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message;
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = "Yes, Upgrade"; }
|
||||
}
|
||||
}
|
||||
|
||||
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer);
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
// Check onboarding status first — redirect to wizard if not complete
|
||||
try {
|
||||
var onboardingStatus = await apiFetch("/api/onboarding/status");
|
||||
if (!onboardingStatus.complete) {
|
||||
window.location.href = "/onboarding";
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// If we can't reach the endpoint, continue to normal dashboard
|
||||
}
|
||||
|
||||
// Check for legacy machine security warning
|
||||
await checkLegacySecurity();
|
||||
|
||||
try {
|
||||
var cfg = await apiFetch("/api/config");
|
||||
_currentRole = cfg.role || "server_plus_desktop";
|
||||
if (cfg.category_order) {
|
||||
for (var i = 0; i < cfg.category_order.length; i++) {
|
||||
_categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1];
|
||||
}
|
||||
}
|
||||
var badge = document.getElementById("role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
|
||||
if (cfg.feature_manager) {
|
||||
loadFeatureManager();
|
||||
}
|
||||
loadAutolaunchToggle();
|
||||
} catch (_) {
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
loadAutolaunchToggle();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
664
app/sovran_systemsos_web/static/js/features.js
Normal file
@@ -0,0 +1,664 @@
|
||||
"use strict";
|
||||
|
||||
// ── Feature confirm modal ─────────────────────────────────────────
|
||||
|
||||
function openFeatureConfirm(message, onConfirm) {
|
||||
if (!$featureConfirmModal) return;
|
||||
if ($featureConfirmMsg) $featureConfirmMsg.textContent = message;
|
||||
$featureConfirmModal.classList.add("open");
|
||||
// Replace ok handler
|
||||
var newOk = $featureConfirmOk.cloneNode(true);
|
||||
$featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk);
|
||||
newOk.addEventListener("click", function() {
|
||||
closeFeatureConfirm();
|
||||
onConfirm();
|
||||
});
|
||||
}
|
||||
|
||||
function closeFeatureConfirm() {
|
||||
if ($featureConfirmModal) $featureConfirmModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── SSL Email modal ───────────────────────────────────────────────
|
||||
|
||||
function openSslEmailModal(onSaved) {
|
||||
if (!$sslEmailModal) return;
|
||||
if ($sslEmailInput) $sslEmailInput.value = "";
|
||||
$sslEmailModal.classList.add("open");
|
||||
// Replace save handler
|
||||
var newSave = $sslEmailSave.cloneNode(true);
|
||||
$sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave);
|
||||
newSave.addEventListener("click", async function() {
|
||||
var email = $sslEmailInput ? $sslEmailInput.value.trim() : "";
|
||||
if (!email) { alert("Please enter an email address."); return; }
|
||||
newSave.disabled = true;
|
||||
newSave.textContent = "Saving…";
|
||||
try {
|
||||
await apiFetch("/api/domains/set-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email }),
|
||||
});
|
||||
closeSslEmailModal();
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
newSave.disabled = false;
|
||||
newSave.textContent = "Save";
|
||||
alert("Failed to save email. Please try again.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeSslEmailModal() {
|
||||
if ($sslEmailModal) $sslEmailModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Domain Setup modal ────────────────────────────────────────────
|
||||
|
||||
function openDomainSetupModal(feat, onSaved) {
|
||||
if (!$domainSetupModal) return;
|
||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||
|
||||
var npubField = "";
|
||||
if (feat.id === "haven") {
|
||||
var currentNpub = "";
|
||||
if (feat.extra_fields && feat.extra_fields.length > 0) {
|
||||
for (var i = 0; i < feat.extra_fields.length; i++) {
|
||||
if (feat.extra_fields[i].id === "nostr_npub") {
|
||||
currentNpub = feat.extra_fields[i].current_value || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
'<p><strong>Before continuing:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' +
|
||||
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
|
||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||
'<li>Njal.la will give you a curl command like:<br>' +
|
||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>' +
|
||||
'<li>Enter the subdomain and paste that curl command below</li>' +
|
||||
'</ol>' +
|
||||
'</div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=myservice.example.com&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
||||
npubField +
|
||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||
|
||||
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
|
||||
|
||||
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
|
||||
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
|
||||
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
|
||||
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
|
||||
subdomain = subdomain.trim();
|
||||
ddnsUrl = ddnsUrl.trim();
|
||||
npub = npub.trim();
|
||||
|
||||
if (!subdomain) { alert("Please enter a subdomain."); return; }
|
||||
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
|
||||
|
||||
var saveBtn = document.getElementById("domain-setup-save-btn");
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/domains/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
domain_name: feat.domain_name,
|
||||
domain: subdomain,
|
||||
ddns_url: ddnsUrl,
|
||||
}),
|
||||
});
|
||||
closeDomainSetupModal();
|
||||
onSaved(npub);
|
||||
} catch (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Enable";
|
||||
alert("Failed to save domain. Please try again.");
|
||||
}
|
||||
});
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
}
|
||||
|
||||
function closeDomainSetupModal() {
|
||||
if ($domainSetupModal) $domainSetupModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Port Requirements modal ───────────────────────────────────────
|
||||
|
||||
function openPortRequirementsModal(featureName, ports, onContinue) {
|
||||
if (!$portReqModal || !$portReqBody) return;
|
||||
|
||||
var continueBtn = onContinue
|
||||
? '<button class="btn btn-primary" id="port-req-continue-btn">I Understand — Continue</button>'
|
||||
: '';
|
||||
|
||||
// Show loading state while fetching port status
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro">Checking port status for <strong>' + escHtml(featureName) + '</strong>…</p>' +
|
||||
'<p class="port-req-hint">Detecting which ports are open on this machine…</p>';
|
||||
|
||||
$portReqModal.classList.add("open");
|
||||
|
||||
// Fetch live port status from local system commands (no external calls)
|
||||
fetch("/api/ports/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ports: ports }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
|
||||
? data.internal_ip : null;
|
||||
var portStatuses = {};
|
||||
(data.ports || []).forEach(function(p) {
|
||||
portStatuses[p.port + "/" + p.protocol] = p.status;
|
||||
});
|
||||
|
||||
var rows = ports.map(function(p) {
|
||||
var key = p.port + "/" + p.protocol;
|
||||
var status = portStatuses[key] || "unknown";
|
||||
var statusHtml;
|
||||
if (status === "listening") {
|
||||
statusHtml = '<span class="port-status-listening" title="Service is running and firewall allows this port">🟢 Listening</span>';
|
||||
} else if (status === "firewall_open") {
|
||||
statusHtml = '<span class="port-status-open" title="Firewall allows this port but no service is bound yet">🟡 Open (idle)</span>';
|
||||
} else if (status === "closed") {
|
||||
statusHtml = '<span class="port-status-closed" title="Firewall blocks this port and/or nothing is listening">🔴 Closed</span>';
|
||||
} else {
|
||||
statusHtml = '<span class="port-status-unknown" title="Status could not be determined">⚪ Unknown</span>';
|
||||
}
|
||||
return '<tr>' +
|
||||
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td>' +
|
||||
'<td class="port-req-status">' + statusHtml + '</td>' +
|
||||
'</tr>';
|
||||
}).join("");
|
||||
|
||||
var ipLine = internalIp
|
||||
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
|
||||
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
|
||||
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
|
||||
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
|
||||
ipLine +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th><th>Status</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
|
||||
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
|
||||
"or check your router's port forwarding page.</p>" +
|
||||
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
'</div>';
|
||||
|
||||
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
});
|
||||
|
||||
if (onContinue) {
|
||||
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
onContinue();
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
// Fallback: show static table without status column if fetch fails
|
||||
var rows = ports.map(function(p) {
|
||||
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
|
||||
}).join("");
|
||||
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> to work with clients outside your local network, ' +
|
||||
'you must configure <strong>port forwarding</strong> in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.</p>' +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
'</div>';
|
||||
|
||||
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
});
|
||||
|
||||
if (onContinue) {
|
||||
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
|
||||
closePortRequirementsModal();
|
||||
onContinue();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closePortRequirementsModal() {
|
||||
if ($portReqModal) $portReqModal.classList.remove("open");
|
||||
}
|
||||
|
||||
if ($portReqClose) {
|
||||
$portReqClose.addEventListener("click", closePortRequirementsModal);
|
||||
}
|
||||
|
||||
// ── Feature toggle logic ──────────────────────────────────────────
|
||||
|
||||
async function performFeatureToggle(featId, enabled, extra) {
|
||||
// Look up feature name for the rebuild modal
|
||||
_rebuildIsEnabling = enabled;
|
||||
_rebuildFeatureName = featId;
|
||||
if (_featuresData) {
|
||||
var found = _featuresData.features.find(function(f) { return f.id === featId; });
|
||||
if (found) _rebuildFeatureName = found.name;
|
||||
}
|
||||
try {
|
||||
var res = await fetch("/api/features/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }),
|
||||
});
|
||||
var body = await res.json();
|
||||
if (!res.ok) {
|
||||
if (body && body.error === "domain_required") {
|
||||
alert("Domain not configured for this feature. Please configure it first.");
|
||||
} else {
|
||||
alert("Error: " + (body.detail || body.error || "Unknown error"));
|
||||
}
|
||||
loadFeatureManager();
|
||||
return;
|
||||
}
|
||||
openRebuildModal();
|
||||
} catch (err) {
|
||||
alert("Failed to toggle feature: " + err);
|
||||
loadFeatureManager();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFeatureToggle(feat, newEnabled) {
|
||||
if (!newEnabled) {
|
||||
// Disable: ask confirmation
|
||||
openFeatureConfirm(
|
||||
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
||||
function() { performFeatureToggle(feat.id, false, {}); }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enabling
|
||||
var conflictNames = [];
|
||||
if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) {
|
||||
feat.conflicts_with.forEach(function(cid) {
|
||||
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||
if (cf && cf.enabled) conflictNames.push(cf.name);
|
||||
});
|
||||
}
|
||||
|
||||
function proceedAfterPortCheck() {
|
||||
// Check SSL email first
|
||||
if (!_featuresData || !_featuresData.ssl_email_configured) {
|
||||
if (feat.needs_domain) {
|
||||
openSslEmailModal(function() {
|
||||
// After ssl email saved, check domain
|
||||
checkDomainAndEnable(feat, {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (feat.needs_domain && !feat.domain_configured) {
|
||||
checkDomainAndEnable(feat, {});
|
||||
return;
|
||||
}
|
||||
if (feat.id === "haven") {
|
||||
var npub = "";
|
||||
if (feat.extra_fields) {
|
||||
var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; });
|
||||
if (ef) npub = ef.current_value || "";
|
||||
}
|
||||
if (!npub) {
|
||||
// Need to collect npub via domain modal
|
||||
openDomainSetupModal(feat, function(collectedNpub) {
|
||||
performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub });
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
performFeatureToggle(feat.id, true, {});
|
||||
}
|
||||
|
||||
function proceedAfterConflictCheck() {
|
||||
// Show port requirements notification if the feature has extra port needs
|
||||
var ports = feat.port_requirements || [];
|
||||
if (ports.length > 0) {
|
||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||
} else {
|
||||
proceedAfterPortCheck();
|
||||
}
|
||||
}
|
||||
|
||||
if (conflictNames.length > 0) {
|
||||
var confirmMsg;
|
||||
if (feat.id === "bip110") {
|
||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?";
|
||||
} else if (feat.id === "bitcoin-core") {
|
||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?";
|
||||
} else {
|
||||
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
|
||||
}
|
||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||
} else {
|
||||
proceedAfterConflictCheck();
|
||||
}
|
||||
}
|
||||
|
||||
function checkDomainAndEnable(feat, extra) {
|
||||
openDomainSetupModal(feat, function(collectedNpub) {
|
||||
var extraData = {};
|
||||
if (collectedNpub) extraData.nostr_npub = collectedNpub;
|
||||
performFeatureToggle(feat.id, true, extraData);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Feature Manager rendering ─────────────────────────────────────
|
||||
|
||||
async function loadFeatureManager() {
|
||||
try {
|
||||
var data = await apiFetch("/api/features");
|
||||
_featuresData = data;
|
||||
// Feature Manager is now integrated into tile modals; sidebar rendering removed.
|
||||
} catch (err) {
|
||||
console.warn("Failed to load features:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function _checkFeatureManagerDomains(data) {
|
||||
// Collect all features with a configured domain
|
||||
var featsWithDomain = (data.features || []).filter(function(f) {
|
||||
return f.needs_domain && f.domain_configured;
|
||||
});
|
||||
if (!featsWithDomain.length) return;
|
||||
|
||||
// Get the actual domain values from /api/domains/status, then check them
|
||||
fetch("/api/domains/status")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(statusData) {
|
||||
var domainFileMap = statusData.domains || {};
|
||||
// Build list of domains to check and a map from domain value → feature id
|
||||
var domainsToCheck = [];
|
||||
var domainToFeatIds = {};
|
||||
featsWithDomain.forEach(function(feat) {
|
||||
var domainName = feat.domain_name;
|
||||
var domainVal = domainName ? domainFileMap[domainName] : null;
|
||||
if (domainVal) {
|
||||
domainsToCheck.push(domainVal);
|
||||
if (!domainToFeatIds[domainVal]) domainToFeatIds[domainVal] = [];
|
||||
domainToFeatIds[domainVal].push(feat.id);
|
||||
} else {
|
||||
// Domain file missing — update badge to warn
|
||||
_updateFeatureDomainBadge(feat.id, null, "unresolvable");
|
||||
}
|
||||
});
|
||||
|
||||
if (!domainsToCheck.length) return;
|
||||
|
||||
return fetch("/api/domains/check", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domains: domainsToCheck }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(checkData) {
|
||||
(checkData.domains || []).forEach(function(d) {
|
||||
var featIds = domainToFeatIds[d.domain] || [];
|
||||
featIds.forEach(function(featId) {
|
||||
_updateFeatureDomainBadge(featId, d.domain, d.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function _updateFeatureDomainBadge(featId, domainVal, status) {
|
||||
var section = $sidebarFeatures.querySelector(".feature-manager-section");
|
||||
if (!section) return;
|
||||
// Find the card — cards don't have a data-feat-id, so find via name match
|
||||
var badges = section.querySelectorAll(".feature-domain-badge.configured");
|
||||
badges.forEach(function(badge) {
|
||||
var domainNameAttr = badge.getAttribute("data-domain-name");
|
||||
// Match by domain_name attribute — we need to look up the feat's domain_name
|
||||
var feat = _featuresData && _featuresData.features
|
||||
? _featuresData.features.find(function(f) { return f.id === featId; })
|
||||
: null;
|
||||
if (!feat) return;
|
||||
if (domainNameAttr !== (feat.domain_name || "")) return;
|
||||
|
||||
var lbl = badge.querySelector(".feature-domain-label");
|
||||
if (!lbl) return;
|
||||
lbl.classList.remove("feature-domain-label--checking");
|
||||
if (status === "connected") {
|
||||
lbl.className = "feature-domain-label feature-domain-label--ok";
|
||||
lbl.textContent = (domainVal || "Domain") + " ✓";
|
||||
} else if (status === "dns_mismatch") {
|
||||
lbl.className = "feature-domain-label feature-domain-label--warn";
|
||||
lbl.textContent = (domainVal || "Domain") + " (IP mismatch)";
|
||||
} else if (status === "unresolvable") {
|
||||
lbl.className = "feature-domain-label feature-domain-label--error";
|
||||
lbl.textContent = (domainVal || "Domain") + " (DNS error)";
|
||||
} else {
|
||||
lbl.className = "feature-domain-label feature-domain-label--warn";
|
||||
lbl.textContent = (domainVal || "Domain") + " (unknown)";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderFeatureManager(data) {
|
||||
// Remove old feature manager section if it exists
|
||||
var old = $sidebarFeatures.querySelector(".feature-manager-section");
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section feature-manager-section";
|
||||
section.dataset.category = "feature-manager";
|
||||
section.innerHTML = '<div class="section-header">Feature Manager</div><hr class="section-divider" />';
|
||||
|
||||
// Group by sub-category
|
||||
var grouped = {};
|
||||
for (var i = 0; i < data.features.length; i++) {
|
||||
var f = data.features[i];
|
||||
var cat = f.category || "other";
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(f);
|
||||
}
|
||||
|
||||
var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
||||
Object.keys(grouped).forEach(function(k) {
|
||||
if (orderedCats.indexOf(k) === -1) orderedCats.push(k);
|
||||
});
|
||||
|
||||
for (var j = 0; j < orderedCats.length; j++) {
|
||||
var catKey = orderedCats[j];
|
||||
var feats = grouped[catKey];
|
||||
if (!feats || feats.length === 0) continue;
|
||||
|
||||
var subcat = document.createElement("div");
|
||||
subcat.className = "feature-subcategory";
|
||||
var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey;
|
||||
subcat.innerHTML = '<div class="feature-subcategory-header">' + escHtml(subcatLabel) + '</div>';
|
||||
|
||||
var cardsWrap = document.createElement("div");
|
||||
cardsWrap.className = "feature-cards-wrap";
|
||||
|
||||
for (var k = 0; k < feats.length; k++) {
|
||||
cardsWrap.appendChild(buildFeatureCard(feats[k]));
|
||||
}
|
||||
subcat.appendChild(cardsWrap);
|
||||
section.appendChild(subcat);
|
||||
}
|
||||
|
||||
$sidebarFeatures.appendChild(section);
|
||||
}
|
||||
|
||||
function buildFeatureCard(feat) {
|
||||
var card = document.createElement("div");
|
||||
card.className = "feature-card";
|
||||
|
||||
var conflictHtml = "";
|
||||
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
|
||||
var conflictNames = feat.conflicts_with.map(function(cid) {
|
||||
if (!_featuresData) return cid;
|
||||
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||
return cf ? cf.name : cid;
|
||||
});
|
||||
conflictHtml = '<div class="feature-conflict-warning">⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||||
}
|
||||
|
||||
var domainHtml = "";
|
||||
if (feat.needs_domain) {
|
||||
if (feat.domain_configured) {
|
||||
domainHtml = '<div class="feature-domain-badge configured" data-domain-name="' + escHtml(feat.domain_name || '') + '">'
|
||||
+ '<span class="feature-domain-icon">🌐</span>'
|
||||
+ '<span class="feature-domain-label feature-domain-label--checking">Domain: Checking\u2026</span>'
|
||||
+ '</div>';
|
||||
} else {
|
||||
domainHtml = '<div class="feature-domain-badge not-configured">'
|
||||
+ '<span class="feature-domain-icon">🌐</span>'
|
||||
+ '<span class="feature-domain-label feature-domain-label--warn">Domain: Not configured</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
var statusText = feat.enabled ? "Enabled" : "Disabled";
|
||||
|
||||
card.innerHTML =
|
||||
'<div class="feature-card-top">' +
|
||||
'<div class="feature-card-info">' +
|
||||
'<div class="feature-card-name">' + escHtml(feat.name) + '</div>' +
|
||||
'<div class="feature-card-desc">' + escHtml(feat.description) + '</div>' +
|
||||
'</div>' +
|
||||
'<label class="feature-toggle' + (feat.enabled ? " active" : "") + '" title="Toggle ' + escHtml(feat.name) + '">' +
|
||||
'<input type="checkbox" class="feature-toggle-input"' + (feat.enabled ? " checked" : "") + ' />' +
|
||||
'<span class="feature-toggle-slider"></span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
domainHtml +
|
||||
conflictHtml +
|
||||
'<div class="feature-card-status">Status: ' + escHtml(statusText) + '</div>';
|
||||
|
||||
var toggle = card.querySelector(".feature-toggle-input");
|
||||
var toggleLabel = card.querySelector(".feature-toggle");
|
||||
toggle.addEventListener("change", function() {
|
||||
var newEnabled = toggle.checked;
|
||||
// Revert visually until confirmed
|
||||
toggle.checked = feat.enabled;
|
||||
if (newEnabled) { toggleLabel.classList.remove("active"); } else { toggleLabel.classList.add("active"); }
|
||||
handleFeatureToggle(feat, newEnabled);
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Auto-launch toggle ────────────────────────────────────────────
|
||||
|
||||
async function loadAutolaunchToggle() {
|
||||
try {
|
||||
var data = await apiFetch("/api/autolaunch/status");
|
||||
renderAutolaunchToggle(data.enabled);
|
||||
} catch (err) {
|
||||
console.warn("Failed to load autolaunch status:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutolaunchToggle(enabled) {
|
||||
// Remove existing section if any
|
||||
var old = $sidebarFeatures.querySelector(".autolaunch-section");
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section autolaunch-section";
|
||||
|
||||
var securityBanner = "";
|
||||
if (_securityIsLegacy) {
|
||||
var msg = _securityWarningMessage || "Your system may have factory default passwords. Please change your passwords to secure your system.";
|
||||
var linkText, linkAction;
|
||||
if (_securityStatus === "unsealed") {
|
||||
linkText = "Contact Support";
|
||||
linkAction = "openSupportModal(); return false;";
|
||||
} else {
|
||||
linkText = "Change Passwords";
|
||||
linkAction = "openServiceDetailModal('root-password-setup.service', 'System Passwords', 'passwords'); return false;";
|
||||
}
|
||||
securityBanner =
|
||||
'<div class="security-inline-banner">' +
|
||||
'<span class="security-inline-icon">⚠</span>' +
|
||||
'<span class="security-inline-text">' + msg + '</span>' +
|
||||
'<a class="security-inline-link" href="#" onclick="' + linkAction + '">' + linkText + '</a>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
section.innerHTML =
|
||||
'<div class="section-header">Preferences</div>' +
|
||||
'<hr class="section-divider" />' +
|
||||
securityBanner +
|
||||
'<div class="feature-card">' +
|
||||
'<div class="feature-card-top">' +
|
||||
'<div class="feature-card-info">' +
|
||||
'<div class="feature-card-name">Auto-launch Hub on Login</div>' +
|
||||
'<div class="feature-card-desc">Automatically open the Sovran Hub dashboard in your browser when you log in to the desktop.</div>' +
|
||||
'</div>' +
|
||||
'<label class="feature-toggle' + (enabled ? " active" : "") + '" id="autolaunch-toggle-label" title="Toggle auto-launch">' +
|
||||
'<input type="checkbox" class="feature-toggle-input" id="autolaunch-toggle-input"' + (enabled ? " checked" : "") + ' />' +
|
||||
'<span class="feature-toggle-slider"></span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$sidebarFeatures.appendChild(section);
|
||||
|
||||
var input = document.getElementById("autolaunch-toggle-input");
|
||||
var label = document.getElementById("autolaunch-toggle-label");
|
||||
if (!input || !label) return;
|
||||
|
||||
input.addEventListener("change", async function() {
|
||||
var newEnabled = input.checked;
|
||||
// Revert visually until confirmed
|
||||
input.checked = !newEnabled;
|
||||
if (newEnabled) { label.classList.remove("active"); } else { label.classList.add("active"); }
|
||||
input.disabled = true;
|
||||
try {
|
||||
await apiFetch("/api/autolaunch/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: newEnabled }),
|
||||
});
|
||||
input.checked = newEnabled;
|
||||
if (newEnabled) { label.classList.add("active"); } else { label.classList.remove("active"); }
|
||||
} catch (err) {
|
||||
alert("Failed to update auto-launch setting. Please try again.");
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
60
app/sovran_systemsos_web/static/js/helpers.js
Normal file
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function tileId(svc) { return svc.unit + "::" + svc.name; }
|
||||
|
||||
function statusClass(health) {
|
||||
if (!health) return "unknown";
|
||||
if (health === "healthy") return "active";
|
||||
if (health === "needs_attention") return "needs-attention";
|
||||
if (health === "active") return "active"; // backwards compat
|
||||
if (health === "inactive") return "inactive";
|
||||
if (health === "failed") return "failed";
|
||||
if (health === "disabled") return "disabled";
|
||||
if (health === "syncing") return "syncing";
|
||||
if (STATUS_LOADING_STATES.has(health)) return "loading";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function statusText(health, enabled) {
|
||||
if (!enabled) return "Disabled";
|
||||
if (health === "healthy") return "Active";
|
||||
if (health === "needs_attention") return "Needs Attention";
|
||||
if (health === "active") return "Active";
|
||||
if (health === "inactive") return "Inactive";
|
||||
if (health === "failed") return "Failed";
|
||||
if (health === "syncing") return "Syncing\u2026";
|
||||
if (!health || health === "unknown") return "Unknown";
|
||||
if (STATUS_LOADING_STATES.has(health)) return health;
|
||||
return health;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||||
}
|
||||
|
||||
function linkify(str) {
|
||||
return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return h + "h " + m + "m " + s + "s";
|
||||
if (m > 0) return m + "m " + s + "s";
|
||||
return s + "s";
|
||||
}
|
||||
|
||||
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
const res = await fetch(path, options || {});
|
||||
if (!res.ok) {
|
||||
let detail = res.status + " " + res.statusText;
|
||||
try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
84
app/sovran_systemsos_web/static/js/rebuild.js
Normal file
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
|
||||
// ── Rebuild modal ─────────────────────────────────────────────────
|
||||
|
||||
function openRebuildModal() {
|
||||
if (!$rebuildModal) return;
|
||||
_rebuildLog = "";
|
||||
_rebuildLogOffset = 0;
|
||||
_rebuildServerDown = false;
|
||||
_rebuildFinished = false;
|
||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||
var label = _rebuildFeatureName || "feature";
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…";
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "none";
|
||||
if ($rebuildSave) $rebuildSave.style.display = "none";
|
||||
if ($rebuildClose) $rebuildClose.disabled = true;
|
||||
$rebuildModal.classList.add("open");
|
||||
// Delay first poll slightly to let the rebuild service start and clear stale log
|
||||
setTimeout(startRebuildPoll, 1500);
|
||||
}
|
||||
|
||||
function closeRebuildModal() {
|
||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||
stopRebuildPoll();
|
||||
}
|
||||
|
||||
function appendRebuildLog(text) {
|
||||
if (!text) return;
|
||||
_rebuildLog += text;
|
||||
// Log is collected silently for error reports — not displayed to user
|
||||
}
|
||||
|
||||
function startRebuildPoll() {
|
||||
pollRebuildStatus();
|
||||
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopRebuildPoll() {
|
||||
if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; }
|
||||
}
|
||||
|
||||
async function pollRebuildStatus() {
|
||||
if (_rebuildFinished) return;
|
||||
try {
|
||||
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
||||
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||
if (data.log) appendRebuildLog(data.log);
|
||||
_rebuildLogOffset = data.offset;
|
||||
if (data.running) return;
|
||||
_rebuildFinished = true;
|
||||
stopRebuildPoll();
|
||||
onRebuildDone(data.result === "success");
|
||||
} catch (err) {
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||
}
|
||||
}
|
||||
|
||||
function onRebuildDone(success) {
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if (success) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
}
|
||||
}
|
||||
|
||||
function saveRebuildErrorReport() {
|
||||
var blob = new Blob([_rebuildLog], { type: "text/plain" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
16
app/sovran_systemsos_web/static/js/security.js
Normal file
@@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
// ── Legacy security warning ───────────────────────────────────────
|
||||
|
||||
async function checkLegacySecurity() {
|
||||
try {
|
||||
var data = await apiFetch("/api/security/status");
|
||||
if (data && (data.status === "legacy" || data.status === "unsealed")) {
|
||||
_securityIsLegacy = true;
|
||||
_securityStatus = data.status;
|
||||
_securityWarningMessage = data.warning || "This machine may have a security issue. Please review your system security.";
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-fatal — silently ignore if the endpoint is unreachable
|
||||
}
|
||||
}
|
||||
667
app/sovran_systemsos_web/static/js/service-detail.js
Normal file
@@ -0,0 +1,667 @@
|
||||
"use strict";
|
||||
|
||||
// ── Service detail modal ──────────────────────────────────────────
|
||||
|
||||
function _renderCredsHtml(credentials, unit) {
|
||||
var html = "";
|
||||
for (var i = 0; i < credentials.length; i++) {
|
||||
var cred = credentials[i];
|
||||
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||
var displayValue = linkify(cred.value);
|
||||
var qrBlock = "";
|
||||
if (cred.qrcode) {
|
||||
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
||||
}
|
||||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function _attachCopyHandlers(container) {
|
||||
container.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
var target = document.getElementById(btn.dataset.target);
|
||||
if (!target) return;
|
||||
var text = target.textContent;
|
||||
|
||||
function onSuccess() {
|
||||
btn.textContent = "Copied!";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500);
|
||||
}
|
||||
|
||||
function fallbackCopy() {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
onSuccess();
|
||||
} catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy);
|
||||
} else {
|
||||
fallbackCopy();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openServiceDetailModal(unit, name, icon) {
|
||||
if (!$credsModal) return;
|
||||
if ($credsTitle) {
|
||||
$credsTitle.innerHTML = '';
|
||||
if (icon) {
|
||||
var iconImg = document.createElement("img");
|
||||
iconImg.className = "creds-title-icon";
|
||||
iconImg.src = "/static/icons/" + escHtml(icon) + ".svg";
|
||||
iconImg.alt = name;
|
||||
iconImg.onerror = function() { this.style.display = "none"; };
|
||||
$credsTitle.appendChild(iconImg);
|
||||
}
|
||||
var nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = name;
|
||||
$credsTitle.appendChild(nameSpan);
|
||||
}
|
||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||
$credsModal.classList.add("open");
|
||||
|
||||
try {
|
||||
var url = "/api/service-detail/" + encodeURIComponent(unit);
|
||||
if (icon) url += "?icon=" + encodeURIComponent(icon);
|
||||
var data = await apiFetch(url);
|
||||
var html = "";
|
||||
|
||||
// Section A: Description
|
||||
if (data.description) {
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<p class="svc-detail-desc">' + escHtml(data.description) + '</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section: Desktop Launch (only for services with desktop apps)
|
||||
if (data.desktop_links && data.desktop_links.length > 0) {
|
||||
var launchBtns = '';
|
||||
data.desktop_links.forEach(function(link) {
|
||||
launchBtns += '<button class="btn btn-primary svc-detail-launch-btn" data-desktop="' + escHtml(link.desktop_file) + '">' +
|
||||
'🖥️ ' + escHtml(link.label) +
|
||||
'</button>';
|
||||
});
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Open on Desktop</div>' +
|
||||
'<p class="svc-detail-desc" style="margin-bottom:12px">If you are accessing this machine locally on the GNOME desktop, click below to launch the app directly.</p>' +
|
||||
'<div class="svc-detail-launch-row">' + launchBtns + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section B: Status
|
||||
// When a feature override is present, use the feature's enabled state so the
|
||||
// modal matches what the dashboard tile shows (feature toggle is authoritative).
|
||||
var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled;
|
||||
var effectiveHealth = data.feature && !data.feature.enabled
|
||||
? "disabled"
|
||||
: (data.health || data.status);
|
||||
var sc = statusClass(effectiveHealth);
|
||||
var st = statusText(effectiveHealth, effectiveEnabled);
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Status</div>' +
|
||||
'<div class="svc-detail-status">' +
|
||||
'<span class="status-dot ' + sc + '"></span>' +
|
||||
'<span>' + escHtml(st) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Section C: Ports (only if service has port_requirements)
|
||||
if (data.port_statuses && data.port_statuses.length > 0) {
|
||||
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
|
||||
var portTableRows = "";
|
||||
data.port_statuses.forEach(function(p) {
|
||||
var statusIcon, statusClass2;
|
||||
if (p.status === "listening") {
|
||||
statusIcon = "✅ Open";
|
||||
statusClass2 = "port-status-listening";
|
||||
} else if (p.status === "firewall_open") {
|
||||
statusIcon = "🟡 Firewall open";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "closed") {
|
||||
statusIcon = "🔴 Closed";
|
||||
statusClass2 = "port-status-closed";
|
||||
} else {
|
||||
statusIcon = "— Unknown";
|
||||
statusClass2 = "port-status-unknown";
|
||||
}
|
||||
var desc = p.description;
|
||||
var portNum = parseInt(p.port, 10);
|
||||
if (portNum === 80 || portNum === 443) {
|
||||
desc += " (shared — all services)";
|
||||
}
|
||||
portTableRows += '<tr>' +
|
||||
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="svc-detail-port-table-desc">' + escHtml(desc) + '</td>' +
|
||||
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
|
||||
var troubleshootHtml = "";
|
||||
if (anyPortClosed) {
|
||||
var sharedPorts = [];
|
||||
var specificPorts = [];
|
||||
data.port_statuses.forEach(function(p) {
|
||||
if (p.status === "closed") {
|
||||
var portNum = parseInt(p.port, 10);
|
||||
if (portNum === 80 || portNum === 443) {
|
||||
sharedPorts.push(p);
|
||||
} else {
|
||||
specificPorts.push(p);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var troubleParts = [];
|
||||
|
||||
if (sharedPorts.length > 0) {
|
||||
troubleParts.push(
|
||||
'<strong>⚠️ Ports 80 and 443 need to be forwarded on your router.</strong>' +
|
||||
'<p style="margin-top:8px">These are <strong>shared system ports</strong> — you only need to set them up once and they cover all your domain-based services ' +
|
||||
'(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).</p>' +
|
||||
'<p style="margin-top:8px">If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your router\'s admin panel (usually <code>http://192.168.1.1</code>)</li>' +
|
||||
'<li>Find the <strong>Port Forwarding</strong> section</li>' +
|
||||
'<li>Forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong> to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
||||
'<li>Save your router settings</li>' +
|
||||
'</ol>' +
|
||||
'<p style="margin-top:8px">💡 Once these two ports are forwarded, you won\'t see this warning on any service again.</p>'
|
||||
);
|
||||
}
|
||||
|
||||
if (specificPorts.length > 0) {
|
||||
var portList = specificPorts.map(function(p) {
|
||||
return '<strong>' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ')</strong> — ' + escHtml(p.description);
|
||||
}).join('<br>');
|
||||
|
||||
troubleParts.push(
|
||||
'<strong>⚠️ This service requires additional ports to be forwarded:</strong>' +
|
||||
'<p style="margin-top:8px">' + portList + '</p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your router\'s admin panel</li>' +
|
||||
'<li>Forward each port listed above to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
|
||||
'<li>Save your router settings</li>' +
|
||||
'</ol>'
|
||||
);
|
||||
}
|
||||
|
||||
troubleshootHtml = '<div class="svc-detail-troubleshoot">' + troubleParts.join('<hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);margin:16px 0">') + '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Port Status</div>' +
|
||||
'<table class="svc-detail-port-table">' +
|
||||
'<thead><tr>' +
|
||||
'<th>Port</th><th>Protocol</th><th>Description</th><th>Status</th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + portTableRows + '</tbody>' +
|
||||
'</table>' +
|
||||
troubleshootHtml +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section D: Domain (only if service needs_domain)
|
||||
if (data.needs_domain) {
|
||||
var domainStatusHtml = "";
|
||||
var ds = data.domain_status || {};
|
||||
var domainBadge = "";
|
||||
|
||||
if (data.domain) {
|
||||
if (ds.status === "connected") {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--ok">✓ ' + escHtml(data.domain) + '</span></span>';
|
||||
} else if (ds.status === "dns_mismatch") {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">⚠ ' + escHtml(data.domain) + ' (IP mismatch)</span></span>';
|
||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||
'<strong>⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.</strong>' +
|
||||
'<p style="margin-top:8px">This usually means the DNS record needs to be updated:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
||||
'<li>Find your domain and check the Dynamic DNS record</li>' +
|
||||
'<li>Make sure it points to your current external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
||||
'<li>If you set up a DDNS curl command during onboarding, verify it\'s running correctly</li>' +
|
||||
'</ol>' +
|
||||
'</div>';
|
||||
} else if (ds.status === "unresolvable") {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--error">✗ ' + escHtml(data.domain) + ' (DNS error)</span></span>';
|
||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||
'<strong>⚠️ This domain cannot be resolved. DNS is not configured yet.</strong>' +
|
||||
'<p style="margin-top:8px">Let\'s get it set up:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
|
||||
'<li>Find the domain you purchased for this service</li>' +
|
||||
'<li>Create a Dynamic DNS record pointing to your external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
|
||||
'<li>Copy the DDNS curl command from Njal.la\'s dashboard</li>' +
|
||||
'</ol>' +
|
||||
'<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>' +
|
||||
'</div>';
|
||||
} else {
|
||||
domainBadge = '<span class="svc-detail-domain-value">' + escHtml(data.domain) + '</span>';
|
||||
}
|
||||
} else {
|
||||
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">Not configured</span></span>';
|
||||
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
|
||||
'<strong>⚠️ No domain has been configured for this service yet.</strong>' +
|
||||
'<p style="margin-top:8px">To get this service working:</p>' +
|
||||
'<ol>' +
|
||||
'<li>Purchase a subdomain at <a href="https://njal.la" target="_blank">njal.la</a> (if you haven\'t already)</li>' +
|
||||
'<li>Use the button below to configure your domain through the setup wizard</li>' +
|
||||
'</ol>' +
|
||||
'<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Domain</div>' +
|
||||
domainBadge +
|
||||
domainStatusHtml +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section E: Credentials & Links
|
||||
if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||||
_renderCredsHtml(data.credentials, unit) +
|
||||
(unit === "matrix-synapse.service" ?
|
||||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||
'</div>' : "") +
|
||||
(unit === "root-password-setup.service" ?
|
||||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||
'<button class="matrix-action-btn" id="sys-change-pw-btn">🔑 Change Password</button>' +
|
||||
'</div>' : "") +
|
||||
'</div>';
|
||||
} else if (!data.enabled && !data.feature) {
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<p class="creds-empty">This service is not enabled in your configuration.</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section F: Addon Feature toggle
|
||||
if (data.feature) {
|
||||
var feat = data.feature;
|
||||
// Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state
|
||||
if (!_featuresData) {
|
||||
_featuresData = { features: [feat], ssl_email_configured: false };
|
||||
} else {
|
||||
var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; });
|
||||
if (fidx >= 0) { _featuresData.features[fidx] = feat; }
|
||||
else { _featuresData.features.push(feat); }
|
||||
}
|
||||
var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled";
|
||||
var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off";
|
||||
var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature";
|
||||
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||
|
||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
|
||||
? "\u20BF Bitcoin Node Selection"
|
||||
: "\uD83D\uDD27 Addon Feature";
|
||||
|
||||
// Description: prefer the feature's own description over a generic fallback
|
||||
var addonDesc = feat.description
|
||||
? feat.description
|
||||
: "This is an optional addon feature. You can enable or disable it at any time.";
|
||||
|
||||
// Conflicts warning: list mutually-exclusive feature names when present
|
||||
var conflictsHtml = "";
|
||||
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
|
||||
var conflictNames = feat.conflicts_with.map(function(cid) {
|
||||
if (_featuresData && Array.isArray(_featuresData.features)) {
|
||||
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
|
||||
if (cf) return cf.name;
|
||||
}
|
||||
return cid;
|
||||
});
|
||||
conflictsHtml = '<div class="feature-conflict-warning">\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">' + addonSectionTitle + '</div>' +
|
||||
'<p class="svc-detail-desc">' + escHtml(addonDesc) + '</p>' +
|
||||
conflictsHtml +
|
||||
'<div class="svc-detail-addon-row">' +
|
||||
'<span class="svc-detail-addon-status ' + addonStatusCls + '">' + addonStatusLabel + '</span>' +
|
||||
'<button class="' + addonBtnCls + '" id="svc-detail-addon-btn">' + escHtml(addonBtnLabel) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
$credsBody.innerHTML = html;
|
||||
_attachCopyHandlers($credsBody);
|
||||
|
||||
// Desktop launch button handlers
|
||||
$credsBody.querySelectorAll(".svc-detail-launch-btn").forEach(function(btn) {
|
||||
btn.addEventListener("click", async function() {
|
||||
var desktopFile = btn.dataset.desktop;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Launching…";
|
||||
try {
|
||||
await apiFetch("/api/desktop/launch/" + encodeURIComponent(desktopFile), {
|
||||
method: "POST"
|
||||
});
|
||||
btn.textContent = "✓ Launched!";
|
||||
setTimeout(function() { btn.textContent = "🖥️ " + btn.textContent; btn.disabled = false; }, 2000);
|
||||
} catch (err) {
|
||||
btn.textContent = "❌ Failed";
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (unit === "matrix-synapse.service") {
|
||||
var addBtn = document.getElementById("matrix-add-user-btn");
|
||||
var changePwBtn = document.getElementById("matrix-change-pw-btn");
|
||||
if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name, icon); });
|
||||
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); });
|
||||
}
|
||||
|
||||
if (unit === "root-password-setup.service") {
|
||||
var sysPwBtn = document.getElementById("sys-change-pw-btn");
|
||||
if (sysPwBtn) sysPwBtn.addEventListener("click", function() { openSystemChangePasswordModal(unit, name, icon); });
|
||||
}
|
||||
|
||||
if (data.feature) {
|
||||
var addonBtn = document.getElementById("svc-detail-addon-btn");
|
||||
if (addonBtn) {
|
||||
var addonFeat = data.feature;
|
||||
addonBtn.addEventListener("click", function() {
|
||||
closeCredsModal();
|
||||
handleFeatureToggle(addonFeat, !addonFeat.enabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Configure Domain button (for non-feature services that need a domain)
|
||||
var configDomainBtn = document.getElementById("svc-detail-config-domain-btn");
|
||||
var reconfigDomainBtn = document.getElementById("svc-detail-reconfig-domain-btn");
|
||||
var domainBtn = configDomainBtn || reconfigDomainBtn;
|
||||
if (domainBtn && data.needs_domain && data.domain_name) {
|
||||
var pseudoFeat = {
|
||||
id: data.domain_name,
|
||||
name: name,
|
||||
domain_name: data.domain_name,
|
||||
needs_ddns: true,
|
||||
extra_fields: []
|
||||
};
|
||||
domainBtn.addEventListener("click", function() {
|
||||
closeCredsModal();
|
||||
openDomainSetupModal(pseudoFeat, function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-empty">Could not load service details.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credentials info modal ────────────────────────────────────────
|
||||
|
||||
async function openCredsModal(unit, name, icon) {
|
||||
if (!$credsModal) return;
|
||||
if ($credsTitle) {
|
||||
$credsTitle.innerHTML = '';
|
||||
if (icon) {
|
||||
var iconImg = document.createElement("img");
|
||||
iconImg.className = "creds-title-icon";
|
||||
iconImg.src = "/static/icons/" + escHtml(icon) + ".svg";
|
||||
iconImg.alt = name;
|
||||
iconImg.onerror = function() { this.style.display = "none"; };
|
||||
$credsTitle.appendChild(iconImg);
|
||||
}
|
||||
var nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = name + " — Connection Info";
|
||||
$credsTitle.appendChild(nameSpan);
|
||||
}
|
||||
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
|
||||
$credsModal.classList.add("open");
|
||||
try {
|
||||
var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
|
||||
if (!data.credentials || data.credentials.length === 0) {
|
||||
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
|
||||
return;
|
||||
}
|
||||
var html = _renderCredsHtml(data.credentials, unit);
|
||||
if (unit === "matrix-synapse.service") {
|
||||
html += '<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||
'</div>';
|
||||
}
|
||||
$credsBody.innerHTML = html;
|
||||
_attachCopyHandlers($credsBody);
|
||||
if (unit === "matrix-synapse.service") {
|
||||
var addBtn = document.getElementById("matrix-add-user-btn");
|
||||
var changePwBtn = document.getElementById("matrix-change-pw-btn");
|
||||
if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); });
|
||||
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); });
|
||||
}
|
||||
} catch (err) {
|
||||
$credsBody.innerHTML = '<p class="creds-empty">Could not load credentials.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function openMatrixCreateUserModal(unit, name, icon) {
|
||||
if (!$credsBody) return;
|
||||
$credsBody.innerHTML =
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-username">Username</label>' +
|
||||
'<input class="matrix-form-input" type="text" id="matrix-new-username" placeholder="alice" autocomplete="off"></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-password">Password</label>' +
|
||||
'<input class="matrix-form-input" type="password" id="matrix-new-password" placeholder="Strong password" autocomplete="new-password"></div>' +
|
||||
'<div class="matrix-form-checkbox-row"><input type="checkbox" id="matrix-new-admin"><label class="matrix-form-label" for="matrix-new-admin" style="margin:0">Make admin</label></div>' +
|
||||
'<div class="matrix-form-actions">' +
|
||||
'<button class="matrix-form-back" id="matrix-create-back-btn">← Back</button>' +
|
||||
'<button class="matrix-form-submit" id="matrix-create-submit-btn">Create User</button>' +
|
||||
'</div>' +
|
||||
'<div class="matrix-form-result" id="matrix-create-result"></div>';
|
||||
|
||||
document.getElementById("matrix-create-back-btn").addEventListener("click", function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
|
||||
document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() {
|
||||
var submitBtn = document.getElementById("matrix-create-submit-btn");
|
||||
var resultEl = document.getElementById("matrix-create-result");
|
||||
var username = (document.getElementById("matrix-new-username").value || "").trim();
|
||||
var password = document.getElementById("matrix-new-password").value || "";
|
||||
var isAdmin = document.getElementById("matrix-new-admin").checked;
|
||||
|
||||
if (!username || !password) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Username and password are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Creating…";
|
||||
resultEl.className = "matrix-form-result";
|
||||
resultEl.textContent = "";
|
||||
|
||||
try {
|
||||
var resp = await apiFetch("/api/matrix/create-user", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username, password: password, admin: isAdmin })
|
||||
});
|
||||
resultEl.className = "matrix-form-result success";
|
||||
resultEl.textContent = "✅ User @" + escHtml(resp.username) + " created successfully.";
|
||||
submitBtn.textContent = "Create User";
|
||||
submitBtn.disabled = false;
|
||||
} catch (err) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "❌ " + (err.message || "Failed to create user.");
|
||||
submitBtn.textContent = "Create User";
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openMatrixChangePasswordModal(unit, name, icon) {
|
||||
if (!$credsBody) return;
|
||||
$credsBody.innerHTML =
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-username">Username (localpart only, e.g. <em>alice</em>)</label>' +
|
||||
'<input class="matrix-form-input" type="text" id="matrix-chpw-username" placeholder="alice" autocomplete="off"></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-password">New Password</label>' +
|
||||
'<input class="matrix-form-input" type="password" id="matrix-chpw-password" placeholder="New strong password" autocomplete="new-password"></div>' +
|
||||
'<div class="matrix-form-actions">' +
|
||||
'<button class="matrix-form-back" id="matrix-chpw-back-btn">← Back</button>' +
|
||||
'<button class="matrix-form-submit" id="matrix-chpw-submit-btn">Change Password</button>' +
|
||||
'</div>' +
|
||||
'<div class="matrix-form-result" id="matrix-chpw-result"></div>';
|
||||
|
||||
document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
|
||||
document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() {
|
||||
var submitBtn = document.getElementById("matrix-chpw-submit-btn");
|
||||
var resultEl = document.getElementById("matrix-chpw-result");
|
||||
var username = (document.getElementById("matrix-chpw-username").value || "").trim();
|
||||
var newPassword = document.getElementById("matrix-chpw-password").value || "";
|
||||
|
||||
if (!username || !newPassword) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Username and new password are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Changing…";
|
||||
resultEl.className = "matrix-form-result";
|
||||
resultEl.textContent = "";
|
||||
|
||||
try {
|
||||
var resp = await apiFetch("/api/matrix/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username, new_password: newPassword })
|
||||
});
|
||||
resultEl.className = "matrix-form-result success";
|
||||
resultEl.textContent = "✅ Password for @" + escHtml(resp.username) + " changed successfully.";
|
||||
submitBtn.textContent = "Change Password";
|
||||
submitBtn.disabled = false;
|
||||
} catch (err) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
|
||||
submitBtn.textContent = "Change Password";
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openSystemChangePasswordModal(unit, name, icon) {
|
||||
if (!$credsBody) return;
|
||||
$credsBody.innerHTML =
|
||||
'<div class="sys-chpw-header">' +
|
||||
'<div class="sys-chpw-title">🔑 Change \'free\' Account Password</div>' +
|
||||
'<div class="sys-chpw-desc">This updates the system login password for the <strong>free</strong> user account on this device.</div>' +
|
||||
'</div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
|
||||
'<div class="pw-input-wrap">' +
|
||||
'<input class="matrix-form-input" type="password" id="sys-chpw-new" placeholder="New strong password" autocomplete="new-password">' +
|
||||
'<button type="button" class="pw-toggle-btn" id="sys-chpw-new-toggle" aria-label="Toggle password visibility">👁</button>' +
|
||||
'</div>' +
|
||||
'<div class="pw-hint">Password must be at least 8 characters.</div></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-confirm">Confirm Password</label>' +
|
||||
'<div class="pw-input-wrap">' +
|
||||
'<input class="matrix-form-input" type="password" id="sys-chpw-confirm" placeholder="Confirm new password" autocomplete="new-password">' +
|
||||
'<button type="button" class="pw-toggle-btn" id="sys-chpw-confirm-toggle" aria-label="Toggle password visibility">👁</button>' +
|
||||
'</div></div>' +
|
||||
'<div class="pw-credentials-note">⚠ After changing, your updated password will appear in the System Passwords credentials tile. Make sure to remember it.</div>' +
|
||||
'<div class="matrix-form-actions">' +
|
||||
'<button class="matrix-form-back" id="sys-chpw-back-btn">← Back</button>' +
|
||||
'<button class="matrix-form-submit" id="sys-chpw-submit-btn">Change Password</button>' +
|
||||
'</div>' +
|
||||
'<div class="matrix-form-result" id="sys-chpw-result"></div>';
|
||||
|
||||
document.getElementById("sys-chpw-back-btn").addEventListener("click", function() {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() {
|
||||
var inp = document.getElementById("sys-chpw-new");
|
||||
var isHidden = inp.type === "password";
|
||||
inp.type = isHidden ? "text" : "password";
|
||||
this.textContent = isHidden ? "👁🗨" : "👁";
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-confirm-toggle").addEventListener("click", function() {
|
||||
var inp = document.getElementById("sys-chpw-confirm");
|
||||
var isHidden = inp.type === "password";
|
||||
inp.type = isHidden ? "text" : "password";
|
||||
this.textContent = isHidden ? "👁🗨" : "👁";
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() {
|
||||
var submitBtn = document.getElementById("sys-chpw-submit-btn");
|
||||
var resultEl = document.getElementById("sys-chpw-result");
|
||||
var newPassword = document.getElementById("sys-chpw-new").value || "";
|
||||
var confirmPassword = document.getElementById("sys-chpw-confirm").value || "";
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Both password fields are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Password must be at least 8 characters.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Changing…";
|
||||
resultEl.className = "matrix-form-result";
|
||||
resultEl.textContent = "";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword })
|
||||
});
|
||||
resultEl.className = "matrix-form-result success";
|
||||
resultEl.textContent = "✅ System password changed successfully.";
|
||||
submitBtn.textContent = "Change Password";
|
||||
submitBtn.disabled = false;
|
||||
// Hide the legacy security banner if it's visible — but only for
|
||||
// "legacy" status machines. For "unsealed" machines, changing passwords
|
||||
// is not enough; the factory residue remains until a proper re-seal or re-install.
|
||||
if (typeof _securityIsLegacy !== "undefined" && _securityIsLegacy &&
|
||||
(typeof _securityStatus === "undefined" || _securityStatus !== "unsealed")) {
|
||||
_securityIsLegacy = false;
|
||||
var banner = document.querySelector(".security-inline-banner");
|
||||
if (banner) banner.remove();
|
||||
}
|
||||
} catch (err) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
|
||||
submitBtn.textContent = "Change Password";
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }
|
||||
108
app/sovran_systemsos_web/static/js/state.js
Normal file
@@ -0,0 +1,108 @@
|
||||
"use strict";
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
|
||||
let _servicesCache = [];
|
||||
let _categoryLabels = {};
|
||||
let _updateLog = "";
|
||||
let _updatePollTimer = null;
|
||||
let _updateLogOffset = 0;
|
||||
let _serverWasDown = false;
|
||||
let _updateFinished = false;
|
||||
let _supportTimerInt = null;
|
||||
let _supportEnabledAt = null;
|
||||
let _supportStatus = null; // last fetched /api/support/status payload
|
||||
let _walletUnlockTimerInt = null;
|
||||
let _cachedExternalIp = null;
|
||||
|
||||
// Current role (set during init from /api/config)
|
||||
let _currentRole = "server_plus_desktop";
|
||||
|
||||
// Feature Manager state
|
||||
let _featuresData = null;
|
||||
let _rebuildLog = "";
|
||||
let _rebuildLogOffset = 0;
|
||||
let _rebuildPollTimer = null;
|
||||
let _rebuildFinished = false;
|
||||
let _rebuildServerDown = false;
|
||||
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
|
||||
let _rebuildFeatureName = "";
|
||||
let _rebuildIsEnabling = true;
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────
|
||||
|
||||
const $tilesArea = document.getElementById("tiles-area");
|
||||
const $sidebarSupport = document.getElementById("sidebar-support");
|
||||
const $sidebarFeatures = document.getElementById("sidebar-features");
|
||||
// No longer needed — Update System moved to sidebar
|
||||
// const $updateBtn = document.getElementById("btn-update");
|
||||
// const $updateBadge = document.getElementById("update-badge");
|
||||
const $internalIp = document.getElementById("ip-internal");
|
||||
const $externalIp = document.getElementById("ip-external");
|
||||
|
||||
const $modal = document.getElementById("update-modal");
|
||||
const $modalSpinner = document.getElementById("modal-spinner");
|
||||
const $modalStatus = document.getElementById("modal-status");
|
||||
const $modalLog = document.getElementById("modal-log");
|
||||
const $btnReboot = document.getElementById("btn-reboot");
|
||||
const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
|
||||
const $credsModal = document.getElementById("creds-modal");
|
||||
const $credsTitle = document.getElementById("creds-modal-title");
|
||||
const $credsBody = document.getElementById("creds-body");
|
||||
const $credsCloseBtn = document.getElementById("creds-close-btn");
|
||||
|
||||
const $supportModal = document.getElementById("support-modal");
|
||||
const $supportBody = document.getElementById("support-body");
|
||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
||||
|
||||
// Feature Manager — rebuild modal
|
||||
const $rebuildModal = document.getElementById("rebuild-modal");
|
||||
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
||||
const $rebuildStatus = document.getElementById("rebuild-status");
|
||||
const $rebuildLog = document.getElementById("rebuild-log");
|
||||
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
||||
const $rebuildSave = document.getElementById("rebuild-save-report");
|
||||
const $rebuildClose = document.getElementById("rebuild-close-btn");
|
||||
|
||||
// Feature Manager — domain setup modal
|
||||
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
||||
const $domainSetupTitle = document.getElementById("domain-setup-title");
|
||||
const $domainSetupBody = document.getElementById("domain-setup-body");
|
||||
const $domainSetupClose = document.getElementById("domain-setup-close-btn");
|
||||
|
||||
// Feature Manager — SSL email modal
|
||||
const $sslEmailModal = document.getElementById("ssl-email-modal");
|
||||
const $sslEmailInput = document.getElementById("ssl-email-input");
|
||||
const $sslEmailSave = document.getElementById("ssl-email-save-btn");
|
||||
const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn");
|
||||
const $sslEmailClose = document.getElementById("ssl-email-close-btn");
|
||||
|
||||
// Feature Manager — confirm modal
|
||||
const $featureConfirmModal = document.getElementById("feature-confirm-modal");
|
||||
const $featureConfirmMsg = document.getElementById("feature-confirm-message");
|
||||
const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn");
|
||||
const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn");
|
||||
const $featureConfirmClose = document.getElementById("feature-confirm-close-btn");
|
||||
|
||||
// Port Requirements modal
|
||||
const $portReqModal = document.getElementById("port-requirements-modal");
|
||||
const $portReqBody = document.getElementById("port-req-body");
|
||||
const $portReqClose = document.getElementById("port-req-close-btn");
|
||||
|
||||
// Upgrade modal (Node → Server+Desktop)
|
||||
const $upgradeModal = document.getElementById("upgrade-modal");
|
||||
const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
|
||||
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
||||
|
||||
// Legacy security warning state (populated by checkLegacySecurity in security.js)
|
||||
var _securityIsLegacy = false;
|
||||
var _securityStatus = "ok"; // "ok", "legacy", or "unsealed"
|
||||
var _securityWarningMessage = "";
|
||||
|
||||
// System status banner
|
||||
// (removed — health is now shown per-tile via the composite health field)
|
||||
631
app/sovran_systemsos_web/static/js/support.js
Normal file
@@ -0,0 +1,631 @@
|
||||
"use strict";
|
||||
|
||||
// ── Tech Support modal ────────────────────────────────────────────
|
||||
|
||||
async function openSupportModal() {
|
||||
if (!$supportModal) return;
|
||||
$supportModal.classList.add("open");
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); }
|
||||
else if (!status.sshd_enabled) { renderSupportSshdOff(); }
|
||||
else { renderSupportInactive(); }
|
||||
} catch (err) {
|
||||
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSupportSshdOff() {
|
||||
stopSupportTimer();
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">🛟</div>',
|
||||
'<h3 class="support-heading">Need help from Sovran Systems?</h3>',
|
||||
'<p class="support-desc">To get Tech Support, SSH must be enabled first. SSH is <strong>off by default</strong> for maximum security — it only needs to be on during a support session.</p>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔐</span><span class="support-wallet-title">SSH is Off</span></div>',
|
||||
'<p class="support-wallet-desc">SSH (remote login) is <strong>disabled by default</strong> on your Sovran Pro. Clicking the button below will enable SSH and trigger a system rebuild. Once complete, you can then grant support access.</p>',
|
||||
'<p class="support-wallet-desc">When you end the support session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||
'</div>',
|
||||
'<div class="support-steps"><div class="support-steps-title">Steps:</div><ol>',
|
||||
'<li>Enable SSH (triggers a system rebuild — takes a few minutes)</li>',
|
||||
'<li>Grant Sovran Systems temporary support access</li>',
|
||||
'<li>End the session when done — you\'ll be prompted to disable SSH</li>',
|
||||
'</ol></div>',
|
||||
'<button class="btn support-btn-enable" id="btn-sshd-enable">Enable SSH</button>',
|
||||
'<p class="support-fine-print">This will trigger a NixOS rebuild. Your machine will remain operational during the rebuild.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
document.getElementById("btn-sshd-enable").addEventListener("click", enableSshd);
|
||||
}
|
||||
|
||||
async function enableSshd() {
|
||||
var btn = document.getElementById("btn-sshd-enable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling SSH…"; }
|
||||
try {
|
||||
await apiFetch("/api/features/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ feature: "sshd", enabled: true }),
|
||||
});
|
||||
// Poll until rebuild completes and sshd_enabled is true
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">⚙️</div>',
|
||||
'<h3 class="support-heading">Enabling SSH…</h3>',
|
||||
'<p class="support-desc">A system rebuild is in progress. This may take a few minutes. The page will update automatically when SSH is ready.</p>',
|
||||
'<p class="creds-loading" id="sshd-rebuild-status">Rebuilding system…</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
pollForSshdReady();
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable SSH"; }
|
||||
alert("Failed to enable SSH. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function pollForSshdReady() {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // 5 minutes (5s interval)
|
||||
var interval = setInterval(async function() {
|
||||
attempts++;
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
var el = document.getElementById("sshd-rebuild-status");
|
||||
if (status.sshd_enabled) {
|
||||
clearInterval(interval);
|
||||
_supportStatus = status;
|
||||
renderSupportInactive();
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (el) el.textContent = "Rebuild is taking longer than expected. Please close this dialog and try again.";
|
||||
} else {
|
||||
if (el) el.textContent = "Rebuilding system… (" + attempts * 5 + "s)";
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function renderSupportInactive() {
|
||||
stopSupportTimer();
|
||||
var ip = _cachedExternalIp || "loading…";
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">🛟</div>',
|
||||
'<h3 class="support-heading">Need help from Sovran Systems?</h3>',
|
||||
'<p class="support-desc">This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.</p>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">✅</span><span class="support-wallet-title">SSH is Active</span></div>',
|
||||
'<p class="support-wallet-desc">SSH is enabled on your machine. You can now grant Sovran Systems temporary access below.</p>',
|
||||
'</div>',
|
||||
'<div class="support-info-box">',
|
||||
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||
'<div class="support-info-hint">This IP will be shared with Sovran Systems support</div>',
|
||||
'</div>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Protection</span></div>',
|
||||
'<p class="support-wallet-desc">Wallet files (LND, Sparrow, Bisq) are <strong>protected by default</strong>. Support staff cannot access your private keys unless you explicitly grant access.</p>',
|
||||
'</div>',
|
||||
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||
'<li>Our SSH key is added only to that restricted account</li>',
|
||||
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||
'<li>All session events are logged for your audit</li>',
|
||||
'</ol></div>',
|
||||
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
|
||||
}
|
||||
|
||||
function renderSupportActive(status) {
|
||||
var ip = _cachedExternalIp || "loading…";
|
||||
var walletProtected = status && status.wallet_protected;
|
||||
var walletUnlocked = status && status.wallet_unlocked;
|
||||
var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : "";
|
||||
var protectedPaths = (status && status.protected_paths && status.protected_paths.length)
|
||||
? status.protected_paths : [];
|
||||
|
||||
var walletSection;
|
||||
if (walletProtected) {
|
||||
if (walletUnlocked) {
|
||||
walletSection = [
|
||||
'<div class="support-wallet-box support-wallet-unlocked">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔓</span><span class="support-wallet-title">Wallet Access: UNLOCKED</span></div>',
|
||||
'<p class="support-wallet-desc">You have granted support temporary access to wallet files' + (unlockUntil ? ' until <strong>' + escHtml(unlockUntil) + '</strong>' : '') + '.</p>',
|
||||
'<button class="btn support-btn-wallet-lock" id="btn-wallet-lock">Re-lock Wallet Now</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
} else {
|
||||
var pathList = protectedPaths.length
|
||||
? '<ul class="support-wallet-paths">' + protectedPaths.map(function(p){ return '<li>' + escHtml(p) + '</li>'; }).join("") + '</ul>'
|
||||
: '';
|
||||
walletSection = [
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Files: Protected</span></div>',
|
||||
'<p class="support-wallet-desc">Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.</p>',
|
||||
pathList,
|
||||
'<div class="support-wallet-unlock-row">',
|
||||
'<select id="wallet-unlock-duration" class="support-unlock-select">',
|
||||
'<option value="3600">1 hour</option>',
|
||||
'<option value="1800">30 minutes</option>',
|
||||
'<option value="7200">2 hours</option>',
|
||||
'</select>',
|
||||
'<button class="btn support-btn-wallet-unlock" id="btn-wallet-unlock">Grant Wallet Access</button>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
} else {
|
||||
walletSection = [
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">⚠️</span><span class="support-wallet-title">Wallet Protection Unavailable</span></div>',
|
||||
'<p class="support-wallet-desc">The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big support-active-icon">🔓</div>',
|
||||
'<h3 class="support-heading support-active-heading">Support Access is Active</h3>',
|
||||
'<p class="support-active-note">Sovran Systems can currently connect to your machine via SSH.</p>',
|
||||
'<div class="support-info-box support-active-box">',
|
||||
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||
'<div class="support-info-row"><span class="support-info-label">Duration</span><span class="support-info-value" id="support-timer">…</span></div>',
|
||||
'</div>',
|
||||
walletSection,
|
||||
'<button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button>',
|
||||
'<p class="support-fine-print">This will remove the SSH key and revoke all wallet access immediately.</p>',
|
||||
'<button class="btn support-btn-auditlog" id="btn-support-audit">View Audit Log</button>',
|
||||
'</div>',
|
||||
'<div id="support-audit-container" class="support-audit-container" style="display:none;"></div>',
|
||||
].join("");
|
||||
|
||||
document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
|
||||
document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog);
|
||||
if (walletProtected && !walletUnlocked) {
|
||||
document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock);
|
||||
}
|
||||
if (walletProtected && walletUnlocked) {
|
||||
document.getElementById("btn-wallet-lock").addEventListener("click", walletLock);
|
||||
}
|
||||
startSupportTimer();
|
||||
if (walletUnlocked && status.wallet_unlocked_until) {
|
||||
startWalletUnlockTimer(status.wallet_unlocked_until);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSupportRemoved(verified) {
|
||||
stopSupportTimer();
|
||||
stopWalletUnlockTimer();
|
||||
var icon = verified ? "✅" : "⚠️";
|
||||
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone.";
|
||||
var vclass = verified ? "verified-gone" : "verify-warning";
|
||||
var vlabel = verified ? "✓ Removed — No access" : "⚠ Verify by rebooting";
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">' + icon + '</div>',
|
||||
'<h3 class="support-heading">Support Session Ended</h3>',
|
||||
'<p class="support-desc">' + escHtml(msg) + '</p>',
|
||||
'<div class="support-verify-box"><span class="support-verify-label">SSH Key Status:</span><span class="support-verify-value ' + vclass + '">' + vlabel + '</span></div>',
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-top:12px;">',
|
||||
'<div class="support-wallet-header"><span class="support-wallet-icon">🔐</span><span class="support-wallet-title">Disable SSH When Done</span></div>',
|
||||
'<p class="support-wallet-desc">SSH is still enabled on your machine. Click below to turn it off and return to the default secure state.</p>',
|
||||
'<button class="btn support-btn-enable" id="btn-sshd-disable">Disable SSH</button>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-done" id="btn-support-done">Done</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
|
||||
document.getElementById("btn-sshd-disable").addEventListener("click", disableSshd);
|
||||
}
|
||||
|
||||
async function enableSupport() {
|
||||
var btn = document.getElementById("btn-support-enable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/enable", { method: "POST" });
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
_supportEnabledAt = status.enabled_at;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||
alert("Failed to enable support access. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSupport() {
|
||||
var btn = document.getElementById("btn-support-disable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; }
|
||||
try {
|
||||
var result = await apiFetch("/api/support/disable", { method: "POST" });
|
||||
renderSupportRemoved(result.verified);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; }
|
||||
alert("Failed to disable support access. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSshd() {
|
||||
var btn = document.getElementById("btn-sshd-disable");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Disabling SSH…"; }
|
||||
try {
|
||||
await apiFetch("/api/features/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ feature: "sshd", enabled: false }),
|
||||
});
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">⚙️</div>',
|
||||
'<h3 class="support-heading">Disabling SSH…</h3>',
|
||||
'<p class="support-desc">A system rebuild is in progress to turn off SSH. This may take a few minutes.</p>',
|
||||
'<p class="creds-loading" id="sshd-disable-status">Rebuilding system…</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
pollForSshdDisabled();
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Disable SSH"; }
|
||||
alert("Failed to disable SSH. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
function pollForSshdDisabled() {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // 5 minutes (5s interval)
|
||||
var interval = setInterval(async function() {
|
||||
attempts++;
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
var el = document.getElementById("sshd-disable-status");
|
||||
if (!status.sshd_enabled) {
|
||||
clearInterval(interval);
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">🔐</div>',
|
||||
'<h3 class="support-heading">SSH is Off</h3>',
|
||||
'<p class="support-desc">SSH has been disabled. Your machine is back to its default secure state.</p>',
|
||||
'<button class="btn support-btn-done" id="btn-support-done">Done</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (el) el.textContent = "Rebuild is taking longer than expected. Please close this dialog and try again.";
|
||||
} else {
|
||||
if (el) el.textContent = "Rebuilding system… (" + attempts * 5 + "s)";
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function walletUnlock() {
|
||||
var btn = document.getElementById("btn-wallet-unlock");
|
||||
var sel = document.getElementById("wallet-unlock-duration");
|
||||
var duration = sel ? parseInt(sel.value, 10) : 3600;
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; }
|
||||
try {
|
||||
var result = await apiFetch("/api/support/wallet-unlock", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ duration: duration }),
|
||||
});
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; }
|
||||
alert("Failed to unlock wallet access: " + (err.message || "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function walletLock() {
|
||||
var btn = document.getElementById("btn-wallet-lock");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Locking…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/wallet-lock", { method: "POST" });
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; }
|
||||
alert("Failed to re-lock wallet: " + (err.message || "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAuditLog() {
|
||||
var container = document.getElementById("support-audit-container");
|
||||
if (!container) return;
|
||||
if (container.style.display !== "none") {
|
||||
container.style.display = "none";
|
||||
return;
|
||||
}
|
||||
container.style.display = "block";
|
||||
container.innerHTML = '<p class="creds-loading">Loading audit log…</p>';
|
||||
try {
|
||||
var data = await apiFetch("/api/support/audit-log");
|
||||
if (!data.entries || data.entries.length === 0) {
|
||||
container.innerHTML = '<p class="support-audit-empty">No audit events recorded yet.</p>';
|
||||
} else {
|
||||
container.innerHTML = '<div class="support-audit-log">' +
|
||||
data.entries.map(function(e) { return '<div class="support-audit-entry">' + escHtml(e) + '</div>'; }).join("") +
|
||||
'</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
container.innerHTML = '<p class="creds-empty">Could not load audit log.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function startSupportTimer() {
|
||||
stopSupportTimer();
|
||||
updateSupportTimer();
|
||||
_supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL);
|
||||
}
|
||||
|
||||
function stopSupportTimer() {
|
||||
if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; }
|
||||
}
|
||||
|
||||
function updateSupportTimer() {
|
||||
var el = document.getElementById("support-timer");
|
||||
if (!el || !_supportEnabledAt) return;
|
||||
var elapsed = (Date.now() / 1000) - _supportEnabledAt;
|
||||
el.textContent = formatDuration(Math.max(0, elapsed));
|
||||
}
|
||||
|
||||
function startWalletUnlockTimer(expiresAt) {
|
||||
stopWalletUnlockTimer();
|
||||
_walletUnlockTimerInt = setInterval(function() {
|
||||
if (Date.now() / 1000 >= expiresAt) {
|
||||
stopWalletUnlockTimer();
|
||||
// Refresh the support modal to show re-locked state
|
||||
apiFetch("/api/support/status").then(function(status) {
|
||||
_supportStatus = status;
|
||||
renderSupportActive(status);
|
||||
}).catch(function() {});
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function stopWalletUnlockTimer() {
|
||||
if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; }
|
||||
}
|
||||
|
||||
function closeSupportModal() {
|
||||
if ($supportModal) $supportModal.classList.remove("open");
|
||||
stopSupportTimer();
|
||||
stopWalletUnlockTimer();
|
||||
}
|
||||
|
||||
// ── Manual Backup modal ───────────────────────────────────────────
|
||||
|
||||
var _backupPollTimer = null;
|
||||
var _backupLogOffset = 0;
|
||||
|
||||
function openBackupModal() {
|
||||
if (!$supportModal) return;
|
||||
$supportModal.classList.add("open");
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Detecting external drives\u2026</p>';
|
||||
detectDrivesAndRender();
|
||||
}
|
||||
|
||||
async function detectDrivesAndRender() {
|
||||
try {
|
||||
// Check whether a backup is already in progress
|
||||
var status = await apiFetch("/api/backup/status?offset=0");
|
||||
if (status.running) {
|
||||
renderBackupRunning();
|
||||
_backupLogOffset = status.offset || 0;
|
||||
if (status.log) {
|
||||
var logDiv = document.getElementById("backup-log");
|
||||
if (logDiv) { logDiv.insertAdjacentText("beforeend", status.log); logDiv.scrollTop = logDiv.scrollHeight; }
|
||||
}
|
||||
startBackupPoll();
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
var data = await apiFetch("/api/backup/drives");
|
||||
renderBackupReady(data.drives || []);
|
||||
} catch (err) {
|
||||
$supportBody.innerHTML = '<p class="creds-empty">Could not detect drives. Please try again.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderBackupReady(drives) {
|
||||
var driveSelector = "";
|
||||
if (drives.length > 0) {
|
||||
driveSelector = [
|
||||
'<label class="support-info-label" style="display:block;margin-bottom:6px;">Select drive:</label>',
|
||||
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:14px;">',
|
||||
'<select id="backup-drive-select" class="support-unlock-select" style="flex:1;">',
|
||||
].join("");
|
||||
for (var i = 0; i < drives.length; i++) {
|
||||
var d = drives[i];
|
||||
driveSelector += '<option value="' + escHtml(d.path) + '">' +
|
||||
escHtml(d.name) + ' \u2014 ' + d.free_gb + ' GB free / ' + d.total_gb + ' GB total' +
|
||||
'</option>';
|
||||
}
|
||||
driveSelector += '</select>';
|
||||
driveSelector += '<button class="btn support-btn-auditlog" id="btn-backup-refresh" style="white-space:nowrap;">↻ Refresh</button>';
|
||||
driveSelector += '</div>';
|
||||
driveSelector += '<button class="btn support-btn-enable" id="btn-start-backup">Start Backup</button>';
|
||||
} else {
|
||||
driveSelector = [
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
|
||||
'<span class="support-wallet-title">No External Drive Detected</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">',
|
||||
'No USB drive was found under /run/media/. ',
|
||||
'Make sure the drive is plugged in and mounted, then click Refresh.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-auditlog" id="btn-backup-refresh">↻ Refresh</button>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
||||
'<h3 class="support-heading">Manual Backup</h3>',
|
||||
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||
'<p class="support-wallet-desc">',
|
||||
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
|
||||
'This manual backup lets you create an additional copy on an external USB drive \u2014 ',
|
||||
'storing your data in a third location, outside the computer, for maximum protection ',
|
||||
'against hardware failure or physical damage.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
|
||||
'<div class="support-steps">',
|
||||
'<div class="support-steps-title">Requirements</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
||||
'<li>At least 500 GB of free space on the drive</li>',
|
||||
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
'<div class="support-steps">',
|
||||
'<div class="support-steps-title">What gets backed up</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>Bitcoin & Lightning wallet data (<code>/var/lib/lnd</code>)</li>',
|
||||
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||
'<li>Domain configurations (<code>/var/lib/domains</code>)</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Time Estimate</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">This backup can take <strong>up to 4 hours</strong> depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026</p>',
|
||||
'</div>',
|
||||
|
||||
driveSelector,
|
||||
'</div>',
|
||||
].join("");
|
||||
|
||||
if (drives.length > 0) {
|
||||
document.getElementById("btn-start-backup").addEventListener("click", startBackup);
|
||||
document.getElementById("btn-backup-refresh").addEventListener("click", function() {
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Scanning for external drives\u2026</p>';
|
||||
detectDrivesAndRender();
|
||||
});
|
||||
} else {
|
||||
document.getElementById("btn-backup-refresh").addEventListener("click", function() {
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Scanning for external drives\u2026</p>';
|
||||
detectDrivesAndRender();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function startBackup() {
|
||||
var btn = document.getElementById("btn-start-backup");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Starting\u2026"; }
|
||||
var sel = document.getElementById("backup-drive-select");
|
||||
var target = sel ? sel.value : "";
|
||||
try {
|
||||
_backupLogOffset = 0;
|
||||
await apiFetch("/api/backup/run" + (target ? "?target=" + encodeURIComponent(target) : ""), { method: "POST" });
|
||||
renderBackupRunning();
|
||||
startBackupPoll();
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Start Backup"; }
|
||||
alert("Failed to start backup: " + (err.message || "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderBackupRunning() {
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big support-active-icon">\ud83d\udcbe</div>',
|
||||
'<h3 class="support-heading support-active-heading">Backup In Progress</h3>',
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u26a0\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Do Not Unplug</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">Do not remove the USB drive while the backup is running. This could corrupt the backup and your drive.</p>',
|
||||
'</div>',
|
||||
'<div class="modal-log" id="backup-log" style="text-align:left;"></div>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
function startBackupPoll() {
|
||||
stopBackupPoll();
|
||||
_backupPollTimer = setInterval(pollBackupStatus, 2000);
|
||||
pollBackupStatus();
|
||||
}
|
||||
|
||||
function stopBackupPoll() {
|
||||
if (_backupPollTimer) { clearInterval(_backupPollTimer); _backupPollTimer = null; }
|
||||
}
|
||||
|
||||
async function pollBackupStatus() {
|
||||
try {
|
||||
var data = await apiFetch("/api/backup/status?offset=" + _backupLogOffset);
|
||||
var logDiv = document.getElementById("backup-log");
|
||||
if (logDiv && data.log) {
|
||||
logDiv.insertAdjacentText("beforeend", data.log);
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
_backupLogOffset = data.offset;
|
||||
if (!data.running) {
|
||||
stopBackupPoll();
|
||||
renderBackupDone(data.result === "success");
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderBackupDone(success) {
|
||||
var logDiv = document.getElementById("backup-log");
|
||||
var logContent = logDiv ? logDiv.textContent : "";
|
||||
|
||||
if (success) {
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\u2705</div>',
|
||||
'<h3 class="support-heading">All Finished!</h3>',
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u23cf\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Eject Your Drive</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">Please eject the drive before removing it from your Sovran Pro.</p>',
|
||||
'</div>',
|
||||
'<div class="modal-log" id="backup-log-done" style="text-align:left;"></div>',
|
||||
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
var doneLog = document.getElementById("backup-log-done");
|
||||
if (doneLog) { doneLog.textContent = logContent; doneLog.scrollTop = doneLog.scrollHeight; }
|
||||
} else {
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\u26a0\ufe0f</div>',
|
||||
'<h3 class="support-heading">Backup Failed</h3>',
|
||||
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as exFAT. Then try again.</p>',
|
||||
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
|
||||
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||
'</div>',
|
||||
].join("");
|
||||
var failLog = document.getElementById("backup-log-fail");
|
||||
if (failLog) { failLog.textContent = logContent; failLog.scrollTop = failLog.scrollHeight; }
|
||||
}
|
||||
document.getElementById("btn-backup-close").addEventListener("click", closeSupportModal);
|
||||
}
|
||||
308
app/sovran_systemsos_web/static/js/tiles.js
Normal file
@@ -0,0 +1,308 @@
|
||||
"use strict";
|
||||
|
||||
// ── Bitcoin IBD sync state (for ETA calculation) ──────────────────
|
||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||
var _btcSyncPrev = {};
|
||||
|
||||
// ── Render: initial build ─────────────────────────────────────────
|
||||
|
||||
function buildTiles(services, categoryLabels) {
|
||||
_servicesCache = services;
|
||||
var grouped = {};
|
||||
var supportServices = [];
|
||||
for (var i = 0; i < services.length; i++) {
|
||||
var svc = services[i];
|
||||
// Support tiles go to the sidebar, not the main grid
|
||||
if (svc.category === "support" || svc.type === "support") {
|
||||
supportServices.push(svc);
|
||||
continue;
|
||||
}
|
||||
var cat = svc.category || "other";
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(svc);
|
||||
}
|
||||
renderSidebarSupport(supportServices);
|
||||
$tilesArea.innerHTML = "";
|
||||
var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
||||
Object.keys(grouped).forEach(function(k) {
|
||||
if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k);
|
||||
});
|
||||
for (var j = 0; j < orderedKeys.length; j++) {
|
||||
var catKey = orderedKeys[j];
|
||||
var entries = grouped[catKey];
|
||||
if (!entries || entries.length === 0) continue;
|
||||
var label = categoryLabels[catKey] || catKey;
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section";
|
||||
section.dataset.category = catKey;
|
||||
section.innerHTML = '<div class="section-header">' + escHtml(label) + '</div><hr class="section-divider" /><div class="tiles-grid" data-cat="' + escHtml(catKey) + '"></div>';
|
||||
var grid = section.querySelector(".tiles-grid");
|
||||
for (var k = 0; k < entries.length; k++) {
|
||||
grid.appendChild(buildTile(entries[k]));
|
||||
}
|
||||
$tilesArea.appendChild(section);
|
||||
}
|
||||
if ($tilesArea.children.length === 0) {
|
||||
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSidebarSupport(supportServices) {
|
||||
$sidebarSupport.innerHTML = "";
|
||||
|
||||
// ── Update System button (above Tech Help)
|
||||
var sidebarUpdateBtn = document.createElement("button");
|
||||
sidebarUpdateBtn.className = "sidebar-support-btn";
|
||||
sidebarUpdateBtn.id = "sidebar-btn-update";
|
||||
sidebarUpdateBtn.innerHTML =
|
||||
'<img class="sidebar-support-icon" src="/static/icons/update.svg" alt="Update" style="width:1.5rem;height:1.5rem;">' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Update System</span>' +
|
||||
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
||||
'</span>';
|
||||
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
||||
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
||||
|
||||
for (var i = 0; i < supportServices.length; i++) {
|
||||
var svc = supportServices[i];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "sidebar-support-btn";
|
||||
btn.innerHTML =
|
||||
'<span class="sidebar-support-icon">🛟</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
||||
'<span class="sidebar-support-hint">Click for help</span>' +
|
||||
'</span>';
|
||||
btn.addEventListener("click", function() { openSupportModal(); });
|
||||
$sidebarSupport.appendChild(btn);
|
||||
}
|
||||
|
||||
// ── Manual Backup button
|
||||
var backupBtn = document.createElement("button");
|
||||
backupBtn.className = "sidebar-support-btn";
|
||||
backupBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon">💾</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Manual Backup</span>' +
|
||||
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
||||
'</span>';
|
||||
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
||||
$sidebarSupport.appendChild(backupBtn);
|
||||
|
||||
// ── Upgrade button (Node role only)
|
||||
if (_currentRole === "node") {
|
||||
var upgradeBtn = document.createElement("button");
|
||||
upgradeBtn.className = "sidebar-support-btn sidebar-upgrade-btn";
|
||||
upgradeBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon">🚀</span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
|
||||
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
||||
'</span>';
|
||||
upgradeBtn.addEventListener("click", function() { openUpgradeModal(); });
|
||||
$sidebarSupport.appendChild(upgradeBtn);
|
||||
}
|
||||
|
||||
var hr = document.createElement("hr");
|
||||
hr.className = "sidebar-divider";
|
||||
$sidebarSupport.appendChild(hr);
|
||||
}
|
||||
|
||||
function buildTile(svc) {
|
||||
var isSupport = svc.type === "support";
|
||||
var sc = statusClass(svc.health || svc.status);
|
||||
var st = statusText(svc.health || svc.status, svc.enabled);
|
||||
var dis = !svc.enabled;
|
||||
|
||||
var tile = document.createElement("div");
|
||||
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
||||
tile.dataset.unit = svc.unit;
|
||||
tile.dataset.tileId = tileId(svc);
|
||||
if (dis) tile.title = svc.name + " is not enabled in custom.nix";
|
||||
|
||||
if (isSupport) {
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="support-status-label">Click for help</span></div>';
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() { openSupportModal(); });
|
||||
return tile;
|
||||
}
|
||||
|
||||
if (svc.sync_ibd) {
|
||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||
var id = tileId(svc);
|
||||
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
tile.innerHTML =
|
||||
'<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'">' +
|
||||
'<div class="tile-icon-fallback" style="display:none">?</div>' +
|
||||
'<div class="tile-name">' + escHtml(svc.name) + '</div>' +
|
||||
versionLabel +
|
||||
'<div class="tile-sync-container">' +
|
||||
'<div class="tile-sync-label">\u23F3 Syncing Timechain</div>' +
|
||||
'<div class="tile-sync-bar-row">' +
|
||||
'<div class="tile-sync-bar-track"><div class="tile-sync-bar-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<span class="tile-sync-percent">' + pct + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="tile-sync-eta">' + escHtml(eta) + '</div>' +
|
||||
'</div>';
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
openServiceDetailModal(svc.unit, svc.name, svc.icon);
|
||||
});
|
||||
return tile;
|
||||
}
|
||||
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
openServiceDetailModal(svc.unit, svc.name, svc.icon);
|
||||
});
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
// ── Render: live update ───────────────────────────────────────────
|
||||
|
||||
// Calculate ETA text for Bitcoin IBD and track progress history.
|
||||
function _calcBtcEta(id, progress) {
|
||||
var now = Date.now();
|
||||
var prev = _btcSyncPrev[id];
|
||||
// Only update the cache when progress has actually advanced
|
||||
if (!prev || prev.progress < progress) {
|
||||
_btcSyncPrev[id] = { progress: progress, timestamp: now };
|
||||
}
|
||||
if (!prev || prev.progress >= progress) return "Estimating\u2026";
|
||||
var elapsed = (now - prev.timestamp) / 1000; // seconds
|
||||
if (elapsed <= 0) return "Estimating\u2026";
|
||||
var rate = (progress - prev.progress) / elapsed; // progress per second
|
||||
if (rate <= 0) return "Estimating\u2026";
|
||||
var remaining = (1.0 - progress) / rate;
|
||||
return "\u007E" + formatDuration(remaining) + " remaining";
|
||||
}
|
||||
|
||||
function updateTiles(services) {
|
||||
_servicesCache = services;
|
||||
for (var i = 0; i < services.length; i++) {
|
||||
var svc = services[i];
|
||||
if (svc.type === "support") continue;
|
||||
var id = CSS.escape(tileId(svc));
|
||||
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
||||
if (!tile) continue;
|
||||
|
||||
if (svc.sync_ibd) {
|
||||
// If tile was previously normal, rebuild it with the sync layout
|
||||
if (!tile.querySelector(".tile-sync-container")) {
|
||||
var newTile = buildTile(svc);
|
||||
tile.parentNode.replaceChild(newTile, tile);
|
||||
continue;
|
||||
}
|
||||
// Update progress bar values in-place
|
||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||
var etaText = _calcBtcEta(tileId(svc), svc.sync_progress || 0);
|
||||
var fill = tile.querySelector(".tile-sync-bar-fill");
|
||||
var pctEl = tile.querySelector(".tile-sync-percent");
|
||||
var etaEl = tile.querySelector(".tile-sync-eta");
|
||||
if (fill) fill.style.width = pct + "%";
|
||||
if (pctEl) pctEl.textContent = pct + "%";
|
||||
if (etaEl) etaEl.textContent = etaText;
|
||||
// Update or insert version label
|
||||
var syncVer = svc.version || svc.bitcoin_version || '';
|
||||
if (syncVer) {
|
||||
var syncVerEl = tile.querySelector(".tile-version");
|
||||
if (syncVerEl) {
|
||||
syncVerEl.textContent = syncVer;
|
||||
} else {
|
||||
var syncNameEl = tile.querySelector(".tile-name");
|
||||
if (syncNameEl) {
|
||||
var newSyncVerEl = document.createElement("div");
|
||||
newSyncVerEl.className = "tile-version";
|
||||
newSyncVerEl.textContent = syncVer;
|
||||
syncNameEl.insertAdjacentElement("afterend", newSyncVerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// IBD finished or not syncing — if tile had sync layout rebuild it normally
|
||||
if (tile.querySelector(".tile-sync-container")) {
|
||||
delete _btcSyncPrev[tileId(svc)];
|
||||
var normalTile = buildTile(svc);
|
||||
tile.parentNode.replaceChild(normalTile, tile);
|
||||
continue;
|
||||
}
|
||||
var sc = statusClass(svc.health || svc.status);
|
||||
var st = statusText(svc.health || svc.status, svc.enabled);
|
||||
var dot = tile.querySelector(".status-dot");
|
||||
var text = tile.querySelector(".status-text");
|
||||
if (dot) dot.className = "status-dot " + sc;
|
||||
if (text) text.textContent = st;
|
||||
// Update or insert version label for all service tiles
|
||||
var tileVer = svc.version || svc.bitcoin_version || '';
|
||||
if (tileVer) {
|
||||
var verEl = tile.querySelector(".tile-version");
|
||||
if (verEl) {
|
||||
verEl.textContent = tileVer;
|
||||
} else {
|
||||
var nameEl = tile.querySelector(".tile-name");
|
||||
if (nameEl) {
|
||||
var newVerEl = document.createElement("div");
|
||||
newVerEl.className = "tile-version";
|
||||
newVerEl.textContent = tileVer;
|
||||
nameEl.insertAdjacentElement("afterend", newVerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Service polling ───────────────────────────────────────────────
|
||||
|
||||
var _firstLoad = true;
|
||||
|
||||
async function refreshServices() {
|
||||
try {
|
||||
var services = await apiFetch("/api/services");
|
||||
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
||||
else { updateTiles(services); }
|
||||
} catch (err) { console.warn("Failed to fetch services:", err); }
|
||||
}
|
||||
|
||||
// ── Network IPs ───────────────────────────────────────────────────
|
||||
|
||||
async function loadNetwork() {
|
||||
try {
|
||||
var data = await apiFetch("/api/network");
|
||||
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
|
||||
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
|
||||
_cachedExternalIp = data.external_ip || "unavailable";
|
||||
} catch (_) {
|
||||
if ($internalIp) $internalIp.textContent = "—";
|
||||
if ($externalIp) $externalIp.textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update check ──────────────────────────────────────────────────
|
||||
|
||||
async function checkUpdates() {
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/check");
|
||||
var hasUpdates = !!data.available;
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (sidebarUpdateBtn) {
|
||||
if (hasUpdates) {
|
||||
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||
} else {
|
||||
sidebarUpdateBtn.style.borderColor = "";
|
||||
sidebarUpdateBtn.style.backgroundColor = "";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
120
app/sovran_systemsos_web/static/js/update.js
Normal file
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
|
||||
// ── Update modal ──────────────────────────────────────────────────
|
||||
|
||||
function openUpdateModal() {
|
||||
if (!$modal) return;
|
||||
_updateLog = "";
|
||||
_updateLogOffset = 0;
|
||||
_serverWasDown = false;
|
||||
_updateFinished = false;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
$modal.classList.add("open");
|
||||
startUpdate();
|
||||
}
|
||||
|
||||
function closeUpdateModal() {
|
||||
if (!$modal) return;
|
||||
$modal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
}
|
||||
|
||||
function appendLog(text) {
|
||||
if (!text) return;
|
||||
_updateLog += text;
|
||||
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
|
||||
}
|
||||
|
||||
function startUpdate() {
|
||||
fetch("/api/updates/run", { method: "POST" })
|
||||
.then(function(response) {
|
||||
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
startUpdatePoll();
|
||||
})
|
||||
.catch(function(err) {
|
||||
appendLog("[Error: failed to start update — " + err + "]\n");
|
||||
onUpdateDone(false);
|
||||
});
|
||||
}
|
||||
|
||||
function startUpdatePoll() {
|
||||
pollUpdateStatus();
|
||||
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopUpdatePoll() {
|
||||
if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; }
|
||||
}
|
||||
|
||||
async function pollUpdateStatus() {
|
||||
if (_updateFinished) return;
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
||||
if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; }
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
if (data.running) return;
|
||||
_updateFinished = true;
|
||||
stopUpdatePoll();
|
||||
if (data.result === "success") onUpdateDone(true);
|
||||
else onUpdateDone(false);
|
||||
} catch (err) {
|
||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateDone(success) {
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
if (success) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
}
|
||||
}
|
||||
|
||||
function saveErrorReport() {
|
||||
var blob = new Blob([_updateLog], { type: "text/plain" });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Reboot ────────────────────────────────────────────────────────
|
||||
|
||||
function doReboot() {
|
||||
if ($modal) $modal.classList.remove("open");
|
||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
stopRebuildPoll();
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
fetch("/api/reboot", { method: "POST" }).catch(function() {});
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
function waitForServerReboot() {
|
||||
fetch("/api/config", { cache: "no-store" })
|
||||
.then(function(res) {
|
||||
if (res.ok) window.location.reload();
|
||||
else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
})
|
||||
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
|
||||
}
|
||||
121
app/sovran_systemsos_web/static/logo-light.svg
Executable file
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 23.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 218.44057 109.75845"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="sovran_systems_2.svg"
|
||||
width="218.44057"
|
||||
height="109.75845"
|
||||
inkscape:export-filename="sovran_systems_2.svg"
|
||||
inkscape:export-xdpi="169.6163"
|
||||
inkscape:export-ydpi="169.6163"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs47" /><sodipodi:namedview
|
||||
id="namedview45"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style2">
|
||||
.st0{fill:#1C9954;}
|
||||
.st1{fill:#077233;}
|
||||
</style>
|
||||
<g
|
||||
id="g30"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)"
|
||||
style="fill:#dedede;fill-opacity:1">
|
||||
<path
|
||||
d="m 354.93,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.69,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||
id="path4"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 412.05,528.85 c 0,1.81 -0.27,3.46 -0.8,4.94 -0.53,1.48 -1.48,2.74 -2.84,3.78 -1.36,1.04 -3.23,1.86 -5.62,2.45 -2.39,0.59 -5.41,0.89 -9.08,0.89 -3.67,0 -6.7,-0.29 -9.11,-0.89 -2.4,-0.59 -4.29,-1.41 -5.65,-2.45 -1.36,-1.04 -2.31,-2.31 -2.84,-3.78 -0.53,-1.48 -0.8,-3.12 -0.8,-4.94 v -20.17 c 0,-1.81 0.27,-3.46 0.8,-4.94 0.53,-1.48 1.48,-2.75 2.84,-3.81 1.36,-1.06 3.24,-1.89 5.65,-2.48 2.4,-0.59 5.44,-0.89 9.11,-0.89 3.67,0 6.69,0.3 9.08,0.89 2.39,0.59 4.26,1.42 5.62,2.48 1.36,1.06 2.31,2.34 2.84,3.81 0.53,1.48 0.8,3.13 0.8,4.94 z m -23.3,-2.13 c 0,0.79 0.3,1.45 0.89,1.98 0.59,0.53 1.95,0.8 4.08,0.8 2.13,0 3.49,-0.27 4.08,-0.8 0.59,-0.53 0.89,-1.19 0.89,-1.98 v -15.91 c 0,-0.75 -0.3,-1.39 -0.89,-1.92 -0.59,-0.53 -1.95,-0.8 -4.08,-0.8 -2.13,0 -3.49,0.27 -4.08,0.8 -0.59,0.53 -0.89,1.17 -0.89,1.92 z"
|
||||
id="path6"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 447.12,540.02 h -15.38 c -0.75,0 -1.37,-0.14 -1.86,-0.41 -0.49,-0.28 -0.88,-0.79 -1.15,-1.54 l -5.8,-14.49 c -0.35,-0.87 -0.65,-1.63 -0.89,-2.28 -0.24,-0.65 -0.43,-1.27 -0.59,-1.86 -0.16,-0.59 -0.27,-1.21 -0.33,-1.86 -0.06,-0.65 -0.09,-1.45 -0.09,-2.4 v -15.61 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.4 c 0.75,0 1.32,0.17 1.71,0.5 0.39,0.34 0.59,0.88 0.59,1.63 v 16.32 c 0,0.39 0.04,0.79 0.12,1.18 0.08,0.39 0.2,0.81 0.35,1.24 l 2.78,8.28 c 0.12,0.39 0.26,0.66 0.41,0.8 0.16,0.14 0.39,0.21 0.71,0.21 h 0.65 c 0.31,0 0.55,-0.07 0.71,-0.21 0.16,-0.14 0.3,-0.4 0.41,-0.8 l 2.78,-8.34 c 0.16,-0.43 0.28,-0.85 0.35,-1.24 0.08,-0.39 0.12,-0.79 0.12,-1.18 v -16.26 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.28 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 15.61 c 0,0.95 -0.03,1.74 -0.09,2.4 -0.06,0.65 -0.17,1.27 -0.33,1.86 -0.16,0.59 -0.35,1.21 -0.59,1.86 -0.24,0.65 -0.53,1.41 -0.89,2.28 l -5.8,14.49 c -0.28,0.75 -0.66,1.26 -1.15,1.54 -0.45,0.28 -1.07,0.41 -1.82,0.41 z"
|
||||
id="path8"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 478.69,540.02 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 19.69 c 4.41,0 7.46,0.92 9.14,2.75 1.68,1.83 2.51,4.21 2.51,7.13 v 2.72 c 0,1.66 -0.25,3.07 -0.74,4.23 -0.49,1.16 -1.35,2 -2.57,2.51 2.13,0.24 3.85,1.1 5.17,2.6 1.32,1.5 1.98,3.49 1.98,5.97 v 12.54 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.17 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -9.05 c 0,-0.87 -0.17,-1.51 -0.5,-1.92 -0.34,-0.41 -0.92,-0.62 -1.74,-0.62 h -8.28 v 11.59 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.62,0.5 z m 2.13,-31.93 v 7.57 h 4.44 c 1.02,0 1.71,-0.27 2.07,-0.8 0.36,-0.53 0.53,-1.19 0.53,-1.98 v -2.01 c 0,-0.79 -0.18,-1.45 -0.53,-1.98 -0.35,-0.53 -1.04,-0.8 -2.07,-0.8 z"
|
||||
id="path10"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 525.83,537.89 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 H 515 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -15.61 c 0,-1.18 0.19,-2.54 0.56,-4.08 0.38,-1.54 0.96,-3.33 1.75,-5.38 l 5.15,-13.42 c 0.24,-0.67 0.6,-1.16 1.09,-1.48 0.49,-0.31 1.13,-0.47 1.92,-0.47 h 15.91 c 0.75,0 1.37,0.16 1.86,0.47 0.49,0.32 0.86,0.81 1.09,1.48 l 5.14,13.42 c 0.79,2.05 1.37,3.84 1.75,5.38 0.37,1.54 0.56,2.9 0.56,4.08 v 15.61 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.93 c -0.79,0 -1.37,-0.17 -1.74,-0.5 -0.38,-0.33 -0.56,-0.88 -0.56,-1.63 v -8.28 h -10.47 v 8.28 z m 3.37,-28.03 -2.78,8.99 h 9.29 l -2.78,-8.99 c -0.16,-0.35 -0.33,-0.61 -0.5,-0.77 -0.18,-0.16 -0.38,-0.24 -0.62,-0.24 h -1.48 c -0.24,0 -0.44,0.08 -0.62,0.24 -0.19,0.16 -0.36,0.42 -0.51,0.77 z"
|
||||
id="path12"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 570.29,540.02 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 6.15 c 0.75,0 1.39,0.12 1.92,0.35 0.53,0.24 1.05,0.65 1.57,1.24 l 11.47,13.13 v -12.6 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.87 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -8.87 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.27 l -10.11,-12.24 v 19.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.35 -0.88,0.51 -1.63,0.51 z"
|
||||
id="path14"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 641.61,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.17,2.69 -8.96,2.69 z"
|
||||
id="path16"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 683.66,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.57 L 662.9,518.2 c -0.91,-1.22 -1.51,-2.29 -1.8,-3.19 -0.3,-0.91 -0.44,-2.27 -0.44,-4.08 v -11.35 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.11 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 9.7 c 0,0.39 0.02,0.81 0.06,1.24 0.04,0.43 0.2,0.85 0.47,1.24 l 2.72,4.26 c 0.2,0.35 0.4,0.61 0.62,0.77 0.22,0.16 0.48,0.24 0.8,0.24 h 0.59 c 0.31,0 0.58,-0.08 0.8,-0.24 0.22,-0.16 0.42,-0.41 0.62,-0.77 l 2.72,-4.26 c 0.28,-0.39 0.43,-0.81 0.47,-1.24 0.04,-0.43 0.06,-0.85 0.06,-1.24 v -9.7 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 8.81 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 11.35 c 0,1.81 -0.16,3.17 -0.47,4.08 -0.32,0.91 -0.91,1.97 -1.77,3.19 l -8.99,12.18 v 7.51 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.35,0.34 -0.9,0.5 -1.64,0.5 z"
|
||||
id="path18"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 725.88,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||
id="path20"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 766.44,540.02 h -9.58 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -29.04 h -8.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -7.16 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 31.22 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.16 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 h -8.69 v 29.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.33,0.34 -0.88,0.5 -1.63,0.5 z"
|
||||
id="path22"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 817.06,540.02 h -27.44 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 27.44 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -16.32 v 4.55 h 11.53 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.33 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.33 -0.88,0.5 -1.63,0.5 h -11.53 v 5.08 h 16.32 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.33 0.5,0.88 0.5,1.63 v 6.92 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 z"
|
||||
id="path24"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 839.48,540.02 h -8.81 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -38.32 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.33 0.88,-0.5 1.63,-0.5 h 9.52 c 0.63,0 1.15,0.14 1.57,0.41 0.41,0.28 0.8,0.73 1.15,1.36 l 5.32,9.64 c 0.2,0.35 0.36,0.61 0.5,0.77 0.14,0.16 0.33,0.24 0.56,0.24 h 0.53 c 0.24,0 0.42,-0.08 0.56,-0.24 0.14,-0.16 0.3,-0.41 0.5,-0.77 l 5.26,-9.64 c 0.36,-0.63 0.74,-1.08 1.15,-1.36 0.41,-0.28 0.94,-0.41 1.57,-0.41 h 9.58 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 38.32 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -9.11 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.34,-0.33 -0.5,-0.88 -0.5,-1.63 v -20.82 l -3.49,6.45 c -0.35,0.67 -0.78,1.15 -1.27,1.45 -0.49,0.3 -1.12,0.44 -1.86,0.44 h -2.37 c -0.75,0 -1.37,-0.15 -1.86,-0.44 -0.49,-0.29 -0.92,-0.78 -1.27,-1.45 l -3.49,-6.45 v 20.82 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.32,0.34 -0.87,0.5 -1.61,0.5 z"
|
||||
id="path26"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
<path
|
||||
d="m 900.86,540.02 h -18.69 c -0.75,0 -1.29,-0.17 -1.63,-0.5 -0.33,-0.33 -0.5,-0.88 -0.5,-1.63 v -6.92 c 0,-0.75 0.17,-1.29 0.5,-1.63 0.33,-0.34 0.88,-0.5 1.63,-0.5 h 15.91 c 0.51,0 0.9,-0.17 1.15,-0.5 0.26,-0.33 0.38,-0.74 0.38,-1.21 0,-0.67 -0.13,-1.16 -0.38,-1.48 -0.26,-0.31 -0.64,-0.49 -1.15,-0.53 l -8.87,-1.24 c -2.76,-0.39 -4.98,-1.3 -6.65,-2.72 -1.68,-1.42 -2.51,-3.78 -2.51,-7.1 v -6.21 c 0,-3.35 1.08,-5.92 3.25,-7.72 2.17,-1.79 5.16,-2.69 8.99,-2.69 h 16.56 c 0.75,0 1.29,0.17 1.63,0.5 0.33,0.34 0.5,0.88 0.5,1.63 v 7.04 c 0,0.75 -0.17,1.29 -0.5,1.63 -0.34,0.34 -0.88,0.5 -1.63,0.5 h -13.78 c -0.51,0 -0.91,0.17 -1.18,0.5 -0.28,0.34 -0.41,0.76 -0.41,1.27 0,0.51 0.14,0.95 0.41,1.3 0.28,0.35 0.67,0.55 1.18,0.59 l 8.81,1.18 c 2.76,0.39 4.99,1.3 6.68,2.72 1.7,1.42 2.54,3.78 2.54,7.1 v 6.21 c 0,3.35 -1.09,5.92 -3.28,7.72 -2.19,1.8 -5.18,2.69 -8.96,2.69 z"
|
||||
id="path28"
|
||||
style="fill:#dedede;fill-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
id="g34"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)">
|
||||
<path
|
||||
class="st0"
|
||||
d="m 399.59,375.57 c -75.1,0 -136.2,61.1 -136.2,136.2 0,75.1 61.1,136.2 136.2,136.2 27.74,0 53.37,-8.55 74.84,-22.9 -1.35,-1.17 -2.61,-2.4 -3.8,-3.72 -20.44,13.4 -44.79,21.29 -71.04,21.29 -72.16,0 -130.87,-58.71 -130.87,-130.87 0,-72.16 58.71,-130.87 130.87,-130.87 47.14,0 88.35,25.15 111.36,62.66 h 6.25 C 493.61,402.98 449.81,375.57 399.59,375.57 Z"
|
||||
id="path32" />
|
||||
</g>
|
||||
<g
|
||||
id="g38"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)">
|
||||
<path
|
||||
class="st1"
|
||||
d="m 535.7,566.96 c -21.87,53.75 -74.6,91.78 -136.11,91.78 -81.04,0 -146.98,-65.94 -146.98,-146.98 0,-81.04 65.94,-146.98 146.98,-146.98 29.26,0 56.5,8.66 79.43,23.45 l 3.7,-3.7 c -23.92,-15.69 -52.44,-24.91 -83.13,-24.91 -83.89,0 -152.14,68.24 -152.14,152.14 0,83.9 68.24,152.14 152.14,152.14 64.42,0 119.58,-40.26 141.72,-96.94 z"
|
||||
id="path36" />
|
||||
</g>
|
||||
<g
|
||||
id="g42"
|
||||
transform="matrix(0.32162395,0,0,0.33123626,-75.234275,-114.64087)"
|
||||
inkscape:export-filename="./g42.svg"
|
||||
inkscape:export-xdpi="169.6163"
|
||||
inkscape:export-ydpi="169.6163">
|
||||
<path
|
||||
class="st0"
|
||||
d="m 399.59,672.5 c -88.63,0 -160.73,-72.11 -160.73,-160.73 0,-88.62 72.11,-160.73 160.73,-160.73 75.66,0 139.05,52.63 156.04,123.16 h 5.06 C 543.61,400.93 478,346.1 399.6,346.1 c -91.36,0 -165.68,74.32 -165.68,165.68 0,91.36 74.32,165.68 165.68,165.68 56.18,0 105.83,-28.17 135.77,-71.07 -1.21,-1.21 -2.42,-2.42 -3.64,-3.63 -29,42.03 -77.32,69.74 -132.14,69.74 z"
|
||||
id="path40" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
611
app/sovran_systemsos_web/static/onboarding.js
Normal file
@@ -0,0 +1,611 @@
|
||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
||||
Drives the 5-step post-install setup flow. */
|
||||
"use strict";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
|
||||
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
|
||||
// Step 2 (password) is NEVER skipped — all roles need it.
|
||||
const ROLE_SKIP_STEPS = {
|
||||
"desktop": [3, 4],
|
||||
"node": [3, 4],
|
||||
};
|
||||
|
||||
// ── Role state (loaded at init) ───────────────────────────────────
|
||||
|
||||
var _onboardingRole = "server_plus_desktop";
|
||||
|
||||
// Password default state (loaded at step 2)
|
||||
var _passwordIsDefault = true;
|
||||
|
||||
// Domains that may need configuration, with service unit mapping for enabled check
|
||||
const DOMAIN_DEFS = [
|
||||
{ name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true },
|
||||
{ name: "haven", label: "Haven Nostr Relay", unit: "haven-relay.service", needsDdns: true },
|
||||
{ name: "element-calling", label: "Element Video/Audio Calling", unit: "livekit.service", needsDdns: true },
|
||||
{ name: "vaultwarden", label: "Vaultwarden (Password Vault)", unit: "vaultwarden.service", needsDdns: true },
|
||||
{ name: "btcpayserver", label: "BTCPay Server", unit: "btcpayserver.service", needsDdns: true },
|
||||
{ name: "nextcloud", label: "Nextcloud", unit: "phpfpm-nextcloud.service", needsDdns: true },
|
||||
{ name: "wordpress", label: "WordPress", unit: "phpfpm-wordpress.service", needsDdns: true },
|
||||
];
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
|
||||
var _currentStep = 1;
|
||||
var _servicesData = null;
|
||||
var _domainsData = null;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function apiFetch(path, options) {
|
||||
var res = await fetch(path, options || {});
|
||||
if (!res.ok) {
|
||||
var detail = res.status + " " + res.statusText;
|
||||
try {
|
||||
var body = await res.json();
|
||||
if (body && body.detail) detail = body.detail;
|
||||
} catch (e) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function setStatus(elId, msg, type) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
|
||||
}
|
||||
|
||||
// ── Progress / step navigation ────────────────────────────────────
|
||||
|
||||
function updateProgress(step) {
|
||||
var fill = document.getElementById("onboarding-progress-fill");
|
||||
if (fill) {
|
||||
fill.style.width = Math.round(((step - 1) / (TOTAL_STEPS - 1)) * 100) + "%";
|
||||
}
|
||||
var dots = document.querySelectorAll(".onboarding-step-dot");
|
||||
dots.forEach(function(dot) {
|
||||
var ds = parseInt(dot.dataset.step, 10);
|
||||
dot.classList.remove("active", "completed");
|
||||
if (ds < step) dot.classList.add("completed");
|
||||
if (ds === step) dot.classList.add("active");
|
||||
});
|
||||
}
|
||||
|
||||
function showStep(step) {
|
||||
for (var i = 1; i <= TOTAL_STEPS; i++) {
|
||||
var panel = document.getElementById("step-" + i);
|
||||
if (panel) panel.style.display = (i === step) ? "" : "none";
|
||||
}
|
||||
_currentStep = step;
|
||||
updateProgress(step);
|
||||
|
||||
// Lazy-load step content
|
||||
if (step === 2) loadStep2();
|
||||
if (step === 3) loadStep3();
|
||||
if (step === 4) loadStep4();
|
||||
// Step 5 (Complete) is static — no lazy-load needed
|
||||
}
|
||||
|
||||
// Return the next step number, skipping over role-excluded steps
|
||||
function nextStep(current) {
|
||||
var skip = ROLE_SKIP_STEPS[_onboardingRole] || [];
|
||||
var next = current + 1;
|
||||
while (next < TOTAL_STEPS && skip.indexOf(next) !== -1) next++;
|
||||
return next;
|
||||
}
|
||||
|
||||
// Return the previous step number, skipping over role-excluded steps
|
||||
function prevStep(current) {
|
||||
var skip = ROLE_SKIP_STEPS[_onboardingRole] || [];
|
||||
var prev = current - 1;
|
||||
while (prev > 1 && skip.indexOf(prev) !== -1) prev--;
|
||||
return prev;
|
||||
}
|
||||
|
||||
// ── Step 1: Welcome ───────────────────────────────────────────────
|
||||
|
||||
async function loadStep1() {
|
||||
try {
|
||||
var cfg = await apiFetch("/api/config");
|
||||
var badge = document.getElementById("onboarding-role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Step 2: Create Your Password ─────────────────────────────────
|
||||
|
||||
async function loadStep2() {
|
||||
var body = document.getElementById("step-2-body");
|
||||
if (!body) return;
|
||||
|
||||
var nextBtn = document.getElementById("step-2-next");
|
||||
|
||||
try {
|
||||
var result = await apiFetch("/api/security/password-is-default");
|
||||
_passwordIsDefault = result.is_default !== false;
|
||||
} catch (_) {
|
||||
_passwordIsDefault = true;
|
||||
}
|
||||
|
||||
if (_passwordIsDefault) {
|
||||
// Factory-sealed scenario: password must be set before continuing
|
||||
if (nextBtn) nextBtn.textContent = "Set Password & Continue \u2192";
|
||||
|
||||
body.innerHTML =
|
||||
'<div class="onboarding-password-group">' +
|
||||
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
|
||||
'<div class="onboarding-password-input-wrap">' +
|
||||
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
|
||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
|
||||
'</div>' +
|
||||
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
|
||||
'</div>' +
|
||||
'<div class="onboarding-password-group">' +
|
||||
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
|
||||
'<div class="onboarding-password-input-wrap">' +
|
||||
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
|
||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>';
|
||||
|
||||
// Wire show/hide toggles
|
||||
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
var inp = document.getElementById(btn.dataset.target);
|
||||
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
|
||||
});
|
||||
});
|
||||
|
||||
} else {
|
||||
// DIY install scenario: password already set by installer
|
||||
if (nextBtn) nextBtn.textContent = "Continue \u2192";
|
||||
|
||||
body.innerHTML =
|
||||
'<div class="onboarding-password-success">✅ Your password was already set during installation.</div>' +
|
||||
'<details class="onboarding-password-optional">' +
|
||||
'<summary>Change it anyway</summary>' +
|
||||
'<div style="margin-top:14px;">' +
|
||||
'<div class="onboarding-password-group">' +
|
||||
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
|
||||
'<div class="onboarding-password-input-wrap">' +
|
||||
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
|
||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
|
||||
'</div>' +
|
||||
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
|
||||
'</div>' +
|
||||
'<div class="onboarding-password-group">' +
|
||||
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
|
||||
'<div class="onboarding-password-input-wrap">' +
|
||||
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
|
||||
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>' +
|
||||
'</div>' +
|
||||
'</details>';
|
||||
|
||||
// Wire show/hide toggles
|
||||
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
var inp = document.getElementById(btn.dataset.target);
|
||||
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStep2() {
|
||||
var newPw = document.getElementById("pw-new");
|
||||
var confirmPw = document.getElementById("pw-confirm");
|
||||
|
||||
// If no fields visible or both empty and password already set → skip
|
||||
if (!newPw || !newPw.value.trim()) {
|
||||
if (!_passwordIsDefault) return true; // already set, no change requested
|
||||
setStatus("step-2-status", "⚠ Please enter a password.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
var pw = newPw.value;
|
||||
var cpw = confirmPw ? confirmPw.value : "";
|
||||
|
||||
if (pw.length < 8) {
|
||||
setStatus("step-2-status", "⚠ Password must be at least 8 characters.", "error");
|
||||
return false;
|
||||
}
|
||||
if (pw !== cpw) {
|
||||
setStatus("step-2-status", "⚠ Passwords do not match.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus("step-2-status", "Saving password…", "info");
|
||||
try {
|
||||
await apiFetch("/api/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus("step-2-status", "⚠ " + err.message, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus("step-2-status", "✓ Password saved", "ok");
|
||||
_passwordIsDefault = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 3: Domain Configuration ─────────────────────────────────
|
||||
|
||||
async function loadStep3() {
|
||||
var body = document.getElementById("step-3-body");
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
// Fetch services, domains, and network info in parallel
|
||||
var results = await Promise.all([
|
||||
apiFetch("/api/services"),
|
||||
apiFetch("/api/domains/status"),
|
||||
apiFetch("/api/network"),
|
||||
]);
|
||||
_servicesData = results[0];
|
||||
_domainsData = results[1];
|
||||
var networkData = results[2];
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load service data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
|
||||
|
||||
// Build set of enabled service units
|
||||
var enabledUnits = new Set();
|
||||
(_servicesData || []).forEach(function(svc) {
|
||||
if (svc.enabled) enabledUnits.add(svc.unit);
|
||||
});
|
||||
|
||||
// Filter domain defs to only those whose service is enabled
|
||||
var relevantDomains = DOMAIN_DEFS.filter(function(d) {
|
||||
return enabledUnits.has(d.unit);
|
||||
});
|
||||
|
||||
var html = "";
|
||||
|
||||
if (relevantDomains.length === 0) {
|
||||
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||
} else {
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ '<strong>Before you continue:</strong>'
|
||||
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
|
||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a></li>'
|
||||
+ '<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>'
|
||||
+ '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
|
||||
+ '<span style="display:inline-block;margin-top:4px;padding:4px 12px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1.1em;font-weight:700;letter-spacing:0.03em;">' + escHtml(externalIp) + '</span></li>'
|
||||
+ '<li>Njal.la will give you a curl command like:<br>'
|
||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>'
|
||||
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
|
||||
+ '</ol>'
|
||||
+ '</div>';
|
||||
html += '<p class="onboarding-hint">Enter each fully-qualified subdomain (e.g. <code>matrix.yourdomain.com</code>) and its Njal.la DDNS curl command.</p>';
|
||||
relevantDomains.forEach(function(d) {
|
||||
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
||||
html += '<div class="onboarding-domain-group">';
|
||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=' + escHtml(d.name) + '.yourdomain.com&k=abc123&auto"" />';
|
||||
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
|
||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// SSL email section
|
||||
var emailVal = (_domainsData && _domainsData["sslemail"]) || "";
|
||||
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
||||
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
|
||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
html += '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
// Wire per-field save buttons for domains
|
||||
body.querySelectorAll('[data-save-domain]').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
var domainName = btn.dataset.saveDomain;
|
||||
var domainInput = document.getElementById('domain-input-' + domainName);
|
||||
var ddnsInput = document.getElementById('ddns-input-' + domainName);
|
||||
var statusEl = document.getElementById('domain-save-status-' + domainName);
|
||||
var domainVal = domainInput ? domainInput.value.trim() : '';
|
||||
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : '';
|
||||
|
||||
if (!domainVal) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ Enter a domain first'; statusEl.style.color = 'var(--red)'; }
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
if (statusEl) { statusEl.textContent = ''; }
|
||||
|
||||
try {
|
||||
await apiFetch('/api/domains/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
|
||||
});
|
||||
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save';
|
||||
});
|
||||
});
|
||||
|
||||
// Wire save button for SSL email
|
||||
body.querySelectorAll('[data-save-email]').forEach(function(btn) {
|
||||
btn.addEventListener('click', async function() {
|
||||
var emailInput = document.getElementById('ssl-email-input');
|
||||
var statusEl = document.getElementById('domain-save-status-email');
|
||||
var emailVal = emailInput ? emailInput.value.trim() : '';
|
||||
|
||||
if (!emailVal) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ Enter an email first'; statusEl.style.color = 'var(--red)'; }
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
if (statusEl) { statusEl.textContent = ''; }
|
||||
|
||||
try {
|
||||
await apiFetch('/api/domains/set-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailVal }),
|
||||
});
|
||||
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveStep3() {
|
||||
setStatus("step-3-status", "Saving domains…", "info");
|
||||
var errors = [];
|
||||
|
||||
// Save each domain input
|
||||
var domainInputs = document.querySelectorAll("[data-domain]");
|
||||
for (var i = 0; i < domainInputs.length; i++) {
|
||||
var inp = domainInputs[i];
|
||||
var domainName = inp.dataset.domain;
|
||||
var domainVal = inp.value.trim();
|
||||
if (!domainVal) continue; // skip empty — not required
|
||||
|
||||
var ddnsInput = document.getElementById("ddns-input-" + domainName);
|
||||
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : "";
|
||||
|
||||
try {
|
||||
await apiFetch("/api/domains/set", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push(domainName + ": " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Save SSL email
|
||||
var emailInput = document.getElementById("ssl-email-input");
|
||||
if (emailInput && emailInput.value.trim()) {
|
||||
try {
|
||||
await apiFetch("/api/domains/set-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: emailInput.value.trim() }),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push("SSL email: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
setStatus("step-3-status", "✓ Saved", "ok");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 4: Port Forwarding ───────────────────────────────────────
|
||||
|
||||
async function loadStep4() {
|
||||
var body = document.getElementById("step-4-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
||||
|
||||
var networkData = null;
|
||||
|
||||
try {
|
||||
networkData = await apiFetch("/api/network");
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load network data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var internalIp = (networkData && networkData.internal_ip) || "unknown";
|
||||
|
||||
var ip = escHtml(internalIp);
|
||||
|
||||
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
|
||||
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
||||
+ '</p>';
|
||||
|
||||
html += '<div class="onboarding-port-ip">';
|
||||
html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>';
|
||||
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Required ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Ports — open these on your router:</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP</td></tr>';
|
||||
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">SSH Remote Access</td></tr>';
|
||||
html += '<tr><td class="port-req-port">8448</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Matrix Federation</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Optional ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</div>';
|
||||
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">These 5 additional port openings are required on top of the 4 required ports above.</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
|
||||
html += '<tr><td class="port-req-port">7882–7894</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media streams</td></tr>';
|
||||
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">30000–40000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Totals
|
||||
html += '<div class="onboarding-port-totals">';
|
||||
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
|
||||
html += '<strong>Total port openings: 9</strong> (with Element Calling — 4 required + 5 optional)';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ '⚠ <strong>Ports 80 and 443 must be forwarded first.</strong> '
|
||||
+ 'Caddy uses these to obtain SSL certificates from Let\'s Encrypt. '
|
||||
+ 'If they are closed, HTTPS will not work and your services will be unreachable from outside your network.'
|
||||
+ '</div>';
|
||||
|
||||
html += '<details class="onboarding-port-details" style="margin-bottom:16px;">'
|
||||
+ '<summary class="onboarding-port-details-summary">How to set up port forwarding</summary>'
|
||||
+ '<ol style="margin:12px 0 0 16px; padding:0; line-height:1.8;">'
|
||||
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
|
||||
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
|
||||
+ '<li>Create a new rule for each port listed above</li>'
|
||||
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
|
||||
+ '<li>Set both internal and external port to the same number</li>'
|
||||
+ '<li>Save and apply changes</li>'
|
||||
+ '</ol>'
|
||||
+ '</details>';
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Step 5: Complete ──────────────────────────────────────────────
|
||||
|
||||
async function completeOnboarding() {
|
||||
var btn = document.getElementById("step-5-finish");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||
|
||||
try {
|
||||
await apiFetch("/api/onboarding/complete", { method: "POST" });
|
||||
} catch (_) {
|
||||
// Even if this fails, navigate to dashboard
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
// ── Event wiring ──────────────────────────────────────────────────
|
||||
|
||||
function wireNavButtons() {
|
||||
// Step 1 → next
|
||||
var s1next = document.getElementById("step-1-next");
|
||||
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
|
||||
|
||||
// Step 2 → 3 (save password first)
|
||||
var s2next = document.getElementById("step-2-next");
|
||||
if (s2next) s2next.addEventListener("click", async function() {
|
||||
s2next.disabled = true;
|
||||
var origText = s2next.textContent;
|
||||
s2next.textContent = "Saving…";
|
||||
var ok = await saveStep2();
|
||||
s2next.disabled = false;
|
||||
s2next.textContent = origText;
|
||||
if (ok) showStep(nextStep(2));
|
||||
});
|
||||
|
||||
// Step 3 → 4 (save domains first)
|
||||
var s3next = document.getElementById("step-3-next");
|
||||
if (s3next) s3next.addEventListener("click", async function() {
|
||||
s3next.disabled = true;
|
||||
s3next.textContent = "Saving…";
|
||||
await saveStep3();
|
||||
s3next.disabled = false;
|
||||
s3next.textContent = "Save & Continue →";
|
||||
showStep(nextStep(3));
|
||||
});
|
||||
|
||||
// Step 4 → 5 (Complete)
|
||||
var s4next = document.getElementById("step-4-next");
|
||||
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
|
||||
|
||||
// Step 5: finish
|
||||
var s5finish = document.getElementById("step-5-finish");
|
||||
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
|
||||
|
||||
// Back buttons
|
||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||
var prev = parseInt(btn.dataset.prev, 10);
|
||||
btn.addEventListener("click", function() { showStep(prevStep(prev + 1)); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async function() {
|
||||
// If onboarding is already complete, go to dashboard
|
||||
try {
|
||||
var status = await apiFetch("/api/onboarding/status");
|
||||
if (status.complete) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Load role so step-skipping is applied before wiring nav buttons
|
||||
try {
|
||||
var cfg = await apiFetch("/api/config");
|
||||
if (cfg.role) _onboardingRole = cfg.role;
|
||||
} catch (_) {}
|
||||
|
||||
wireNavButtons();
|
||||
updateProgress(1);
|
||||
loadStep1();
|
||||
});
|
||||
54
app/sovran_systemsos_web/static/sovran-hub-icon.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -1,962 +0,0 @@
|
||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||
Dark theme matching the Adwaita dark aesthetic
|
||||
v6 — Status-only tiles (no controls) */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #1e1e2e;
|
||||
--surface-color: #2a2a3c;
|
||||
--card-color: #313244;
|
||||
--border-color: #45475a;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #a6adc8;
|
||||
--text-dim: #6c7086;
|
||||
--accent-color: #89b4fa;
|
||||
--green: #2ec27e;
|
||||
--yellow: #e5a50a;
|
||||
--red: #e01b24;
|
||||
--grey: #888888;
|
||||
--radius-card: 18px;
|
||||
--radius-btn: 8px;
|
||||
--shadow-card: 0 2px 8px rgba(0,0,0,0.4);
|
||||
--shadow-hover: 0 6px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||
|
||||
.header-bar {
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 10px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-bar .title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────── */
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Update System button: BLUE by default */
|
||||
.btn-update {
|
||||
background-color: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-update:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
/* Update System button: GREEN when updates are available */
|
||||
.btn-update.has-updates {
|
||||
background-color: #2ec27e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-update.has-updates:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
display: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--yellow);
|
||||
border-radius: 50%;
|
||||
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.update-badge.visible {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.35); }
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||
|
||||
.ip-bar {
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ip-bar .ip-label {
|
||||
color: var(--text-dim);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.ip-bar .ip-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ip-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
/* ── Main content ───────────────────────────────────────────────── */
|
||||
|
||||
.main-content {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
/* ── Category sections ──────────────────────────────────────────── */
|
||||
|
||||
.category-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Service tile card (status-only) ─────────────────────────────── */
|
||||
|
||||
.service-tile {
|
||||
width: 160px;
|
||||
min-height: 150px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 12px 18px;
|
||||
gap: 0;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.service-tile:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: #6c7086;
|
||||
}
|
||||
|
||||
.service-tile.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Info badge on tiles with credentials */
|
||||
.tile-info-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color);
|
||||
color: #1e1e2e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.15s, background-color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tile-info-btn:hover {
|
||||
transform: scale(1.15);
|
||||
background-color: #a8c8ff;
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-icon-fallback {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
max-width: 140px;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
min-height: 1.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tile-status {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--grey);
|
||||
}
|
||||
|
||||
.status-dot.active { background-color: var(--green); }
|
||||
.status-dot.inactive { background-color: var(--red); }
|
||||
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
|
||||
.status-dot.failed { background-color: var(--red); }
|
||||
.status-dot.disabled { background-color: var(--grey); }
|
||||
|
||||
/* ── Update modal ─────────────────────────────────<E29480><E29480><EFBFBD>─────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0,0,0,0.65);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2.5px solid var(--border-color);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-spinner.spinning {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.modal-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background-color: #12121c;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Reboot = GREEN */
|
||||
.modal-footer .btn-reboot,
|
||||
button.btn-reboot {
|
||||
background-color: #2ec27e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-footer .btn-reboot:hover:not(:disabled),
|
||||
button.btn-reboot:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: var(--yellow);
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background-color: #c98d08;
|
||||
}
|
||||
|
||||
.btn-close-modal {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-close-modal:hover:not(:disabled) {
|
||||
background-color: #5a5c72;
|
||||
}
|
||||
|
||||
/* ── Credentials info modal ──────────────────────────────────────── */
|
||||
|
||||
.creds-dialog {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 90vw;
|
||||
max-width: 700px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
|
||||
animation: creds-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes creds-fade-in {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.creds-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 28px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.creds-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.creds-close-btn {
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.3rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.creds-close-btn:hover {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.creds-body {
|
||||
padding: 24px 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.creds-loading {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.creds-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.creds-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.creds-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.creds-value-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.creds-value {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
background-color: #12121c;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.creds-copy-btn {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.creds-copy-btn:hover {
|
||||
background-color: #5a5c72;
|
||||
}
|
||||
|
||||
.creds-copy-btn.copied {
|
||||
background-color: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.creds-empty {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* ── Credential links ────────────────────────────────────────────── */
|
||||
|
||||
.creds-link {
|
||||
color: #b8f0c0;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.creds-link:hover {
|
||||
text-decoration: underline;
|
||||
color: #defce6;
|
||||
}
|
||||
|
||||
/* ── QR code in credentials modal ────────────────────────────────── */
|
||||
|
||||
.creds-qr-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.creds-qr-img {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
border-radius: 12px;
|
||||
border: 4px solid #fff;
|
||||
background-color: #fff;
|
||||
image-rendering: pixelated;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.creds-qr-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Reboot overlay ─────────────────────────────────────────────── */
|
||||
|
||||
.reboot-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(15, 15, 25, 0.92);
|
||||
z-index: 999;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reboot-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reboot-card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 48px 56px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
|
||||
animation: reboot-fade-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes reboot-fade-in {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.reboot-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 16px;
|
||||
animation: reboot-spin 2s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes reboot-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.reboot-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reboot-message {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reboot-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reboot-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color);
|
||||
animation: reboot-bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.reboot-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.reboot-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes reboot-bounce {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.reboot-submessage {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header-bar {
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-bar .title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.ip-bar {
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.main-content {
|
||||
padding: 16px 12px 40px;
|
||||
}
|
||||
.tiles-grid {
|
||||
justify-content: center;
|
||||
}
|
||||
.service-tile {
|
||||
width: 140px;
|
||||
min-height: 130px;
|
||||
}
|
||||
.reboot-card {
|
||||
padding: 36px 28px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
.creds-dialog {
|
||||
margin: 0 12px;
|
||||
}
|
||||
.creds-qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
/* ── Tech Support tile ───────────────────────────────────────────── */
|
||||
|
||||
.support-tile {
|
||||
border-color: var(--accent-color);
|
||||
border-width: 2px;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.support-tile:hover {
|
||||
border-color: #a8c8ff;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.support-status-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Tech Support modal content ──────────────────────────────────── */
|
||||
|
||||
.support-section {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.support-icon-big {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.support-heading {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-active-heading {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.support-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
max-width: 480px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.support-info-box {
|
||||
background-color: #12121c;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin: 0 auto 20px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.support-active-box {
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
|
||||
.support-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.support-info-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.support-info-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.92rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.support-info-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.support-steps {
|
||||
text-align: left;
|
||||
max-width: 420px;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.support-steps-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.support-steps ol {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.support-btn-enable {
|
||||
background-color: var(--green);
|
||||
color: #fff;
|
||||
padding: 12px 32px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.support-btn-enable:hover:not(:disabled) {
|
||||
background-color: #27ae6e;
|
||||
}
|
||||
|
||||
.support-btn-disable {
|
||||
background-color: var(--red);
|
||||
color: #fff;
|
||||
padding: 12px 32px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.support-btn-disable:hover:not(:disabled) {
|
||||
background-color: #c41520;
|
||||
}
|
||||
|
||||
.support-active-note {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
max-width: 420px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.support-fine-print {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.support-verify-box {
|
||||
background-color: #12121c;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin: 20px auto;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.support-verify-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.support-verify-value {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-verify-value.verified-gone {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.support-verify-value.verify-warning {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.support-btn-done {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 28px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.support-btn-done:hover:not(:disabled) {
|
||||
background-color: #5a5c72;
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,19 +4,27 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS Hub</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v={{ style_css_hash }}" />
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||
<link rel="stylesheet" href="/static/css/header.css" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||
<link rel="stylesheet" href="/static/css/features.css" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
||||
<link rel="stylesheet" href="/static/css/support.css" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
||||
<link rel="stylesheet" href="/static/css/security.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header bar -->
|
||||
<header class="header-bar">
|
||||
<img src="/static/logo-light.svg" alt="Sovran Systems" class="header-logo" />
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
<span class="role-badge" id="role-badge">Loading…</span>
|
||||
<button class="btn btn-update" id="btn-update" title="Run system update">
|
||||
<span class="update-badge" id="update-badge"></span>
|
||||
Update System
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-refresh" title="Refresh service status">↺</button>
|
||||
<div class="header-buttons">
|
||||
<span class="role-badge" id="role-badge">Loading…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- IP bar -->
|
||||
@@ -34,6 +42,10 @@
|
||||
|
||||
<!-- Service tiles -->
|
||||
<main class="main-content">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div id="sidebar-support"></div>
|
||||
<div id="sidebar-features"></div>
|
||||
</aside>
|
||||
<div id="tiles-area"></div>
|
||||
</main>
|
||||
|
||||
@@ -80,6 +92,124 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Setup Modal -->
|
||||
<div class="modal-overlay" id="domain-setup-modal" role="dialog" aria-modal="true" aria-labelledby="domain-setup-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="domain-setup-title">🌐 Domain Setup</span>
|
||||
<button class="creds-close-btn" id="domain-setup-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="domain-setup-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSL Email Modal -->
|
||||
<div class="modal-overlay" id="ssl-email-modal" role="dialog" aria-modal="true" aria-labelledby="ssl-email-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="ssl-email-title">📧 SSL Certificate Email</span>
|
||||
<button class="creds-close-btn" id="ssl-email-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<p class="support-desc">Let's Encrypt needs an email address for SSL certificate notifications.</p>
|
||||
<div class="domain-field-group">
|
||||
<label class="domain-field-label" for="ssl-email-input">Email:</label>
|
||||
<input class="domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" />
|
||||
</div>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="ssl-email-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="ssl-email-save-btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Confirm Modal -->
|
||||
<div class="modal-overlay" id="feature-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="feature-confirm-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="feature-confirm-title">Confirm Action</span>
|
||||
<button class="creds-close-btn" id="feature-confirm-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<p class="support-desc" id="feature-confirm-message"></p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="feature-confirm-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="feature-confirm-ok-btn">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Port Requirements Modal -->
|
||||
<div class="modal-overlay" id="port-requirements-modal" role="dialog" aria-modal="true" aria-labelledby="port-req-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="port-req-title">🔌 Router / Firewall Port Requirements</span>
|
||||
<button class="creds-close-btn" id="port-req-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="port-req-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rebuild Modal -->
|
||||
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||
<div class="modal-spinner" id="rebuild-spinner"></div>
|
||||
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
|
||||
</div>
|
||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade Modal (Node → Server+Desktop) -->
|
||||
<div class="modal-overlay" id="upgrade-modal" role="dialog" aria-modal="true" aria-labelledby="upgrade-modal-title">
|
||||
<div class="creds-dialog upgrade-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="upgrade-modal-title">🚀 Upgrade to Server + Desktop</span>
|
||||
<button class="creds-close-btn" id="upgrade-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<p class="support-desc">
|
||||
Upgrading to the full <strong>Server + Desktop</strong> experience will unlock all services —
|
||||
encrypted messaging, password management, cloud storage, website hosting, and more.
|
||||
</p>
|
||||
<div class="upgrade-info-box">
|
||||
<p class="upgrade-info-title">⚠ What you should know:</p>
|
||||
<ul class="upgrade-info-list">
|
||||
<li>You will need to purchase domains for your services (we recommend <a href="https://njal.la" target="_blank" rel="noopener noreferrer">njal.la</a>)</li>
|
||||
<li>Some services require ports to be opened on your router</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="upgrade-info-box">
|
||||
<p class="upgrade-info-title">ℹ️ Good to know:</p>
|
||||
<ul class="upgrade-info-list">
|
||||
<li>Your services will be accessible via your home internet connection. Your approximate geographic area may be visible through DNS records — this is normal for all self-hosted services</li>
|
||||
<li>Your Bitcoin node remains fully private over Tor</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="support-desc">
|
||||
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
|
||||
Domain setup, port forwarding, and configuration are all guided.
|
||||
</p>
|
||||
<p class="support-desc upgrade-rebuild-note">
|
||||
The system will rebuild after upgrading. This may take several minutes.
|
||||
</p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="upgrade-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-primary" id="upgrade-confirm-btn">Yes, Upgrade</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reboot overlay -->
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<div class="reboot-card">
|
||||
@@ -98,6 +228,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js?v={{ app_js_hash }}"></script>
|
||||
<script src="/static/js/constants.js"></script>
|
||||
<script src="/static/js/state.js"></script>
|
||||
<script src="/static/js/helpers.js"></script>
|
||||
<script src="/static/js/tiles.js"></script>
|
||||
<script src="/static/js/service-detail.js"></script>
|
||||
<script src="/static/js/support.js"></script>
|
||||
<script src="/static/js/update.js"></script>
|
||||
<script src="/static/js/rebuild.js"></script>
|
||||
<script src="/static/js/features.js"></script>
|
||||
<script src="/static/js/security.js"></script>
|
||||
<script src="/static/js/events.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
172
app/sovran_systemsos_web/templates/onboarding.html
Normal file
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS — First-Boot Setup</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||
<link rel="stylesheet" href="/static/css/header.css" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||
<link rel="stylesheet" href="/static/css/features.css" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
||||
<link rel="stylesheet" href="/static/css/support.css" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
||||
</head>
|
||||
<body class="onboarding-body">
|
||||
|
||||
<!-- Onboarding wizard container -->
|
||||
<div class="onboarding-shell">
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="onboarding-progress-bar">
|
||||
<div class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="onboarding-steps-nav" id="onboarding-steps-nav">
|
||||
<span class="onboarding-step-dot" data-step="1">1</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="2">2</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="3">3</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="4">4</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="5">5</span>
|
||||
</div>
|
||||
|
||||
<!-- Step panels -->
|
||||
<div class="onboarding-panel-wrap">
|
||||
|
||||
<!-- ── Step 1: Welcome ── -->
|
||||
<div class="onboarding-panel" id="step-1">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">
|
||||
<img src="/static/logo-light.svg" alt="Sovran Systems" class="onboarding-logo-img" />
|
||||
</div>
|
||||
<h1 class="onboarding-title">Welcome to Sovran_SystemsOS!</h1>
|
||||
<p class="onboarding-subtitle">Be Digitally Sovereign</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<p class="onboarding-body-text">
|
||||
Your system is installed and ready to configure. This wizard will guide
|
||||
you through the final setup steps so everything works perfectly.
|
||||
</p>
|
||||
<div class="onboarding-role-row" id="onboarding-role-row">
|
||||
<span class="onboarding-role-label">Your Role:</span>
|
||||
<span class="onboarding-role-badge" id="onboarding-role-badge">Loading…</span>
|
||||
</div>
|
||||
<p class="onboarding-body-text onboarding-body-text--dim">
|
||||
This setup only takes a few minutes. You can always revisit these
|
||||
settings from the main Hub dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<div></div>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-1-next">
|
||||
Let's Go →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 2: Create Your Password ── -->
|
||||
<div class="onboarding-panel" id="step-2" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔒</span>
|
||||
<h2 class="onboarding-step-title">Create Your Password</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Choose a strong password for your <strong>'free'</strong> user account.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-2-body">
|
||||
<p class="onboarding-loading">Checking password status…</p>
|
||||
</div>
|
||||
<div id="step-2-status" class="onboarding-save-status"></div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
|
||||
Set Password & Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 3: Domain Configuration ── -->
|
||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🌐</span>
|
||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
|
||||
First, create an account at <strong>Njal.la</strong> and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
|
||||
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
|
||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-3-body">
|
||||
<p class="onboarding-loading">Loading service information…</p>
|
||||
</div>
|
||||
<div id="step-3-status" class="onboarding-save-status"></div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
||||
Save & Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 4: Port Forwarding ── -->
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔌</span>
|
||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services.
|
||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-4-body">
|
||||
<p class="onboarding-loading">Checking ports…</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
||||
Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 5: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-5" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">✅</div>
|
||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||
<p class="onboarding-subtitle">Setup complete</p>
|
||||
</div>
|
||||
<div class="onboarding-card">
|
||||
<p class="onboarding-body-text">
|
||||
All configuration steps are done. Head to the main Hub dashboard to
|
||||
monitor your services, manage credentials, and make changes at any time.
|
||||
</p>
|
||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||
<li>✅ Password configured</li>
|
||||
<li>✅ Domain configuration saved</li>
|
||||
<li>✅ Port forwarding reviewed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
||||
<button class="btn btn-primary" id="step-5-finish">
|
||||
Go to Dashboard →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /panel-wrap -->
|
||||
</div><!-- /shell -->
|
||||
|
||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,14 +28,24 @@
|
||||
};
|
||||
|
||||
# ── Networking ──────────────────────────────────────────────
|
||||
# NOTE: hostName must remain "nixos" to match the nixosConfigurations key in
|
||||
# flake.nix. Changing it breaks flake-based remote upgrades with:
|
||||
# error: does not provide attribute 'nixosConfigurations."<hostname>"'
|
||||
networking.hostName = "nixos";
|
||||
networking.networkmanager.enable = true;
|
||||
networking.firewall.enable = true;
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ];
|
||||
networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 49152; to = 65535; }
|
||||
];
|
||||
networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ];
|
||||
|
||||
# ── Avahi (mDNS) ───────────────────────────────────────────
|
||||
# Advertise as sovransystemsos.local on the LAN without changing the system
|
||||
# hostname (which must remain "nixos" for flake compatibility — see above).
|
||||
services.avahi = {
|
||||
enable = true;
|
||||
hostName = "sovransystemsos";
|
||||
nssmdns4 = true;
|
||||
publish = { enable = true; addresses = true; };
|
||||
};
|
||||
|
||||
# ── Locale / Time ──────────────────────────────────────────
|
||||
time.timeZone = "America/Los_Angeles";
|
||||
@@ -87,6 +97,7 @@
|
||||
nixpkgs.config.permittedInsecurePackages = [ "jitsi-meet-1.0.8043" ];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
nftables
|
||||
git wget fish htop btop
|
||||
gnomeExtensions.transparent-top-bar-adjustable-transparency
|
||||
gnomeExtensions.dash-to-dock
|
||||
@@ -157,24 +168,8 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
# ── Tor ────────────────────────────────────────────────────
|
||||
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
||||
|
||||
# ── SSH ────────────────────────────────────────────────────
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
KbdInteractiveAuthentication = false;
|
||||
PermitRootLogin = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Fail2Ban ───────────────────────────────────────────────
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
ignoreIP = [ "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" "8.8.8.8" ];
|
||||
};
|
||||
|
||||
# ── Garbage Collection ─────────────────────────────────────
|
||||
nix.gc = { automatic = true; dates = "weekly"; options = "--delete-older-than 7d"; };
|
||||
|
||||
system.stateVersion = "22.05";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,123 +5,37 @@
|
||||
# #
|
||||
# Sovran_SystemsOS — custom.nix #
|
||||
# #
|
||||
# This is YOUR configuration file. Edit it to customize #
|
||||
# which services and features run on your machine. #
|
||||
# Services, features, and roles are managed by the #
|
||||
# Sovran Hub. Any changes you make through the Hub #
|
||||
# will appear in a Hub Managed section added #
|
||||
# automatically below. #
|
||||
# #
|
||||
# If you want to add your own NixOS modules or #
|
||||
# configuration, place them here — outside of the #
|
||||
# Hub Managed section. #
|
||||
# #
|
||||
# After making changes, rebuild with: #
|
||||
# #
|
||||
# nixos-rebuild switch #
|
||||
# nixos-rebuild switch #
|
||||
# #
|
||||
###########################################################
|
||||
|
||||
# ─── Add your custom NixOS configuration below ───────────
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 1: CHOOSE YOUR ROLE
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# ─── Custom Caddy virtual hosts ──────────────────────────
|
||||
# Uncomment and edit below to add your own Caddy sites:
|
||||
#
|
||||
# Your initial role was selected during installation.
|
||||
# To CHANGE your role, uncomment exactly ONE of the lines below.
|
||||
# sovran_systemsOS.caddy.extraVirtualHosts = ''
|
||||
# mysite.example.com {
|
||||
# encode gzip zstd
|
||||
# root * /var/lib/www/mysite
|
||||
# php_fastcgi unix//run/phpfpm/mypool.sock
|
||||
# file_server browse
|
||||
# }
|
||||
#
|
||||
# Server+Desktop: Full server + desktop environment
|
||||
# Desktop Only: Desktop environment, no server services
|
||||
# Node (Bitcoin Only): Bitcoin ecosystem
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# anotherdomain.com {
|
||||
# reverse_proxy localhost:9090
|
||||
# }
|
||||
# '';
|
||||
|
||||
# sovran_systemsOS.roles.server_plus_desktop = true;
|
||||
# sovran_systemsOS.roles.desktop = true;
|
||||
# sovran_systemsOS.roles.node = true;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 2: SERVICES (default: ON)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# These are all ON by default in the Server+Desktop role.
|
||||
# Set any to "false" to disable it.
|
||||
#
|
||||
# ┌─────────────────────┬────────────────────────────────┐
|
||||
# │ Service │ What it does │
|
||||
# ├─────────────────────┼────────────────────────────────┤
|
||||
# │ synapse │ Matrix Synapse homeserver │
|
||||
# │ bitcoin │ Bitcoin ecosystem (bitcoind, │
|
||||
# │ │ electrs, lnd, rtl, btcpay) │
|
||||
# │ vaultwarden │ Vaultwarden password manager │
|
||||
# │ wordpress │ WordPress website │
|
||||
# │ nextcloud │ Nextcloud file hosting │
|
||||
# └─────────────────────┴────────────────────────────────┘
|
||||
#
|
||||
# Example — disable WordPress and Nextcloud:
|
||||
#
|
||||
# sovran_systemsOS.services.wordpress = false;
|
||||
# sovran_systemsOS.services.nextcloud = false;
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.services.wordpress = false;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 3: FEATURES (default: OFF)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# These are OFF by default. Set to "true" to enable.
|
||||
#
|
||||
# ┌─────────────────────┬────────────────────────────────┐
|
||||
# │ Feature │ What it does │
|
||||
# ├─────────────────────┼────────────────────────────────┤
|
||||
# │ haven │ Haven NOSTR relay & Blossom │
|
||||
# │ bip110 │ BIP-110 Bitcoin Better Money │
|
||||
# │ mempool │ Mempool.space block explorer │
|
||||
# │ element-calling │ LiveKit server for Matrix │
|
||||
# │ rdp │ GNOME Remote Desktop (RDP) │
|
||||
# │ bitcoin-core │ Bitcoin Core GUI desktop app │
|
||||
# └─────────────────────┴─────<E29480><E29480><EFBFBD>──────────────────────────┘
|
||||
#
|
||||
# Example — enable element video calling:
|
||||
#
|
||||
# sovran_systemsOS.features.element-calling = true;
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.features.element-calling = true;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 4: WEB EXPOSURE (default: ON)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# Controls whether Caddy serves this application to the web.
|
||||
# (Does not stop the application itself from running).
|
||||
#
|
||||
# ┌─────────────────────┬────────────────────────────────┐
|
||||
# │ Option │ Default │
|
||||
# ├─────────────────────┼────────────────────────────────┤
|
||||
# │ btcpayserver │ true (false in Node role) │
|
||||
# └─────────────────────┴────────────────────────────────┘
|
||||
#
|
||||
# Example — hide BTCPay from the web:
|
||||
#
|
||||
# sovran_systemsOS.web.btcpayserver = false;
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.web.btcpayserver = false;
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# STEP 5: NOSTR PUBLIC KEY (required for Haven)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
#
|
||||
# If you enabled Haven above, paste your npub here.
|
||||
# Haven will NOT start without a valid npub.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# sovran_systemsOS.nostr_npub = "npub1abc123...";
|
||||
#
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
# sovran_systemsOS.nostr_npub = "";
|
||||
|
||||
}
|
||||
}
|
||||
93
docs/manual-backup.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Sovran Hub — Manual Backup
|
||||
|
||||
The manual backup service copies critical system data from your Sovran Pro to an external USB drive, providing a third copy of your data (your Sovran Pro already maintains an automatic internal backup on its second drive).
|
||||
|
||||
Backups are written to:
|
||||
|
||||
```
|
||||
<USB drive>/Sovran_SystemsOS_Backup/<timestamp>/
|
||||
```
|
||||
|
||||
where `<timestamp>` is formatted as `YYYYMMDD_HHMMSS`.
|
||||
|
||||
---
|
||||
|
||||
## Backup Stages
|
||||
|
||||
The script always attempts all four stages, but skips stages that are irrelevant to the system's configured role (see [Per-Role Breakdown](#per-role-breakdown) below).
|
||||
|
||||
| Stage | Directory | Contents |
|
||||
|-------|-----------|----------|
|
||||
| **1/4 — NixOS config** | `/etc/nixos/` | Full NixOS system configuration: `role-state.nix`, `custom.nix`, flake files, and any other config managed by the Hub |
|
||||
| **2/4 — Secrets** | `/etc/nix-bitcoin-secrets`, `/var/lib/domains`, `/var/lib/secrets` | Bitcoin/LND secrets, domain configurations for all web services, and Hub state files |
|
||||
| **3/4 — Home directory** | `/home/` | All user home directories (`.cache/` and Trash are excluded) |
|
||||
| **4/4 — LND wallet data** | `/var/lib/lnd/` | Lightning Network node wallet and channel data (log files excluded) |
|
||||
|
||||
---
|
||||
|
||||
## Per-Role Breakdown
|
||||
|
||||
The script detects the system role at runtime by reading `/var/lib/sovran-hub/config.json` (falling back to `/etc/nixos/role-state.nix`) and adjusts its behaviour accordingly.
|
||||
|
||||
### Server + Desktop (default)
|
||||
|
||||
All services are enabled: Bitcoin, Matrix Synapse, Vaultwarden, WordPress, Nextcloud.
|
||||
|
||||
| Stage | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Stage 1 — NixOS config | ✅ Backed up | Full server configuration |
|
||||
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets, domain configs, and Hub state |
|
||||
| Stage 3 — Home directory | ✅ Backed up | Desktop user data |
|
||||
| Stage 4 — LND wallet | ✅ Backed up | Lightning wallet and channel data |
|
||||
|
||||
This produces the largest backup. All four stages generate meaningful data.
|
||||
|
||||
### Desktop Only
|
||||
|
||||
All server services are disabled (`bitcoin = false`, `synapse = false`, `vaultwarden = false`, `wordpress = false`, `nextcloud = false`). Only GNOME desktop is active.
|
||||
|
||||
| Stage | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Stage 1 — NixOS config | ✅ Backed up | Simpler config (no server services) |
|
||||
| Stage 2 — Secrets | ⚠️ Partial | `/etc/nix-bitcoin-secrets` is **skipped** (not applicable for Desktop Only role). `/var/lib/domains` and `/var/lib/secrets` (Hub state) are still backed up if present |
|
||||
| Stage 3 — Home directory | ✅ Backed up | **The most important data for this role** |
|
||||
| Stage 4 — LND wallet | ⏭️ Skipped | Explicitly skipped — not applicable for Desktop Only role |
|
||||
|
||||
This produces the smallest and fastest backup. Stages 1 and 3 are the primary sources of meaningful data.
|
||||
|
||||
### Node (Bitcoin-only)
|
||||
|
||||
Only the Bitcoin ecosystem is active: `bitcoind`, `electrs`, `lnd`, `rtl`, `btcpay`, `mempool`, and `bip110`. All other server services are disabled.
|
||||
|
||||
| Stage | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| Stage 1 — NixOS config | ✅ Backed up | Node-specific configuration |
|
||||
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets and Hub state. `/var/lib/domains` may be minimal (BTCPay runs but is not exposed via Caddy) |
|
||||
| Stage 3 — Home directory | ✅ Backed up | User data |
|
||||
| Stage 4 — LND wallet | ✅ Backed up | **Critical** — Lightning wallet and channel data |
|
||||
|
||||
All four stages run, matching Server + Desktop behaviour. The `/var/lib/domains` directory may be sparsely populated since non-Bitcoin web services are not configured.
|
||||
|
||||
---
|
||||
|
||||
## Backup Manifest
|
||||
|
||||
After all stages complete, the script writes a `BACKUP_MANIFEST.txt` file inside the timestamped backup directory. This file records the date, hostname, detected role, target drive, and a directory listing of everything that was backed up.
|
||||
|
||||
---
|
||||
|
||||
## Running the Backup
|
||||
|
||||
The backup is triggered from the Sovran Hub web UI. You can also run it directly:
|
||||
|
||||
```bash
|
||||
# Auto-detect the first external USB drive
|
||||
sudo bash /path/to/sovran-hub-backup.sh
|
||||
|
||||
# Specify a target drive explicitly
|
||||
sudo BACKUP_TARGET=/run/media/<user>/<drive> bash /path/to/sovran-hub-backup.sh
|
||||
```
|
||||
|
||||
The script requires at least **10 GB** of free space on the target drive and will refuse to write to internal system drives.
|
||||
|
||||
Logs are written to `/var/log/sovran-hub-backup.log` and the current status (`RUNNING`, `SUCCESS`, or `FAILED`) is tracked in `/var/log/sovran-hub-backup.status`.
|
||||
259
docs/tech-support-security.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Tech Support: Security Design, User Flow, and Incident Response
|
||||
|
||||
## Overview
|
||||
|
||||
The Sovran Hub includes a **Tech Support** feature that lets Sovran Systems
|
||||
staff remotely diagnose and fix issues on a user's machine via SSH — without
|
||||
ever having access to private keys or wallet funds.
|
||||
|
||||
Wallet protection is the default. The user must make an active, time-limited
|
||||
choice to grant support staff access to wallet files, and can revoke that
|
||||
access at any time.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Restricted User Instead of Root
|
||||
|
||||
When a user enables support access the Hub:
|
||||
|
||||
1. Ensures the `sovran-support` system user exists (declared declaratively in
|
||||
`modules/core/tech-support.nix`; the Hub also provisions it on demand as a
|
||||
fallback on non-NixOS systems).
|
||||
2. Writes the Sovran Systems public SSH key **only** to
|
||||
`/var/lib/sovran-support/.ssh/authorized_keys`, not to root's
|
||||
`authorized_keys`.
|
||||
3. Applies POSIX ACLs (`setfacl -R -m u:sovran-support:---`) to every wallet
|
||||
directory that exists on disk, denying all access by the support user.
|
||||
4. Records a timestamped `SUPPORT_ENABLED` event in the audit log at
|
||||
`/var/log/sovran-support-audit.log`.
|
||||
|
||||
When the session ends (or if the Hub cannot create the restricted user), the
|
||||
key is removed and all ACLs are revoked immediately.
|
||||
|
||||
### Protected Wallet Paths
|
||||
|
||||
The following directories are locked by default when a support session starts:
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `/etc/nix-bitcoin-secrets` | nix-bitcoin generated secrets |
|
||||
| `/var/lib/bitcoind` | Bitcoin Core chainstate and wallet |
|
||||
| `/var/lib/lnd` | LND wallet and channel database |
|
||||
| `/home` | User home directories |
|
||||
|
||||
Paths are only locked if they exist on disk at the time the session starts.
|
||||
|
||||
### POSIX ACL Mechanics
|
||||
|
||||
POSIX ACLs on Linux handle access checks in this order:
|
||||
|
||||
1. If the process UID matches the file owner UID → use owner permissions
|
||||
2. **If there is a matching named-user ACL entry → use that entry's
|
||||
permissions** (clamped by the mask entry)
|
||||
3. If any group matches → use group permissions
|
||||
4. Otherwise → use "other" permissions
|
||||
|
||||
Setting `u:sovran-support:---` creates a named-user ACL entry with no
|
||||
permissions. Because the named-user entry is checked before the group/other
|
||||
entries, the support user cannot access those directories regardless of the
|
||||
"other" permission bits.
|
||||
|
||||
`setfacl` and `getfacl` are provided by the `acl` package, which is added to
|
||||
`environment.systemPackages` by `modules/core/tech-support.nix`.
|
||||
|
||||
### Fallback to Root (When Restricted User Cannot Be Created)
|
||||
|
||||
If the `sovran-support` user does not exist and cannot be created (e.g.,
|
||||
`users.mutableUsers = false` and the declarative module has not been deployed
|
||||
yet), the Hub falls back to adding the support key to root's
|
||||
`authorized_keys`. The modal prominently warns the user when this has happened
|
||||
so they can decide whether to end the session.
|
||||
|
||||
### Audit Log
|
||||
|
||||
Every session event is appended to `/var/log/sovran-support-audit.log`:
|
||||
|
||||
```
|
||||
[2025-01-15 14:32:01 UTC] SUPPORT_ENABLED: restricted_user=True acl_applied=True protected_paths=4
|
||||
[2025-01-15 14:45:00 UTC] WALLET_UNLOCKED: duration=3600s expires=2025-01-15 15:45:00 UTC
|
||||
[2025-01-15 15:45:00 UTC] WALLET_RELOCKED: auto-expired
|
||||
[2025-01-15 16:01:22 UTC] SUPPORT_DISABLED
|
||||
```
|
||||
|
||||
The last 100 lines of this log are accessible from the Hub UI while a session
|
||||
is active (or after it ends, until the page is refreshed).
|
||||
|
||||
---
|
||||
|
||||
## Security Tradeoffs
|
||||
|
||||
### What This Protects Against
|
||||
|
||||
- **Accidental wallet exposure** — support staff cannot read wallet files
|
||||
during a normal session; they must ask the user to explicitly grant access.
|
||||
- **Credential theft** — private keys in the wallet directories are not
|
||||
visible to the `sovran-support` user by default.
|
||||
- **Scope creep** — the restricted user account limits the blast radius of an
|
||||
SSH session compared to direct root access.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
| Limitation | Mitigation |
|
||||
|------------|------------|
|
||||
| Support user still has system-wide bash access | Restrict with `ForceCommand` or AppArmor in the NixOS config if a narrower scope is required |
|
||||
| ACLs apply only to directories that exist at session start | If new wallet directories are created during a session, they are not auto-protected. Re-lock and re-enable support to pick up new paths |
|
||||
| Root fallback grants full access | The Hub UI warns the user prominently; users should end the session if they are uncomfortable |
|
||||
| `setfacl` / ACL filesystem support required | The `acl` package is declared in `tech-support.nix`; most Linux filesystems (ext4, btrfs, xfs) support ACLs by default |
|
||||
| Wallet access grant is time-limited but lazy-expired | Expiry is checked on the next `/api/support/status` poll (every 10 seconds in the UI); there is a small window after expiry |
|
||||
|
||||
### Defense-in-Depth Recommendations
|
||||
|
||||
For environments that require stronger isolation, consider layering one or
|
||||
more additional controls:
|
||||
|
||||
- **`ForceCommand`** in `sshd_config` (or `~/.ssh/authorized_keys` command
|
||||
prefix) to restrict the support user to a specific diagnostic script.
|
||||
- **`ChrootDirectory`** in the `sshd_config` `Match User sovran-support` block
|
||||
to confine the session to a prepared directory tree.
|
||||
- **AppArmor or SELinux** profiles that deny the support process read access
|
||||
to wallet paths at the kernel level.
|
||||
- **Namespace/bind-mount overlays** (e.g., via a wrapper systemd unit) to
|
||||
present a sanitized filesystem view.
|
||||
|
||||
---
|
||||
|
||||
## User Flow
|
||||
|
||||
```
|
||||
User opens Hub → Clicks "Tech Support" in sidebar
|
||||
│
|
||||
▼
|
||||
Modal: "Need help from Sovran Systems?"
|
||||
• Explains what will happen
|
||||
• Shows Wallet Protection notice
|
||||
• User clicks "Enable Support Access"
|
||||
│
|
||||
▼
|
||||
Hub: 1. Creates / verifies sovran-support user
|
||||
2. Writes SSH key to that user's authorized_keys
|
||||
3. Applies POSIX ACL deny on all existing wallet paths
|
||||
4. Saves session metadata + writes SUPPORT_ENABLED to audit log
|
||||
│
|
||||
▼
|
||||
Modal: "Support Access is Active"
|
||||
• Live session duration timer
|
||||
• Wallet Files: Protected panel
|
||||
– Optional: "Grant Wallet Access" (time-limited, user-chosen)
|
||||
• "End Support Session" button
|
||||
• "View Audit Log" button
|
||||
│
|
||||
(User grants wallet access)
|
||||
│
|
||||
▼
|
||||
Hub: • Removes ACL deny entries
|
||||
• Records WALLET_UNLOCKED event with expiry time
|
||||
• Starts countdown timer in UI
|
||||
│
|
||||
(Timer expires or user clicks "Re-lock Wallet Now")
|
||||
│
|
||||
▼
|
||||
Hub: • Re-applies ACL deny entries
|
||||
• Removes WALLET_UNLOCK_FILE
|
||||
• Records WALLET_RELOCKED event
|
||||
│
|
||||
(User clicks "End Support Session")
|
||||
│
|
||||
▼
|
||||
Hub: 1. Removes SSH key from sovran-support authorized_keys
|
||||
2. Removes SSH key from root authorized_keys (legacy cleanup)
|
||||
3. Revokes any wallet unlock, re-applies ACL deny
|
||||
4. Verifies key is gone
|
||||
5. Records SUPPORT_DISABLED event
|
||||
│
|
||||
▼
|
||||
Modal: "Support Session Ended — SSH key removed"
|
||||
• Shows verified removal status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Incident Response
|
||||
|
||||
### Scenario 1 — You accidentally granted wallet access and are unsure what was copied
|
||||
|
||||
**Immediate steps:**
|
||||
|
||||
1. Click **"Re-lock Wallet Now"** in the Hub modal, or click
|
||||
**"End Support Session"** to simultaneously revoke SSH access and wallet
|
||||
access.
|
||||
2. Open the **Audit Log** from the Hub modal and note the timestamps of
|
||||
`WALLET_UNLOCKED` and `WALLET_RELOCKED` events.
|
||||
3. Check `/var/log/auth.log` (or `journalctl -u sshd`) for SSH login events
|
||||
by `sovran-support` during the unlocked window.
|
||||
|
||||
**Assessment:**
|
||||
|
||||
- If no SSH login occurred during the wallet-unlocked window, your keys are
|
||||
safe.
|
||||
- If an SSH login did occur, treat private keys as potentially compromised.
|
||||
|
||||
**Recovery if keys may be compromised:**
|
||||
|
||||
| Wallet | Recovery action |
|
||||
|--------|----------------|
|
||||
| LND | Move all funds out using `lncli sendcoins` to a freshly generated on-chain address; close channels; recreate wallet |
|
||||
| Sparrow | Sweep funds to a new wallet generated on an air-gapped device |
|
||||
| Bisq | Withdraw all BSQ and BTC to external wallets; delete the Bisq data directory and recreate |
|
||||
| nix-bitcoin secrets | Rotate all secrets with `nix-bitcoin-secrets generate` and redeploy |
|
||||
|
||||
**Report the incident:**
|
||||
|
||||
Contact Sovran Systems immediately at support@sovransystems.com with:
|
||||
- The audit log output (`/var/log/sovran-support-audit.log`)
|
||||
- The SSH auth log for the affected time window
|
||||
- A description of what you were troubleshooting
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2 — Support session cannot be ended (button fails or server is unresponsive)
|
||||
|
||||
**Manual key removal (run as root on the device):**
|
||||
|
||||
```bash
|
||||
# Remove from support user's authorized_keys
|
||||
rm -f /var/lib/sovran-support/.ssh/authorized_keys
|
||||
|
||||
# Remove from root's authorized_keys (fallback / legacy)
|
||||
sed -i '/sovransystemsos-support/d' /root/.ssh/authorized_keys
|
||||
|
||||
# Remove wallet unlock state
|
||||
rm -f /var/lib/secrets/support-wallet-unlock
|
||||
|
||||
# Re-apply wallet ACL protections
|
||||
setfacl -R -m u:sovran-support:--- /etc/nix-bitcoin-secrets \
|
||||
/var/lib/bitcoind /var/lib/lnd /home 2>/dev/null || true
|
||||
|
||||
# Restart sshd to drop any active connections
|
||||
systemctl restart sshd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3 — You see an unexpected SUPPORT_ENABLED in the audit log
|
||||
|
||||
This should never happen without physical or remote access to the Hub web
|
||||
interface. If you see an unexpected entry:
|
||||
|
||||
1. Immediately run the manual key removal commands above.
|
||||
2. Change the Sovran Hub web interface password.
|
||||
3. Check `/var/log/nginx/access.log` (or Caddy access logs) for unexpected
|
||||
requests to `/api/support/enable`.
|
||||
4. Consider rebooting the device to clear any in-memory state.
|
||||
5. Report the incident to Sovran Systems.
|
||||
|
||||
---
|
||||
|
||||
*This document is part of the Sovran_SystemsOS repository. For the
|
||||
authoritative and up-to-date version, see the repository.*
|
||||
@@ -37,21 +37,26 @@ FILE=/var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed
|
||||
touch /etc/nixos/custom.nix
|
||||
|
||||
/run/current-system/sw/bin/cat > /etc/nixos/custom.nix <<- "EOF"
|
||||
{ config, lib, ... }:
|
||||
|
||||
{config, pkgs, lib, ...}:
|
||||
|
||||
# Add custom NixOS modules here.
|
||||
|
||||
let
|
||||
personalization = import ./personalization.nix;
|
||||
|
||||
in
|
||||
{
|
||||
###########################################################
|
||||
# #
|
||||
# Sovran_SystemsOS — custom.nix #
|
||||
# #
|
||||
# Services, features, and roles are managed by the #
|
||||
# Sovran Hub. Any changes you make through the Hub #
|
||||
# will appear in the "Hub Managed" section below. #
|
||||
# #
|
||||
# If you want to add your own NixOS modules or #
|
||||
# configuration, place them here — outside of the #
|
||||
# Hub Managed section. #
|
||||
# #
|
||||
###########################################################
|
||||
|
||||
|
||||
# ─── Add your custom NixOS configuration below ───────────
|
||||
|
||||
}
|
||||
|
||||
EOF
|
||||
|
||||
|
||||
|
||||
36
flake.lock
generated
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773169138,
|
||||
"narHash": "sha256-6X41z8o2z8KjF4gMzLTPD41WjvCDGXTc0muPGmwcOMk=",
|
||||
"lastModified": 1775155316,
|
||||
"narHash": "sha256-4H8aEChZ6rra9jd8OcVHgHs3IuzKzpDt4PPtsPJrkyM=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "bitcoin-knots-bip-110-nix",
|
||||
"rev": "b9d018b71e20ce8c1567cbc2401b6edc2c1c7793",
|
||||
"rev": "663ea34f6f846f48c385a73d4581ba599bb5bbc0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -24,11 +24,11 @@
|
||||
"oldNixpkgs": "oldNixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774797058,
|
||||
"narHash": "sha256-URUOiKNjG3s7vDkTj554+3yzQ0qqNQoQwHdc7vs63X0=",
|
||||
"lastModified": 1775155241,
|
||||
"narHash": "sha256-J8dGfHLXlpJgDSgbBuQtll9chM9Hsv5hhVWglSRLgIE=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "a10dae067da04b7b170eed73efc665d27fc0e0c5",
|
||||
"rev": "e76e32fef6d0b8a8e9a32318835ba09915232a5c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -127,11 +127,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772380631,
|
||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
||||
"lastModified": 1775054576,
|
||||
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
||||
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -191,11 +191,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1772380631,
|
||||
"narHash": "sha256-FhW0uxeXjefINP0vUD4yRBB52Us7fXZPk9RiPAopfiY=",
|
||||
"lastModified": 1775054576,
|
||||
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6d3b61b190a899042ce82a5355111976ba76d698",
|
||||
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -222,11 +222,11 @@
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1775036866,
|
||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||
"lastModified": 1775423009,
|
||||
"narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||
"rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -259,11 +259,11 @@
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774802402,
|
||||
"narHash": "sha256-L1UJ/zxKTyyaGGmytH6OYlgQ0HGSMhvPkvU+iz4Mkb8=",
|
||||
"lastModified": 1775307257,
|
||||
"narHash": "sha256-y9hEecHH4ennFwIcw1n480YCGh73DkEmizmQnyXuvgg=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "cbd8536a05d1aae2593cb5c9ace1010c8c5845cb",
|
||||
"rev": "2e008bb941f72379d5b935d5bfe70ed8b7c793ff",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
19
flake.nix
@@ -25,26 +25,17 @@
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
self.nixosModules.Sovran_SystemsOS
|
||||
/etc/nixos/role-state.nix
|
||||
/etc/nixos/custom.nix
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
./custom.nix
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.sovran-iso-desktop = nixpkgs.lib.nixosSystem {
|
||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
./iso/desktop.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.sovran-iso-server = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
./iso/server.nix
|
||||
./iso/common.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 31 KiB |
@@ -55,7 +55,6 @@ in
|
||||
gsettings-desktop-schemas
|
||||
adwaita-icon-theme
|
||||
util-linux
|
||||
disko
|
||||
parted
|
||||
dosfstools
|
||||
e2fsprogs
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
{ device ? "/dev/sda", dataDevice ? "", ... }:
|
||||
|
||||
{
|
||||
disko.devices = {
|
||||
disk = {
|
||||
main = {
|
||||
type = "disk";
|
||||
device = builtins.toString device;
|
||||
content = {
|
||||
type = "gpt";
|
||||
partitions = {
|
||||
ESP = {
|
||||
priority = 1;
|
||||
name = "ESP";
|
||||
start = "1M";
|
||||
end = "512M";
|
||||
type = "EF00";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "vfat";
|
||||
mountpoint = "/boot/efi";
|
||||
mountOptions = [ "umask=0077" "defaults" ];
|
||||
};
|
||||
};
|
||||
root = {
|
||||
name = "root";
|
||||
start = "512M";
|
||||
end = "100%";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "ext4";
|
||||
mountpoint = "/";
|
||||
extraArgs = [ "-L" "sovran_systemsos" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} // (if dataDevice != "" then {
|
||||
data = {
|
||||
type = "disk";
|
||||
device = builtins.toString dataDevice;
|
||||
content = {
|
||||
type = "gpt";
|
||||
partitions = {
|
||||
primary = {
|
||||
name = "primary";
|
||||
start = "1M";
|
||||
end = "100%";
|
||||
content = {
|
||||
type = "filesystem";
|
||||
format = "ext4";
|
||||
mountpoint = "/run/media/Second_Drive";
|
||||
extraArgs = [ "-L" "BTCEcoandBackup" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} else {});
|
||||
};
|
||||
}
|
||||
525
iso/installer.py
@@ -14,6 +14,28 @@ LOGO = "/etc/sovran/logo.png"
|
||||
LOG = "/tmp/sovran-install.log"
|
||||
FLAKE = "/etc/sovran/flake"
|
||||
|
||||
DEPLOYED_FLAKE = """\
|
||||
{
|
||||
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
|
||||
|
||||
inputs = {
|
||||
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS?ref=staging-dev";
|
||||
};
|
||||
|
||||
outputs = { self, Sovran_Systems, ... }@inputs: {
|
||||
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
./custom.nix
|
||||
Sovran_Systems.nixosModules.Sovran_SystemsOS
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
logfile = open(LOG, "a")
|
||||
atexit.register(logfile.close)
|
||||
@@ -38,7 +60,8 @@ def run(cmd):
|
||||
|
||||
def run_stream(cmd, buf):
|
||||
log(f"$ {' '.join(cmd)}")
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL, text=True)
|
||||
for line in proc.stdout:
|
||||
log(line.rstrip())
|
||||
GLib.idle_add(append_text, buf, line)
|
||||
@@ -86,7 +109,7 @@ def symbolic_icon(name):
|
||||
return icon
|
||||
|
||||
|
||||
# ── Application ────────────────────────────────────────────────────────────────
|
||||
# ── Application ──────────────────────────────────────────────────────────
|
||||
|
||||
class InstallerApp(Adw.Application):
|
||||
def __init__(self):
|
||||
@@ -98,7 +121,7 @@ class InstallerApp(Adw.Application):
|
||||
self.win.present()
|
||||
|
||||
|
||||
# ── Main Window ────────────────────────────────────────────────────────────────
|
||||
# ── Main Window ──────────────────────────────────────────────────────────
|
||||
|
||||
class InstallerWindow(Adw.ApplicationWindow):
|
||||
def __init__(self, **kwargs):
|
||||
@@ -117,11 +140,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
self.nav = Adw.NavigationView()
|
||||
self.set_content(self.nav)
|
||||
|
||||
# Check for internet before anything else
|
||||
if check_internet():
|
||||
self.push_welcome()
|
||||
else:
|
||||
self.push_no_internet()
|
||||
# Always show the landing/welcome page first
|
||||
self.push_landing()
|
||||
|
||||
# ── Navigation helpers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -148,7 +168,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
break
|
||||
self.push_page(title, child)
|
||||
|
||||
# ── Shared widgets ───────────────<EFBFBD><EFBFBD>─────────────────────────────────────
|
||||
# ── Shared widgets ────────────────────────────────────────────────────
|
||||
|
||||
def make_scrolled_log(self):
|
||||
sw = Gtk.ScrolledWindow()
|
||||
@@ -197,54 +217,93 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
return box
|
||||
|
||||
# ── No Internet Screen ─────────────────────────────────────────────────
|
||||
# ── Landing / Welcome Screen ───────────────────────────────────────────
|
||||
|
||||
def push_no_internet(self):
|
||||
def push_landing(self):
|
||||
"""First screen: always shown. Welcomes the user and checks connectivity."""
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
status = Adw.StatusPage()
|
||||
status.set_title("No Internet Connection")
|
||||
status.set_description(
|
||||
"An active internet connection is required to install Sovran_SystemsOS.\n\n"
|
||||
"Please connect an Ethernet cable or configure Wi-Fi,\n"
|
||||
"then press Retry."
|
||||
)
|
||||
status.set_icon_name("network-offline-symbolic")
|
||||
status.set_vexpand(True)
|
||||
outer.append(status)
|
||||
# Hero
|
||||
hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
hero.set_margin_top(40)
|
||||
hero.set_margin_bottom(16)
|
||||
hero.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
if os.path.exists(LOGO):
|
||||
try:
|
||||
img = Gtk.Image.new_from_file(LOGO)
|
||||
img.set_pixel_size(320)
|
||||
hero.append(img)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
title = Gtk.Label()
|
||||
title.set_markup("<span size='xx-large' weight='heavy'>Welcome to Sovran SystemsOS</span>")
|
||||
title.set_margin_top(8)
|
||||
hero.append(title)
|
||||
|
||||
sub = Gtk.Label()
|
||||
sub.set_markup("<span size='large' style='italic' foreground='#888888'>Be Digitally Sovereign</span>")
|
||||
hero.append(sub)
|
||||
|
||||
outer.append(hero)
|
||||
|
||||
sep = Gtk.Separator()
|
||||
sep.set_margin_start(40)
|
||||
sep.set_margin_end(40)
|
||||
sep.set_margin_top(8)
|
||||
outer.append(sep)
|
||||
|
||||
# Internet requirement notice
|
||||
notice = Gtk.Label()
|
||||
notice.set_markup(
|
||||
"<span size='medium'>"
|
||||
"Before installation begins, please ensure you have an <b>active internet connection</b>.\n"
|
||||
"Sovran SystemsOS downloads packages during installation and requires internet access\n"
|
||||
"to complete the process. Connect via <b>Ethernet cable</b> or configure <b>Wi-Fi</b> now."
|
||||
"</span>"
|
||||
)
|
||||
notice.set_justify(Gtk.Justification.CENTER)
|
||||
notice.set_wrap(True)
|
||||
notice.set_margin_top(20)
|
||||
notice.set_margin_start(48)
|
||||
notice.set_margin_end(48)
|
||||
outer.append(notice)
|
||||
|
||||
# Inline offline warning banner (hidden by default)
|
||||
self._offline_banner = Adw.Banner()
|
||||
self._offline_banner.set_title(
|
||||
"No internet connection detected. Please connect Ethernet or Wi-Fi and try again."
|
||||
)
|
||||
self._offline_banner.set_revealed(False)
|
||||
self._offline_banner.set_margin_top(12)
|
||||
self._offline_banner.set_margin_start(40)
|
||||
self._offline_banner.set_margin_end(40)
|
||||
outer.append(self._offline_banner)
|
||||
|
||||
outer.append(Gtk.Label(label="", vexpand=True))
|
||||
|
||||
# Check & Continue button
|
||||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
btn_box.set_halign(Gtk.Align.CENTER)
|
||||
btn_box.set_margin_bottom(32)
|
||||
|
||||
retry_btn = Gtk.Button(label="Retry")
|
||||
retry_btn.add_css_class("suggested-action")
|
||||
retry_btn.add_css_class("pill")
|
||||
retry_btn.connect("clicked", self.on_retry_internet)
|
||||
btn_box.append(retry_btn)
|
||||
connect_btn = Gtk.Button(label="Check Connection & Continue →")
|
||||
connect_btn.add_css_class("suggested-action")
|
||||
connect_btn.add_css_class("pill")
|
||||
connect_btn.connect("clicked", self._on_landing_connect)
|
||||
btn_box.append(connect_btn)
|
||||
|
||||
outer.append(btn_box)
|
||||
|
||||
self.push_page("No Internet", outer)
|
||||
self.push_page("Sovran_SystemsOS Installer", outer)
|
||||
|
||||
def on_retry_internet(self, btn):
|
||||
def _on_landing_connect(self, btn):
|
||||
if check_internet():
|
||||
# Pop the no-internet page and proceed to welcome
|
||||
try:
|
||||
self.nav.pop()
|
||||
except Exception:
|
||||
pass
|
||||
self._offline_banner.set_revealed(False)
|
||||
self.push_welcome()
|
||||
else:
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("Still Offline")
|
||||
dlg.set_body(
|
||||
"Could not reach the internet.\n"
|
||||
"Please check your network connection and try again."
|
||||
)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
self._offline_banner.set_revealed(True)
|
||||
|
||||
# ── Step 1: Welcome & Role ─────────────────────────────────────────────
|
||||
|
||||
@@ -302,6 +361,23 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
"Node (Bitcoin-only)"),
|
||||
]
|
||||
|
||||
# Detect internal (non-USB) drives to gate role availability
|
||||
try:
|
||||
raw = run(["lsblk", "-b", "-dno", "NAME,SIZE,TYPE,RO,TRAN", "-e", "7,11"])
|
||||
internal_disks = []
|
||||
for line in raw.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[2] == "disk" and parts[3] == "0":
|
||||
tran = parts[4] if len(parts) >= 5 else ""
|
||||
if tran != "usb":
|
||||
internal_disks.append(parts[0])
|
||||
except Exception:
|
||||
internal_disks = []
|
||||
|
||||
has_second_drive = len(internal_disks) >= 2
|
||||
|
||||
NEEDS_DATA_DRIVE = {"Server+Desktop", "Node (Bitcoin-only)"}
|
||||
|
||||
self._role_radios = []
|
||||
radio_group = None
|
||||
cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
@@ -311,14 +387,22 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
for label, desc, key in roles:
|
||||
card = Adw.ActionRow()
|
||||
card.set_title(label)
|
||||
card.set_subtitle(desc)
|
||||
|
||||
available = has_second_drive or key not in NEEDS_DATA_DRIVE
|
||||
if not available:
|
||||
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)")
|
||||
card.set_sensitive(False)
|
||||
else:
|
||||
card.set_subtitle(desc)
|
||||
|
||||
radio = Gtk.CheckButton()
|
||||
radio.set_name(key)
|
||||
if radio_group is None:
|
||||
radio.set_sensitive(available)
|
||||
|
||||
if radio_group is None and available:
|
||||
radio_group = radio
|
||||
radio.set_active(True)
|
||||
else:
|
||||
elif radio_group is not None:
|
||||
radio.set_group(radio_group)
|
||||
|
||||
card.add_prefix(radio)
|
||||
@@ -341,11 +425,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if radio.get_active():
|
||||
self.role = radio.get_name()
|
||||
break
|
||||
self.push_disk_confirm()
|
||||
self.push_disk_detect()
|
||||
|
||||
# ── Step 2: Disk Confirm ───────────────────────────────────────────────
|
||||
# ── Step 2a: Disk Detect ──────────────────────────────────────────────
|
||||
|
||||
def push_disk_confirm(self):
|
||||
def push_disk_detect(self):
|
||||
"""Detect internal drives and show the interactive disk selection page."""
|
||||
try:
|
||||
raw = run(["lsblk", "-b", "-dno", "NAME,SIZE,TYPE,RO,TRAN", "-e", "7,11"])
|
||||
except Exception as e:
|
||||
@@ -358,22 +443,208 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if len(parts) >= 4 and parts[2] == "disk" and parts[3] == "0":
|
||||
tran = parts[4] if len(parts) >= 5 else ""
|
||||
if tran != "usb":
|
||||
disks.append((parts[0], int(parts[1])))
|
||||
disks.append((parts[0], int(parts[1]), tran))
|
||||
|
||||
if not disks:
|
||||
self.show_error("No valid internal drives found. USB drives are excluded.")
|
||||
return
|
||||
|
||||
disks.sort(key=lambda x: x[1])
|
||||
self.boot_disk, self.boot_size = disks[0]
|
||||
self.data_disk, self.data_size = None, None
|
||||
self.push_disk_select(disks)
|
||||
|
||||
BYTES_2TB = 2 * 1024 ** 4
|
||||
if len(disks) >= 2:
|
||||
d, s = disks[-1]
|
||||
if s >= BYTES_2TB:
|
||||
self.data_disk, self.data_size = d, s
|
||||
# ── Step 2b: Disk Select ──────────────────────────────────────────────
|
||||
|
||||
def push_disk_select(self, disks):
|
||||
"""Interactive disk-selection page: pick OS drive and (optionally) data drive."""
|
||||
|
||||
BYTES_256GB = 256 * 1024 ** 3
|
||||
BYTES_2TB = 2 * 10 ** 12
|
||||
|
||||
# Sort ascending by size so the default selection (index 0) is the smallest
|
||||
disks = sorted(disks, key=lambda x: x[1])
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.set_vexpand(True)
|
||||
|
||||
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
# ── OS Drive group ────────────────────────────────────────────
|
||||
os_group = Adw.PreferencesGroup()
|
||||
os_group.set_title("OS Drive (NixOS Boot + Root)")
|
||||
os_group.set_description(
|
||||
"Choose the drive for the NixOS installation. Minimum 256 GB required."
|
||||
)
|
||||
os_group.set_margin_top(24)
|
||||
os_group.set_margin_start(40)
|
||||
os_group.set_margin_end(40)
|
||||
|
||||
self._os_disk_radios = []
|
||||
os_radio_group = None
|
||||
|
||||
for name, size, tran in disks:
|
||||
row = Adw.ActionRow()
|
||||
row.set_title(f"/dev/{name}")
|
||||
type_label = tran.upper() if tran else "Disk"
|
||||
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
|
||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
|
||||
radio = Gtk.CheckButton()
|
||||
radio.set_name(name)
|
||||
if os_radio_group is None:
|
||||
os_radio_group = radio
|
||||
radio.set_active(True)
|
||||
else:
|
||||
radio.set_group(os_radio_group)
|
||||
|
||||
row.add_suffix(radio)
|
||||
row.set_activatable_widget(radio)
|
||||
self._os_disk_radios.append(radio)
|
||||
os_group.add(row)
|
||||
|
||||
inner.append(os_group)
|
||||
|
||||
# ── Data Drive group (skipped for Desktop Only) ───────────────
|
||||
self._data_disk_radios = []
|
||||
|
||||
if self.role != "Desktop Only":
|
||||
data_group = Adw.PreferencesGroup()
|
||||
data_group.set_title("Bitcoin Timechain & Backups Drive")
|
||||
data_group.set_description(
|
||||
"💡 Tip: Always assign your LARGEST drive here. "
|
||||
"The full Bitcoin timechain is over 700 GB and grows continuously — "
|
||||
"a 2 TB or larger drive is required."
|
||||
)
|
||||
data_group.set_margin_top(20)
|
||||
data_group.set_margin_start(40)
|
||||
data_group.set_margin_end(40)
|
||||
|
||||
data_radio_group = None
|
||||
|
||||
# "None" option
|
||||
none_row = Adw.ActionRow()
|
||||
none_row.set_title("None (skip data drive)")
|
||||
none_row.set_subtitle("Bitcoin node functionality will be unavailable")
|
||||
none_radio = Gtk.CheckButton()
|
||||
none_radio.set_name("")
|
||||
data_radio_group = none_radio
|
||||
none_radio.set_active(True)
|
||||
none_row.add_suffix(none_radio)
|
||||
none_row.set_activatable_widget(none_radio)
|
||||
self._data_disk_radios.append(none_radio)
|
||||
data_group.add(none_row)
|
||||
|
||||
for name, size, tran in disks:
|
||||
row = Adw.ActionRow()
|
||||
row.set_title(f"/dev/{name}")
|
||||
type_label = tran.upper() if tran else "Disk"
|
||||
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
|
||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
|
||||
radio = Gtk.CheckButton()
|
||||
radio.set_name(name)
|
||||
radio.set_group(data_radio_group)
|
||||
|
||||
row.add_suffix(radio)
|
||||
row.set_activatable_widget(radio)
|
||||
self._data_disk_radios.append(radio)
|
||||
data_group.add(row)
|
||||
|
||||
inner.append(data_group)
|
||||
|
||||
scroll.set_child(inner)
|
||||
outer.append(scroll)
|
||||
outer.append(self.nav_row(
|
||||
back_label="← Back",
|
||||
back_cb=lambda b: self.nav.pop(),
|
||||
next_label="Next →",
|
||||
next_cb=lambda b: self._on_disk_select_next(disks),
|
||||
))
|
||||
|
||||
self.push_page("Select Drives", outer, show_back=True)
|
||||
|
||||
def _on_disk_select_next(self, disks):
|
||||
"""Validate the user's disk selections and advance to the ERASE confirmation."""
|
||||
BYTES_256GB = 256 * 1024 ** 3
|
||||
BYTES_2TB = 2 * 10 ** 12
|
||||
|
||||
size_map = {name: size for name, size, _ in disks}
|
||||
|
||||
# Read OS disk selection
|
||||
os_name = None
|
||||
for radio in self._os_disk_radios:
|
||||
if radio.get_active():
|
||||
os_name = radio.get_name()
|
||||
break
|
||||
|
||||
# Read data disk selection (empty string = None chosen)
|
||||
data_name = None
|
||||
if self._data_disk_radios:
|
||||
for radio in self._data_disk_radios:
|
||||
if radio.get_active():
|
||||
sel = radio.get_name()
|
||||
data_name = sel if sel else None
|
||||
break
|
||||
|
||||
os_size = size_map.get(os_name, 0)
|
||||
data_size = size_map.get(data_name, 0) if data_name else 0
|
||||
|
||||
# Validate OS drive size
|
||||
if os_size < BYTES_256GB:
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("OS Drive Too Small")
|
||||
dlg.set_body(
|
||||
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
|
||||
f"does not meet the 256 GB minimum. Please choose a larger drive."
|
||||
)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
return
|
||||
|
||||
# Validate data drive size (when one was selected)
|
||||
if data_name and data_size < BYTES_2TB:
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("Bitcoin Drive Too Small")
|
||||
dlg.set_body(
|
||||
f"The selected Bitcoin Timechain & Backups drive "
|
||||
f"(/dev/{data_name}, {human_size(data_size)}) "
|
||||
f"does not meet the 2 TB minimum. "
|
||||
f"Please choose a larger drive or select \"None\"."
|
||||
)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
return
|
||||
|
||||
# Validate no duplicate selection
|
||||
if data_name and data_name == os_name:
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("Same Drive Selected Twice")
|
||||
dlg.set_body(
|
||||
"You cannot use the same drive for both the OS and "
|
||||
"Bitcoin Timechain & Backups. Please choose different drives."
|
||||
)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
return
|
||||
|
||||
# Commit selections
|
||||
self.boot_disk = os_name
|
||||
self.boot_size = os_size
|
||||
self.data_disk = data_name
|
||||
self.data_size = data_size if data_name else None
|
||||
|
||||
self.push_disk_confirm()
|
||||
|
||||
# ── Step 2c: Disk Confirm (ERASE confirmation) ────────────────────────
|
||||
|
||||
def push_disk_confirm(self):
|
||||
"""Show the selected drives and ask the user to type ERASE to confirm."""
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
# Disk info group
|
||||
@@ -384,23 +655,17 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
disk_group.set_margin_end(40)
|
||||
|
||||
boot_row = Adw.ActionRow()
|
||||
boot_row.set_title("Boot Disk")
|
||||
boot_row.set_title("OS Disk")
|
||||
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
|
||||
boot_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
disk_group.add(boot_row)
|
||||
|
||||
if self.data_disk:
|
||||
data_row = Adw.ActionRow()
|
||||
data_row.set_title("Data Disk")
|
||||
data_row.set_title("Bitcoin Timechain & Backups Disk")
|
||||
data_row.set_subtitle(f"/dev/{self.data_disk} — {human_size(self.data_size)}")
|
||||
data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
disk_group.add(data_row)
|
||||
else:
|
||||
no_row = Adw.ActionRow()
|
||||
no_row.set_title("Data Disk")
|
||||
no_row.set_subtitle("None detected (requires 2 TB or larger)")
|
||||
no_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
disk_group.add(no_row)
|
||||
|
||||
outer.append(disk_group)
|
||||
|
||||
@@ -459,7 +724,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
status = Adw.StatusPage()
|
||||
status.set_title(title)
|
||||
status.set_description(subtitle)
|
||||
status.set_icon_name("emblem-synchronizing-symbolic")
|
||||
status.set_vexpand(False)
|
||||
outer.append(status)
|
||||
|
||||
@@ -491,36 +755,69 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
def do_partition(self, buf):
|
||||
boot_path = f"/dev/{self.boot_disk}"
|
||||
data_path = f"/dev/{self.data_disk}" if self.data_disk else None
|
||||
|
||||
# ── Wipe disk(s) to clear stale GPT/MBR data before disko ──
|
||||
# ── Wipe disk(s) ──
|
||||
GLib.idle_add(append_text, buf, "=== Wiping disk(s) ===\n")
|
||||
|
||||
run_stream(["sudo", "sgdisk", "--zap-all", boot_path], buf)
|
||||
run_stream(["sudo", "wipefs", "--all", "--force", boot_path], buf)
|
||||
|
||||
if self.data_disk:
|
||||
data_path = f"/dev/{self.data_disk}"
|
||||
if data_path:
|
||||
run_stream(["sudo", "sgdisk", "--zap-all", data_path], buf)
|
||||
run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf)
|
||||
|
||||
# Inform the kernel of the wiped partition tables
|
||||
run_stream(["sudo", "partprobe", boot_path], buf)
|
||||
if self.data_disk:
|
||||
if data_path:
|
||||
run_stream(["sudo", "partprobe", data_path], buf)
|
||||
|
||||
# Short settle so the kernel finishes re-reading
|
||||
time.sleep(2)
|
||||
|
||||
# ── Now run disko on a clean disk ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning drives ===\n")
|
||||
cmd = [
|
||||
"sudo", "disko", "--mode", "disko",
|
||||
f"{FLAKE}/iso/disko.nix",
|
||||
"--arg", "device", f'"{boot_path}"'
|
||||
]
|
||||
if self.data_disk:
|
||||
cmd += ["--arg", "dataDevice", f'"/dev/{self.data_disk}"']
|
||||
run_stream(cmd, buf)
|
||||
# ── Partition boot disk: 512M ESP + rest as root ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||
boot_path], buf)
|
||||
|
||||
run_stream(["sudo", "partprobe", boot_path], buf)
|
||||
time.sleep(2)
|
||||
|
||||
# ── Partition data disk (if selected) ──
|
||||
if data_path:
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning data disk ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:0", "-t", "1:8300", "-c", "1:primary",
|
||||
data_path], buf)
|
||||
|
||||
run_stream(["sudo", "partprobe", data_path], buf)
|
||||
time.sleep(2)
|
||||
|
||||
# ── Format partitions ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Formatting partitions ===\n")
|
||||
boot_p1 = f"{boot_path}p1" if "nvme" in boot_path else f"{boot_path}1"
|
||||
boot_p2 = f"{boot_path}p2" if "nvme" in boot_path else f"{boot_path}2"
|
||||
|
||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
||||
|
||||
if data_path:
|
||||
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
|
||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "BTCEcoandBackup", data_p1], buf)
|
||||
|
||||
# ── Mount filesystems ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
|
||||
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||
|
||||
if data_path:
|
||||
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive"], buf)
|
||||
run_stream(["sudo", "mount", data_p1, "/mnt/run/media/Second_Drive"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup"], buf)
|
||||
|
||||
GLib.idle_add(append_text, buf, "\n=== Generating hardware config ===\n")
|
||||
run_stream(["sudo", "nixos-generate-config", "--root", "/mnt"], buf)
|
||||
@@ -559,7 +856,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
raise RuntimeError(f"Failed to write role-state.nix: {proc.stderr}")
|
||||
run(["sudo", "cp", "/mnt/etc/nixos/custom.template.nix", "/mnt/etc/nixos/custom.nix"])
|
||||
|
||||
# ── Step 4: Ready to install ────────<EFBFBD><EFBFBD><EFBFBD>──────────────────────────────────
|
||||
# ── Step 4: Ready to install ──────────────────────────────────────────
|
||||
|
||||
def push_ready(self):
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
@@ -567,7 +864,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
status = Adw.StatusPage()
|
||||
status.set_title("Drives Ready")
|
||||
status.set_description("Your drives have been partitioned successfully.")
|
||||
status.set_icon_name("emblem-ok-symbolic")
|
||||
status.set_vexpand(True)
|
||||
|
||||
details = Adw.PreferencesGroup()
|
||||
@@ -629,15 +925,54 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if not os.path.exists(f):
|
||||
raise RuntimeError(f"Required file missing: {f}")
|
||||
|
||||
# The flake.nix imports /etc/nixos/role-state.nix and /etc/nixos/custom.nix
|
||||
# as absolute paths. With --impure, Nix resolves these on the live ISO host,
|
||||
# not under /mnt. Copy them so they exist on the host filesystem too.
|
||||
GLib.idle_add(append_text, buf, "Copying config files to host /etc/nixos for flake evaluation...\n")
|
||||
run(["sudo", "mkdir", "-p", "/etc/nixos"])
|
||||
run(["sudo", "cp", "/mnt/etc/nixos/role-state.nix", "/etc/nixos/role-state.nix"])
|
||||
run(["sudo", "cp", "/mnt/etc/nixos/custom.nix", "/etc/nixos/custom.nix"])
|
||||
run(["sudo", "cp", "/mnt/etc/nixos/hardware-configuration.nix", "/etc/nixos/hardware-configuration.nix"])
|
||||
|
||||
run_stream([
|
||||
"sudo", "nixos-install",
|
||||
"--root", "/mnt",
|
||||
"--flake", "/mnt/etc/nixos#nixos"
|
||||
"--flake", "/mnt/etc/nixos#nixos",
|
||||
"--no-root-password",
|
||||
"--impure"
|
||||
], buf)
|
||||
|
||||
# Clean up /mnt/etc/nixos — only 5 files are needed post-install.
|
||||
# configuration.nix and modules/ were needed during nixos-install
|
||||
# for flake evaluation, but are now baked into the Nix store via
|
||||
# self.nixosModules.Sovran_SystemsOS.
|
||||
GLib.idle_add(append_text, buf, "Cleaning up /mnt/etc/nixos...\n")
|
||||
keep = {"flake.nix", "flake.lock", "hardware-configuration.nix",
|
||||
"role-state.nix", "custom.nix"}
|
||||
nixos_dir = "/mnt/etc/nixos"
|
||||
for entry in os.listdir(nixos_dir):
|
||||
if entry not in keep:
|
||||
path = os.path.join(nixos_dir, entry)
|
||||
run(["sudo", "rm", "-rf", path])
|
||||
|
||||
GLib.idle_add(append_text, buf, "Writing deployed flake.nix...\n")
|
||||
proc = subprocess.run(
|
||||
["sudo", "tee", "/mnt/etc/nixos/flake.nix"],
|
||||
input=DEPLOYED_FLAKE,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
log(proc.stdout)
|
||||
if proc.returncode != 0:
|
||||
log(proc.stderr)
|
||||
raise RuntimeError(proc.stderr.strip() or "Failed to write deployed flake.nix")
|
||||
GLib.idle_add(append_text, buf, "Locking flake to staging-dev...\n")
|
||||
run_stream(["sudo", "nix", "--extra-experimental-features", "nix-command flakes",
|
||||
"flake", "lock", "/mnt/etc/nixos"], buf)
|
||||
|
||||
GLib.idle_add(self.push_complete)
|
||||
|
||||
# ── Step 6: Complete ───────────────────────────────────────────────────
|
||||
# ── Complete ───────────────────────────────────────────────────────────
|
||||
|
||||
def push_complete(self):
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
@@ -645,11 +980,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
status = Adw.StatusPage()
|
||||
status.set_title("Installation Complete!")
|
||||
status.set_description("Welcome to Sovran SystemsOS.")
|
||||
status.set_icon_name("emblem-ok-symbolic")
|
||||
status.set_vexpand(True)
|
||||
|
||||
creds_group = Adw.PreferencesGroup()
|
||||
creds_group.set_title("⚠ Write down your login details before rebooting")
|
||||
creds_group.set_title("⚠ Important — read before rebooting")
|
||||
creds_group.set_margin_start(40)
|
||||
creds_group.set_margin_end(40)
|
||||
|
||||
@@ -659,15 +993,15 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
creds_group.add(user_row)
|
||||
|
||||
pass_row = Adw.ActionRow()
|
||||
pass_row.set_title("Password")
|
||||
pass_row.set_subtitle("free")
|
||||
pass_row.set_title("Default Password")
|
||||
pass_row.set_subtitle("free — you will be prompted to change it on first boot")
|
||||
creds_group.add(pass_row)
|
||||
|
||||
note_row = Adw.ActionRow()
|
||||
note_row.set_title("App Passwords")
|
||||
note_row.set_title("First Boot Setup")
|
||||
note_row.set_subtitle(
|
||||
"After rebooting, all app passwords (Nextcloud, Bitcoin, Matrix, etc.) "
|
||||
"will be saved to a secure PDF in your Documents folder."
|
||||
"After rebooting, the Sovran Hub will guide you through setting "
|
||||
"your password, domains, and all app credentials."
|
||||
)
|
||||
creds_group.add(note_row)
|
||||
|
||||
@@ -677,9 +1011,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
outer.append(content_box)
|
||||
|
||||
reboot_btn = Gtk.Button(label="Reboot Now")
|
||||
reboot_btn.add_css_class("suggested-action")
|
||||
reboot_btn.add_css_class("success")
|
||||
reboot_btn.add_css_class("pill")
|
||||
reboot_btn.add_css_class("destructive-action")
|
||||
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
||||
|
||||
nav = Gtk.Box()
|
||||
@@ -716,4 +1049,4 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = InstallerApp()
|
||||
app.run(None)
|
||||
app.run(None)
|
||||
@@ -10,15 +10,15 @@ pkgs.stdenv.mkDerivation {
|
||||
mkdir -p $out/share/plymouth/themes/sovran
|
||||
cp ${./assets/splash-logo.png} $out/share/plymouth/themes/sovran/logo.png
|
||||
|
||||
cat > $out/share/plymouth/themes/sovran/sovran.plymouth <<'EOF'
|
||||
cat > $out/share/plymouth/themes/sovran/sovran.plymouth <<EOF
|
||||
[Plymouth Theme]
|
||||
Name=Sovran Systems
|
||||
Description=Sovran Systems Splash
|
||||
ModuleName=script
|
||||
|
||||
[script]
|
||||
ImageDir=/share/plymouth/themes/sovran
|
||||
ScriptFile=/share/plymouth/themes/sovran/sovran.script
|
||||
ImageDir=$out/share/plymouth/themes/sovran
|
||||
ScriptFile=$out/share/plymouth/themes/sovran/sovran.script
|
||||
EOF
|
||||
|
||||
cat > $out/share/plymouth/themes/sovran/sovran.script <<'EOF'
|
||||
@@ -34,11 +34,6 @@ logo = Image("logo.png");
|
||||
logo_sprite = Sprite(logo);
|
||||
logo_sprite.SetX((Window.GetWidth() - logo.GetWidth()) / 2);
|
||||
logo_sprite.SetY((Window.GetHeight() - logo.GetHeight()) / 2);
|
||||
|
||||
spinner = Sprite();
|
||||
spinner.SetImage(Spinner());
|
||||
spinner.SetX((Window.GetWidth() - spinner.GetImage().GetWidth()) / 2);
|
||||
spinner.SetY((Window.GetHeight() + logo.GetHeight()) / 2 + 20);
|
||||
EOF
|
||||
'';
|
||||
}
|
||||
@@ -69,7 +69,36 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
};
|
||||
|
||||
nix-bitcoin.useVersionLockedPkgs = false;
|
||||
|
||||
|
||||
systemd.services.bitcoind = {
|
||||
requires = [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" ];
|
||||
};
|
||||
|
||||
systemd.services.electrs = {
|
||||
requires = [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" ];
|
||||
};
|
||||
|
||||
systemd.services.sovran-btc-permissions = {
|
||||
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "run-media-Second_Drive.mount" ];
|
||||
before = [ "bitcoind.service" "electrs.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
||||
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||
fi
|
||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
||||
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
||||
];
|
||||
|
||||
@@ -2,13 +2,25 @@
|
||||
|
||||
let
|
||||
exposeBtcpay = config.sovran_systemsOS.web.btcpayserver;
|
||||
extraVhosts = config.sovran_systemsOS.caddy.extraVirtualHosts;
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
configFile = "/run/caddy/Caddyfile";
|
||||
};
|
||||
|
||||
# Override ExecStart + ExecReload to point at the runtime-generated Caddyfile
|
||||
systemd.services.caddy.serviceConfig = {
|
||||
ExecStart = lib.mkForce [
|
||||
""
|
||||
"${pkgs.caddy}/bin/caddy run --config /run/caddy/Caddyfile --adapter caddyfile"
|
||||
];
|
||||
ExecReload = lib.mkForce [
|
||||
""
|
||||
"${pkgs.caddy}/bin/caddy reload --config /run/caddy/Caddyfile --adapter caddyfile --force"
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services.caddy-generate-config = {
|
||||
@@ -144,6 +156,37 @@ $HAVEN {
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
http://sovransystemsos.local {
|
||||
reverse_proxy localhost:8937
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── RTL (LAN access) ────────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:3051 {
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Mempool (LAN access) ────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:60847 {
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Custom vhosts from custom.nix ──────────────
|
||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||
${extraVhosts}
|
||||
CUSTOM_VHOSTS_EOF
|
||||
'';
|
||||
};
|
||||
}
|
||||
}
|
||||
292
modules/core/factory-seal.nix
Normal file
@@ -0,0 +1,292 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
sovran-factory-seal = pkgs.writeShellScriptBin "sovran-factory-seal" ''
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Error: must be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ SOVRAN FACTORY SEAL — WARNING ⚠ ║"
|
||||
echo "╠══════════════════════════════════════════════════════════════╣"
|
||||
echo "║ This command will PERMANENTLY DELETE: ║"
|
||||
echo "║ • All generated passwords and secrets ║"
|
||||
echo "║ • LND wallet data (seed words, channels, macaroons) ║"
|
||||
echo "║ • SSH factory login key ║"
|
||||
echo "║ • Application databases (Matrix, Nextcloud, WordPress) ║"
|
||||
echo "║ • Vaultwarden database ║"
|
||||
echo "║ ║"
|
||||
echo "║ After sealing, all credentials will be regenerated fresh ║"
|
||||
echo "║ when the customer boots the device for the first time. ║"
|
||||
echo "║ ║"
|
||||
echo "║ DO NOT run this on a customer's live system. ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -n "Type SEAL to confirm: "
|
||||
read -r CONFIRM
|
||||
if [ "$CONFIRM" != "SEAL" ]; then
|
||||
echo "Aborted." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Sealing system..."
|
||||
|
||||
# ── 1. Delete all generated secrets ──────────────────────────────
|
||||
echo " Wiping secrets..."
|
||||
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
|
||||
rm -rf /var/lib/matrix-synapse/registration-secret
|
||||
rm -rf /var/lib/matrix-synapse/db-password
|
||||
rm -rf /var/lib/gnome-remote-desktop/rdp-password
|
||||
rm -rf /var/lib/gnome-remote-desktop/rdp-username
|
||||
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
|
||||
rm -rf /var/lib/livekit/livekit_keyFile
|
||||
rm -rf /etc/nix-bitcoin-secrets/*
|
||||
|
||||
# ── 2. Wipe LND wallet (seed words, wallet DB, macaroons) ────────
|
||||
echo " Wiping LND wallet data..."
|
||||
rm -rf /var/lib/lnd/*
|
||||
|
||||
# ── 3. Wipe SSH factory key so it regenerates with new passphrase ─
|
||||
echo " Removing SSH factory key..."
|
||||
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
|
||||
if [ -f /root/.ssh/authorized_keys ]; then
|
||||
sed -i '/factory_login/d' /root/.ssh/authorized_keys
|
||||
fi
|
||||
|
||||
# ── 4. Drop application databases ────────────────────────────────
|
||||
echo " Dropping application databases..."
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
|
||||
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
|
||||
|
||||
# ── 5. Remove application config files (so init services re-run) ─
|
||||
echo " Removing application config files..."
|
||||
rm -rf /var/lib/www/wordpress/wp-config.php
|
||||
rm -rf /var/lib/www/nextcloud/config/config.php
|
||||
|
||||
# ── 6. Wipe Vaultwarden database ──────────────────────────────────
|
||||
echo " Wiping Vaultwarden data..."
|
||||
rm -rf /var/lib/bitwarden_rs/*
|
||||
rm -rf /var/lib/vaultwarden/*
|
||||
|
||||
# ── 7. Set sealed flag and remove onboarded flag ─────────────────
|
||||
echo " Setting sealed flag..."
|
||||
touch /var/lib/sovran-factory-sealed
|
||||
rm -f /var/lib/sovran-customer-onboarded
|
||||
|
||||
echo ""
|
||||
echo "System sealed. Power off now or the system will shut down in 10 seconds."
|
||||
sleep 10
|
||||
poweroff
|
||||
'';
|
||||
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [ sovran-factory-seal ];
|
||||
|
||||
# ── Auto-seal on first customer boot ───────────────────────────────
|
||||
systemd.services.sovran-auto-seal = {
|
||||
description = "Auto-seal Sovran system on first customer boot";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "sovran-hub.service" "sovran-legacy-security-check.service" ];
|
||||
after = [ "local-fs.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils pkgs.e2fsprogs pkgs.openssl pkgs.postgresql pkgs.mariadb pkgs.shadow ];
|
||||
script = ''
|
||||
# ── Idempotency check ─────────────────────────────────────────
|
||||
if [ -f /var/lib/sovran-factory-sealed ]; then
|
||||
echo "sovran-auto-seal: already sealed, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "sovran-auto-seal: seal flag missing — checking system state..."
|
||||
|
||||
# ── Safety guard 1: customer has already onboarded ────────────
|
||||
if [ -f /var/lib/sovran-customer-onboarded ]; then
|
||||
echo "sovran-auto-seal: /var/lib/sovran-customer-onboarded exists — live system detected. Restoring flag and exiting."
|
||||
touch /var/lib/sovran-factory-sealed
|
||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Safety guard 2: onboarding was completed ──────────────────
|
||||
if [ -f /var/lib/sovran/onboarding-complete ]; then
|
||||
echo "sovran-auto-seal: /var/lib/sovran/onboarding-complete exists — live system detected. Restoring flag and exiting."
|
||||
touch /var/lib/sovran-factory-sealed
|
||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Safety guard 3: password has been changed from factory defaults ──
|
||||
if [ -f /etc/shadow ]; then
|
||||
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
|
||||
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
|
||||
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
|
||||
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
|
||||
STILL_DEFAULT=false
|
||||
# If the salt field starts with "rounds=", we cannot extract the real salt
|
||||
# with a simple cut — treat as still-default for safety
|
||||
if printf '%s' "$SALT" | grep -q '^rounds='; then
|
||||
STILL_DEFAULT=true
|
||||
else
|
||||
for DEFAULT_PW in "free" "gosovransystems"; do
|
||||
case "$ALGO_ID" in
|
||||
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
||||
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
||||
*)
|
||||
# Unknown hash algorithm — treat as still-default for safety
|
||||
STILL_DEFAULT=true
|
||||
break
|
||||
;;
|
||||
esac
|
||||
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
|
||||
STILL_DEFAULT=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$STILL_DEFAULT" = "false" ]; then
|
||||
echo "sovran-auto-seal: password has been changed from factory defaults — live system detected. Restoring flag and exiting."
|
||||
touch /var/lib/sovran-factory-sealed
|
||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── All safety guards passed: this is a fresh/unsealed system ─
|
||||
echo "sovran-auto-seal: fresh system confirmed — performing auto-seal..."
|
||||
|
||||
# ── 1. Wipe generated secrets ─────────────────────────────────
|
||||
echo "sovran-auto-seal: wiping secrets..."
|
||||
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
|
||||
rm -rf /var/lib/matrix-synapse/registration-secret
|
||||
rm -rf /var/lib/matrix-synapse/db-password
|
||||
rm -rf /var/lib/gnome-remote-desktop/rdp-password
|
||||
rm -rf /var/lib/gnome-remote-desktop/rdp-username
|
||||
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
|
||||
rm -rf /var/lib/livekit/livekit_keyFile
|
||||
rm -rf /etc/nix-bitcoin-secrets/*
|
||||
|
||||
# ── 2. Wipe LND wallet data ───────────────────────────────────
|
||||
echo "sovran-auto-seal: wiping LND wallet data..."
|
||||
rm -rf /var/lib/lnd/*
|
||||
|
||||
# ── 3. Remove SSH factory key ─────────────────────────────────
|
||||
echo "sovran-auto-seal: removing SSH factory key..."
|
||||
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
|
||||
if [ -f /root/.ssh/authorized_keys ]; then
|
||||
sed -i '/factory_login/d' /root/.ssh/authorized_keys
|
||||
fi
|
||||
|
||||
# ── 4. Drop application databases ────────────────────────────
|
||||
echo "sovran-auto-seal: dropping application databases..."
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
|
||||
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
|
||||
|
||||
# ── 5. Remove application config files ───────────────────────
|
||||
echo "sovran-auto-seal: removing application config files..."
|
||||
rm -rf /var/lib/www/wordpress/wp-config.php
|
||||
rm -rf /var/lib/www/nextcloud/config/config.php
|
||||
|
||||
# ── 6. Wipe Vaultwarden data ──────────────────────────────────
|
||||
echo "sovran-auto-seal: wiping Vaultwarden data..."
|
||||
rm -rf /var/lib/bitwarden_rs/*
|
||||
rm -rf /var/lib/vaultwarden/*
|
||||
|
||||
# ── 7. Set sealed flag and make it immutable ──────────────────
|
||||
echo "sovran-auto-seal: setting sealed flag..."
|
||||
touch /var/lib/sovran-factory-sealed
|
||||
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
|
||||
|
||||
# ── 8. Remove onboarded flag so onboarding runs fresh ─────────
|
||||
rm -f /var/lib/sovran-customer-onboarded
|
||||
|
||||
echo "sovran-auto-seal: auto-seal complete. Continuing boot into onboarding."
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Legacy security check: warn existing (pre-seal) machines ───────
|
||||
systemd.services.sovran-legacy-security-check = {
|
||||
description = "Check for legacy (pre-factory-seal) security status";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "local-fs.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils pkgs.openssl ];
|
||||
script = ''
|
||||
# If sealed AND onboarded — fully clean, nothing to do
|
||||
[ -f /var/lib/sovran-factory-sealed ] && [ -f /var/lib/sovran-customer-onboarded ] && exit 0
|
||||
|
||||
# If sealed but not yet onboarded — seal was run, customer hasn't finished setup yet, that's fine
|
||||
[ -f /var/lib/sovran-factory-sealed ] && exit 0
|
||||
|
||||
# If onboarded but NOT sealed — installer ran without factory seal!
|
||||
if [ -f /var/lib/sovran-customer-onboarded ] && [ ! -f /var/lib/sovran-factory-sealed ]; then
|
||||
mkdir -p /var/lib/sovran
|
||||
echo "unsealed" > /var/lib/sovran/security-status
|
||||
cat > /var/lib/sovran/security-warning << 'EOF'
|
||||
This machine was set up without the factory seal process. Factory test data — including SSH keys, database contents, and wallet information — may still be present on this system. It is strongly recommended to back up any important data and re-install using a fresh ISO, or contact Sovran Systems support for assistance.
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If the user completed Hub onboarding, they've addressed security
|
||||
[ -f /var/lib/sovran/onboarding-complete ] && exit 0
|
||||
|
||||
# If the free password has been changed from ALL known factory defaults, no warning needed
|
||||
if [ -f /etc/shadow ]; then
|
||||
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
|
||||
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
|
||||
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
|
||||
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
|
||||
STILL_DEFAULT=false
|
||||
# If the salt field starts with "rounds=", we cannot extract the real salt
|
||||
# with a simple cut — treat as still-default for safety
|
||||
if printf '%s' "$SALT" | grep -q '^rounds='; then
|
||||
STILL_DEFAULT=true
|
||||
else
|
||||
for DEFAULT_PW in "free" "gosovransystems"; do
|
||||
case "$ALGO_ID" in
|
||||
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
||||
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
|
||||
*)
|
||||
# Unknown hash algorithm — treat as still-default for safety
|
||||
STILL_DEFAULT=true
|
||||
break
|
||||
;;
|
||||
esac
|
||||
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
|
||||
STILL_DEFAULT=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$STILL_DEFAULT" = "false" ]; then
|
||||
# Password was changed — clear any legacy warning and exit
|
||||
rm -f /var/lib/sovran/security-status /var/lib/sovran/security-warning
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# No flags at all + secrets exist = legacy (pre-seal era) machine
|
||||
if [ -f /var/lib/secrets/root-password ]; then
|
||||
mkdir -p /var/lib/sovran
|
||||
echo "legacy" > /var/lib/sovran/security-status
|
||||
echo "This system was deployed before the factory seal feature. Your passwords may be known to the factory. Please change your passwords through the Sovran Hub." > /var/lib/sovran/security-warning
|
||||
fi
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
|
||||
|
||||
## Add DDNS entries below — one curl per line
|
||||
## Run 'sudo sovran-setup-domains' to configure automatically
|
||||
## Managed via Sovran Hub web interface
|
||||
SCRIPT
|
||||
|
||||
chmod 700 /var/lib/njalla/njalla.sh
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
};
|
||||
|
||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||
@@ -59,6 +60,15 @@
|
||||
};
|
||||
};
|
||||
|
||||
# ── Caddy customisation ───────────────────────────────────
|
||||
caddy = {
|
||||
extraVirtualHosts = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
description = "Additional raw Caddyfile blocks appended to the generated Caddy config. Use this in custom.nix to add custom domains and reverse proxies.";
|
||||
};
|
||||
};
|
||||
|
||||
# ── Domain setup registry ─────────────────────────────────
|
||||
domainRequirements = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
|
||||
@@ -4,16 +4,22 @@ let
|
||||
cfg = config.sovran_systemsOS;
|
||||
|
||||
monitoredServices =
|
||||
# ── Infrastructure (always present) ────────────────────────
|
||||
# ── Infrastructure — System Passwords (always present) ─────
|
||||
[
|
||||
{ name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; }
|
||||
{ name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; }
|
||||
{ name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "system"; enabled = true; category = "infrastructure"; credentials = [
|
||||
{ name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "passwords"; enabled = true; category = "infrastructure"; credentials = [
|
||||
{ label = "Free Account — Username"; value = "free"; }
|
||||
{ label = "Free Account — Password"; file = "/var/lib/secrets/free-password"; }
|
||||
{ label = "Root Password"; file = "/var/lib/secrets/root-password"; }
|
||||
{ label = "SSH Local Access"; value = "ssh root@localhost / Passphrase: gosovransystems"; }
|
||||
{ label = "SSH Passphrase"; file = "/var/lib/secrets/ssh-passphrase"; }
|
||||
]; }
|
||||
]
|
||||
# ── Infrastructure — Caddy + Tor (NOT desktop-only) ────────
|
||||
++ lib.optionals (!cfg.roles.desktop) [
|
||||
{ name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; enabled = true; category = "infrastructure"; credentials = []; }
|
||||
{ name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; enabled = true; category = "infrastructure"; credentials = []; }
|
||||
]
|
||||
# ── Infrastructure — Remote Desktop (all roles) ─────────────
|
||||
++ [
|
||||
{ name = "Remote Desktop"; unit = "gnome-remote-desktop.service"; type = "system"; icon = "rdp"; enabled = cfg.features.rdp; category = "infrastructure"; credentials = [
|
||||
{ label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; }
|
||||
{ label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; }
|
||||
@@ -22,7 +28,7 @@ let
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Base (node implementations) ────────────────────
|
||||
++ [
|
||||
++ lib.optionals cfg.services.bitcoin [
|
||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
@@ -34,7 +40,7 @@ let
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||
++ [
|
||||
++ lib.optionals cfg.services.bitcoin [
|
||||
{ name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; }
|
||||
{ label = "Port"; value = "50001"; }
|
||||
@@ -42,7 +48,7 @@ let
|
||||
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
||||
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Access"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3050"; }
|
||||
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
||||
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; }
|
||||
]; }
|
||||
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
@@ -51,22 +57,34 @@ let
|
||||
]; }
|
||||
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; }
|
||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus <EFBFBD><EFBFBD> Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; }
|
||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; }
|
||||
]; }
|
||||
{ name = "Sparrow Auto-Connect"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||
]; }
|
||||
{ name = "Bisq Auto-Connect"; unit = "bisq-autoconnect.service"; type = "system"; icon = "bisq"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Node"; value = "127.0.0.1:8333 (Bitcoin Core)"; }
|
||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||
]; }
|
||||
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
||||
]; }
|
||||
]
|
||||
# ── Communication ──────────────────────────────────────────
|
||||
++ [
|
||||
# ── Communication (server+desktop only) ────────────────────
|
||||
++ lib.optionals cfg.roles.server_plus_desktop [
|
||||
{ name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; enabled = cfg.services.synapse; category = "communication"; credentials = [
|
||||
{ label = "Users"; file = "/var/lib/secrets/matrix-users"; multiline = true; }
|
||||
{ label = "Homeserver URL"; file = "/var/lib/secrets/matrix-homeserver-url"; }
|
||||
{ label = "Admin Username"; file = "/var/lib/secrets/matrix-admin-username"; }
|
||||
{ label = "Admin Password"; file = "/var/lib/secrets/matrix-admin-password"; }
|
||||
{ label = "Test Username"; file = "/var/lib/secrets/matrix-test-username"; }
|
||||
{ label = "Test Password"; file = "/var/lib/secrets/matrix-test-password"; }
|
||||
]; }
|
||||
{ name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "livekit"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
|
||||
{ name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
|
||||
]
|
||||
# ── Self-Hosted Apps ───────────────────────────────────────
|
||||
++ [
|
||||
# ── Self-Hosted Apps (server+desktop only) ─────────────────
|
||||
++ lib.optionals cfg.roles.server_plus_desktop [
|
||||
{ name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; enabled = cfg.services.vaultwarden; category = "apps"; credentials = [
|
||||
{ label = "URL"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; }
|
||||
{ label = "Admin Panel"; file = "/var/lib/domains/vaultwarden"; prefix = "https://"; suffix = "/admin"; }
|
||||
@@ -79,11 +97,11 @@ let
|
||||
{ label = "Credentials"; file = "/var/lib/secrets/wordpress-admin"; multiline = true; }
|
||||
]; }
|
||||
]
|
||||
# ── Nostr / Relay ──────────────────────────────────────────
|
||||
++ [
|
||||
# ── Nostr / Relay (server+desktop only) ────────────────────
|
||||
++ lib.optionals cfg.roles.server_plus_desktop [
|
||||
{ name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; enabled = cfg.features.haven; category = "nostr"; credentials = []; }
|
||||
]
|
||||
# ── Support ────────────────────────────────────────────────
|
||||
# ── Support (always present) ────────────────────────────────
|
||||
++ [
|
||||
{ name = "Tech Support"; unit = "sovran-tech-support"; type = "support"; icon = "support"; enabled = true; category = "support"; credentials = []; }
|
||||
];
|
||||
@@ -99,6 +117,7 @@ let
|
||||
command_method = "systemctl";
|
||||
role = activeRole;
|
||||
services = monitoredServices;
|
||||
feature_manager = true;
|
||||
});
|
||||
|
||||
# ── Update wrapper script ──────────────────────────────────────
|
||||
@@ -159,6 +178,63 @@ let
|
||||
exit "$RC"
|
||||
'';
|
||||
|
||||
# ── Rebuild wrapper script ─────────────────────────────────────
|
||||
rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" ''
|
||||
set -uo pipefail
|
||||
export PATH="${lib.makeBinPath [ pkgs.nix pkgs.nixos-rebuild pkgs.coreutils ]}:$PATH"
|
||||
|
||||
LOG="/var/log/sovran-hub-rebuild.log"
|
||||
STATUS="/var/log/sovran-hub-rebuild.status"
|
||||
|
||||
echo "RUNNING" > "$STATUS"
|
||||
: > "$LOG"
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " Sovran_SystemsOS Rebuild — $(date)"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "── Rebuilding system configuration ──────────────"
|
||||
if nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Rebuild completed successfully"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "SUCCESS" > "$STATUS"
|
||||
else
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Rebuild failed — see errors above"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "FAILED" > "$STATUS"
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── Hub auto-launch wrapper script ────────────────────────────────
|
||||
hub-autolaunch-script = pkgs.writeShellScript "sovran-hub-autolaunch.sh" ''
|
||||
export PATH="${lib.makeBinPath [ pkgs.curl pkgs.xdg-utils ]}:$PATH"
|
||||
|
||||
DISABLE_FLAG="/var/lib/sovran/hub-autolaunch-disabled"
|
||||
BOOT_FLAG="/run/sovran-hub-autolaunch-done"
|
||||
|
||||
# User disabled auto-launch via Hub toggle
|
||||
[ -f "$DISABLE_FLAG" ] && exit 0
|
||||
|
||||
# Already launched this boot
|
||||
[ -f "$BOOT_FLAG" ] && exit 0
|
||||
|
||||
touch "$BOOT_FLAG"
|
||||
|
||||
# Wait for Hub server to become ready (max ~15 seconds)
|
||||
for i in $(seq 1 15); do
|
||||
curl -s -o /dev/null http://localhost:8937 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
xdg-open http://localhost:8937
|
||||
'';
|
||||
|
||||
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
||||
pname = "sovran-systemsos-hub-web";
|
||||
version = "1.0.0";
|
||||
@@ -186,6 +262,22 @@ let
|
||||
install -d $out/share/sovran-hub/icons
|
||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||
|
||||
install -d $out/share/icons/hicolor/scalable/apps
|
||||
cp sovran_systemsos_web/static/sovran-hub-icon.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg
|
||||
|
||||
install -d $out/share/applications
|
||||
cat > $out/share/applications/sovran-hub.desktop <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Sovran Hub
|
||||
Comment=Open Sovran_SystemsOS Hub dashboard
|
||||
Exec=xdg-open http://localhost:8937
|
||||
Icon=sovran-hub
|
||||
Terminal=false
|
||||
Categories=System;
|
||||
StartupNotify=false
|
||||
DESKTOP
|
||||
|
||||
install -d $out/bin
|
||||
cat > $out/bin/sovran-hub-web <<LAUNCHER
|
||||
#!${pkgs.python3}/bin/python3
|
||||
@@ -230,7 +322,7 @@ in
|
||||
StandardError = "journal";
|
||||
};
|
||||
|
||||
path = [ pkgs.qrencode ];
|
||||
path = [ pkgs.qrencode ] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
|
||||
};
|
||||
|
||||
systemd.services.sovran-hub-update = {
|
||||
@@ -241,6 +333,28 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8937 ];
|
||||
systemd.services.sovran-hub-rebuild = {
|
||||
description = "Sovran_SystemsOS System Rebuild";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${rebuild-script}";
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ sovran-hub-web ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 3051 8937 60847 ];
|
||||
|
||||
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Sovran Hub Auto-Launch
|
||||
Exec=${hub-autolaunch-script}
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
NoDisplay=true
|
||||
'';
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
domains = config.sovran_systemsOS.domainRequirements;
|
||||
|
||||
domainNamesList = lib.concatMapStringsSep " " (d: d.name) domains;
|
||||
|
||||
ddnsPrompt = ''
|
||||
read -p " Njal.la DDNS curl command (paste full line, or Enter to skip): " DDNS_LINE
|
||||
if [ -n "$DDNS_LINE" ]; then
|
||||
# Strip any leading "curl " if they pasted the whole command
|
||||
DDNS_LINE="''${DDNS_LINE#curl }"
|
||||
# Strip surrounding quotes
|
||||
DDNS_LINE="''${DDNS_LINE%\"}"
|
||||
DDNS_LINE="''${DDNS_LINE#\"}"
|
||||
# Replace &auto with &a=''${IP} at the end
|
||||
DDNS_LINE="''${DDNS_LINE%auto}&a=''${DOLLAR}{IP}"
|
||||
# Remove any trailing double &a= if they already had &a=
|
||||
DDNS_LINE=$(echo "$DDNS_LINE" | sed 's/&a=&a=/\&a=/g')
|
||||
'';
|
||||
|
||||
confirmDomain = name: ''
|
||||
while true; do
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$YELLOW" " You entered:" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " Domain: $DOMAIN" "$NC"
|
||||
if [ -n "''${DDNS_DISPLAY:-}" ]; then
|
||||
printf "%b%s%b\n" "$CYAN" " DDNS URL: $DDNS_DISPLAY" "$NC"
|
||||
fi
|
||||
echo ""
|
||||
read -p " Is this correct? (y/n): " CONFIRM
|
||||
case "$CONFIRM" in
|
||||
[yY])
|
||||
echo "$DOMAIN" > "/var/lib/domains/${name}"
|
||||
printf "%b%s%b\n" "$GREEN" " Saved." "$NC"
|
||||
break
|
||||
;;
|
||||
[nN])
|
||||
echo " Let's try again."
|
||||
REDO=true
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo " Please enter y or n."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
'';
|
||||
|
||||
domainPrompts = lib.concatMapStringsSep "\n" (d: ''
|
||||
REDO=true
|
||||
while [ "$REDO" = true ]; do
|
||||
REDO=false
|
||||
DDNS_DISPLAY=""
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$GREEN" "── ${d.label} ──" "$NC"
|
||||
EXISTING=""
|
||||
if [ -f "/var/lib/domains/${d.name}" ]; then
|
||||
EXISTING=$(cat "/var/lib/domains/${d.name}")
|
||||
printf "%b%s%b\n" "$CYAN" " Current: $EXISTING" "$NC"
|
||||
fi
|
||||
read -p " Subdomain (e.g. ${d.example}) or Enter to keep current: " DOMAIN_INPUT
|
||||
DOMAIN="''${DOMAIN_INPUT:-$EXISTING}"
|
||||
|
||||
if [ -n "$DOMAIN" ]; then
|
||||
${lib.optionalString d.needsDDNS ''
|
||||
${ddnsPrompt}
|
||||
DDNS_DISPLAY="$DDNS_LINE"
|
||||
PENDING_NJALLA="curl \"$DDNS_LINE\""
|
||||
fi
|
||||
''}
|
||||
|
||||
${confirmDomain d.name}
|
||||
|
||||
if [ "$REDO" = false ] && [ -n "''${PENDING_NJALLA:-}" ]; then
|
||||
NJALLA_ENTRIES="$NJALLA_ENTRIES
|
||||
$PENDING_NJALLA"
|
||||
PENDING_NJALLA=""
|
||||
fi
|
||||
else
|
||||
echo " Skipped."
|
||||
fi
|
||||
done
|
||||
'') domains;
|
||||
|
||||
missingDomainPrompts = lib.concatMapStringsSep "\n" (d: ''
|
||||
if [ ! -f "/var/lib/domains/${d.name}" ]; then
|
||||
MISSING=true
|
||||
REDO=true
|
||||
while [ "$REDO" = true ]; do
|
||||
REDO=false
|
||||
DDNS_DISPLAY=""
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$GREEN" "── ${d.label} (NEW) ──" "$NC"
|
||||
read -p " Subdomain (e.g. ${d.example}): " DOMAIN
|
||||
|
||||
if [ -n "$DOMAIN" ]; then
|
||||
${lib.optionalString d.needsDDNS ''
|
||||
${ddnsPrompt}
|
||||
DDNS_DISPLAY="$DDNS_LINE"
|
||||
PENDING_NJALLA="curl \"$DDNS_LINE\""
|
||||
fi
|
||||
''}
|
||||
|
||||
${confirmDomain d.name}
|
||||
|
||||
if [ "$REDO" = false ] && [ -n "''${PENDING_NJALLA:-}" ]; then
|
||||
NEW_NJALLA_ENTRIES="$NEW_NJALLA_ENTRIES
|
||||
$PENDING_NJALLA"
|
||||
PENDING_NJALLA=""
|
||||
fi
|
||||
else
|
||||
echo " Skipped."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
'') domains;
|
||||
|
||||
domainSummary = lib.concatMapStringsSep "\n" (d: ''
|
||||
if [ -f "/var/lib/domains/${d.name}" ]; then
|
||||
printf "%b%s%b\n" "$NC" " ${d.label}: $(cat /var/lib/domains/${d.name})" "$NC"
|
||||
fi
|
||||
'') domains;
|
||||
|
||||
setupScript = pkgs.writeShellScriptBin "sovran-setup-domains" ''
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
DOLLAR='$'
|
||||
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " Sovran_SystemsOS — Domain & DDNS Setup" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$YELLOW" "Before running this, you need:" "$NC"
|
||||
echo ""
|
||||
echo " 1. Domains/subdomains purchased on https://njal.la"
|
||||
echo " 2. For each subdomain, add a Dynamic record in"
|
||||
echo " your Njal.la dashboard."
|
||||
echo " 3. Njal.la will give you a curl command like:"
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$CYAN" " curl \"https://njal.la/update/?h=sub.domain.com&k=abc123&auto\"" "$NC"
|
||||
echo ""
|
||||
echo " Have those curl commands ready."
|
||||
echo ""
|
||||
read -p "Press Enter to continue..."
|
||||
|
||||
# ── Create directories ────────────────────────────
|
||||
mkdir -p /var/lib/domains
|
||||
|
||||
NJALLA_ENTRIES=""
|
||||
PENDING_NJALLA=""
|
||||
|
||||
# ── SSL Email ─────────────────────────────────────
|
||||
REDO=true
|
||||
while [ "$REDO" = true ]; do
|
||||
REDO=false
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$GREEN" "── SSL Certificate Email ──" "$NC"
|
||||
echo "Let's Encrypt needs an email for certificate notifications."
|
||||
EXISTING_EMAIL=""
|
||||
if [ -f "/var/lib/domains/sslemail" ]; then
|
||||
EXISTING_EMAIL=$(cat /var/lib/domains/sslemail)
|
||||
printf "%b%s%b\n" "$CYAN" " Current: $EXISTING_EMAIL" "$NC"
|
||||
fi
|
||||
read -p " Email address (or Enter to keep current): " EMAIL_INPUT
|
||||
SSL_EMAIL="''${EMAIL_INPUT:-$EXISTING_EMAIL}"
|
||||
if [ -n "$SSL_EMAIL" ]; then
|
||||
while true; do
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$YELLOW" " You entered:" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " Email: $SSL_EMAIL" "$NC"
|
||||
echo ""
|
||||
read -p " Is this correct? (y/n): " CONFIRM
|
||||
case "$CONFIRM" in
|
||||
[yY])
|
||||
echo "$SSL_EMAIL" > /var/lib/domains/sslemail
|
||||
printf "%b%s%b\n" "$GREEN" " Saved." "$NC"
|
||||
break
|
||||
;;
|
||||
[nN])
|
||||
echo " Let's try again."
|
||||
REDO=true
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo " Please enter y or n."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# ── All module domains ────────────────────────────
|
||||
${domainPrompts}
|
||||
|
||||
# ── Final review ──────────────────────────────────
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " Review All Entries" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
echo ""
|
||||
echo " Configured domains:"
|
||||
${domainSummary}
|
||||
echo ""
|
||||
echo " DDNS entries:"
|
||||
if [ -n "$NJALLA_ENTRIES" ]; then
|
||||
echo "$NJALLA_ENTRIES"
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
echo ""
|
||||
read -p " Does everything look correct? (y/n): " FINAL_CONFIRM
|
||||
if [ "$FINAL_CONFIRM" != "y" ] && [ "$FINAL_CONFIRM" != "Y" ]; then
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$YELLOW" " Setup cancelled. Run 'sudo sovran-setup-domains' to start over." "$NC"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Append curl entries to njalla.sh ──────────────
|
||||
if [ -n "$NJALLA_ENTRIES" ]; then
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$GREEN" "── Updating DDNS script ──" "$NC"
|
||||
echo "$NJALLA_ENTRIES" >> /var/lib/njalla/njalla.sh
|
||||
echo " Appended entries to /var/lib/njalla/njalla.sh"
|
||||
fi
|
||||
|
||||
# ── Run DDNS update now ───────────────────────────
|
||||
echo ""
|
||||
read -p "Update Njal.la DNS records now? (y/n): " RUN_NOW
|
||||
if [ "$RUN_NOW" = "y" ]; then
|
||||
bash /var/lib/njalla/njalla.sh
|
||||
echo " DNS records updated."
|
||||
fi
|
||||
|
||||
# ── Mark setup complete ───────────────────────────
|
||||
touch /var/lib/domains/.setup-complete
|
||||
|
||||
# ── Summary ───────────────────────────────────────
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " Setup Complete!" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
echo ""
|
||||
echo " Domain files: /var/lib/domains/"
|
||||
echo " DDNS script: /var/lib/njalla/njalla.sh"
|
||||
echo " DDNS cron: Every 15 minutes (already configured)"
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$YELLOW" " Rebuilding to activate services with new domains..." "$NC"
|
||||
echo ""
|
||||
nixos-rebuild switch --flake /etc/nixos#nixos
|
||||
'';
|
||||
|
||||
addDomainScript = pkgs.writeShellScriptBin "sovran-add-domains" ''
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
DOLLAR='$'
|
||||
|
||||
MISSING=false
|
||||
NEW_NJALLA_ENTRIES=""
|
||||
PENDING_NJALLA=""
|
||||
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " Sovran_SystemsOS — New Feature Domains" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
echo ""
|
||||
echo " Checking for newly enabled features that need domains..."
|
||||
|
||||
mkdir -p /var/lib/domains
|
||||
|
||||
${missingDomainPrompts}
|
||||
|
||||
if [ "$MISSING" = false ]; then
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$GREEN" " All domains are already configured. Nothing to do." "$NC"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Final review ──────────────────────────────────
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " Review New Entries" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
echo ""
|
||||
echo " All configured domains:"
|
||||
${domainSummary}
|
||||
echo ""
|
||||
echo " New DDNS entries:"
|
||||
if [ -n "$NEW_NJALLA_ENTRIES" ]; then
|
||||
echo "$NEW_NJALLA_ENTRIES"
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
echo ""
|
||||
read -p " Does everything look correct? (y/n): " FINAL_CONFIRM
|
||||
if [ "$FINAL_CONFIRM" != "y" ] && [ "$FINAL_CONFIRM" != "Y" ]; then
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$YELLOW" " Setup cancelled. Run 'sudo sovran-add-domains' to start over." "$NC"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Append new entries to njalla.sh ───────────────
|
||||
if [ -n "$NEW_NJALLA_ENTRIES" ]; then
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$GREEN" "── Updating DDNS script ──" "$NC"
|
||||
echo "$NEW_NJALLA_ENTRIES" >> /var/lib/njalla/njalla.sh
|
||||
echo " Appended new entries to /var/lib/njalla/njalla.sh"
|
||||
|
||||
echo ""
|
||||
read -p "Update Njal.la DNS records now? (y/n): " RUN_NOW
|
||||
if [ "$RUN_NOW" = "y" ]; then
|
||||
bash /var/lib/njalla/njalla.sh
|
||||
echo " DNS records updated."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" " New Domains Added!" "$NC"
|
||||
printf "%b%s%b\n" "$CYAN" "══════════════════════════════════════════════" "$NC"
|
||||
echo ""
|
||||
echo " All configured domains:"
|
||||
${domainSummary}
|
||||
echo ""
|
||||
printf "%b%s%b\n" "$YELLOW" " Rebuilding to activate services with new domains..." "$NC"
|
||||
echo ""
|
||||
nixos-rebuild switch --impure
|
||||
'';
|
||||
|
||||
needsSetup = pkgs.writeShellScriptBin "sovran-domains-need-setup" ''
|
||||
# First boot — no setup done at all
|
||||
if [ ! -f /var/lib/domains/.setup-complete ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Existing machine — check for missing domain files
|
||||
for NAME in ${domainNamesList}; do
|
||||
if [ ! -f "/var/lib/domains/$NAME" ]; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Everything is configured
|
||||
exit 1
|
||||
'';
|
||||
|
||||
in
|
||||
{
|
||||
environment.systemPackages = [
|
||||
setupScript
|
||||
addDomainScript
|
||||
needsSetup
|
||||
];
|
||||
|
||||
environment.etc."xdg/autostart/sovran-setup-domains.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Sovran_SystemsOS Domain Setup
|
||||
Comment=Configure domains for newly enabled features
|
||||
Exec=${pkgs.bash}/bin/bash -c 'if ${needsSetup}/bin/sovran-domains-need-setup; then if [ ! -f /var/lib/domains/.setup-complete ]; then ${pkgs.gnome-terminal}/bin/gnome-terminal -- sudo ${setupScript}/bin/sovran-setup-domains; else ${pkgs.gnome-terminal}/bin/gnome-terminal -- sudo ${addDomainScript}/bin/sovran-add-domains; fi; fi'
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
'';
|
||||
}
|
||||
@@ -12,9 +12,29 @@ lib.mkIf userExists {
|
||||
"d /home/${userName}/.ssh 0700 ${userName} users -"
|
||||
];
|
||||
|
||||
systemd.services.ssh-passphrase-setup = {
|
||||
description = "Generate per-device SSH key passphrase";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "factory-ssh-keygen.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.pwgen pkgs.coreutils ];
|
||||
script = ''
|
||||
if [ ! -f "/var/lib/secrets/ssh-passphrase" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
pwgen -s 20 1 > /var/lib/secrets/ssh-passphrase
|
||||
chmod 600 /var/lib/secrets/ssh-passphrase
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.factory-ssh-keygen = {
|
||||
description = "Generate factory SSH key for ${userName} if missing";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "ssh-passphrase-setup.service" ];
|
||||
requires = [ "ssh-passphrase-setup.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
@@ -22,7 +42,8 @@ lib.mkIf userExists {
|
||||
path = [ pkgs.openssh pkgs.coreutils ];
|
||||
script = ''
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
ssh-keygen -q -N "gosovransystems" -t ed25519 -f "${keyPath}"
|
||||
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
|
||||
ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
|
||||
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
|
||||
chmod 600 "${keyPath}"
|
||||
chmod 644 "${keyPath}.pub"
|
||||
|
||||
21
modules/core/sshd-localhost.nix
Normal file
@@ -0,0 +1,21 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
# ── Always-on localhost SSH ────────────────────────────────────
|
||||
# Provides "ssh root@localhost" for local root access and Hub
|
||||
# operations. Binds exclusively to 127.0.0.1 — zero network exposure.
|
||||
# The sshd *feature flag* in sshd.nix extends this to 0.0.0.0 and
|
||||
# opens port 22 on the firewall when the user enables remote SSH.
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
listenAddresses = lib.mkDefault [
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
];
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
KbdInteractiveAuthentication = false;
|
||||
PermitRootLogin = "yes";
|
||||
};
|
||||
};
|
||||
}
|
||||
42
modules/core/tech-support.nix
Normal file
@@ -0,0 +1,42 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
# ── Tech Support — restricted support user & tooling ─────────────────────────
|
||||
#
|
||||
# This module declaratively provisions the `sovran-support` system account that
|
||||
# the Sovran Hub uses when a user enables remote tech support access.
|
||||
#
|
||||
# Security design:
|
||||
# • Support staff log in as `sovran-support`, not as root.
|
||||
# • Protected directories (LND, bitcoind, nix-bitcoin-secrets, /home) are locked with POSIX ACLs
|
||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
||||
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||
# and view a full audit log of every session event.
|
||||
#
|
||||
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
|
||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
||||
{
|
||||
# ── System packages ────────────────────────────────────────────────────────
|
||||
environment.systemPackages = [ pkgs.acl ];
|
||||
|
||||
# ── Restricted support user and group ─────────────────────────────────────
|
||||
users.groups.sovran-support = {};
|
||||
|
||||
users.users.sovran-support = {
|
||||
isSystemUser = true;
|
||||
group = "sovran-support";
|
||||
description = "Sovran Systems restricted tech support account";
|
||||
home = "/var/lib/sovran-support";
|
||||
createHome = false;
|
||||
# Use a real interactive shell so support staff can run diagnostic commands;
|
||||
# the Hub API limits *when* they can connect (key present only while active).
|
||||
shell = pkgs.bashInteractive;
|
||||
};
|
||||
|
||||
# ── Home and SSH directories ───────────────────────────────────────────────
|
||||
# tmpfiles ensures the directories exist at boot with the correct ownership
|
||||
# even before the first support session is started.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/sovran-support 0700 sovran-support sovran-support -"
|
||||
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
||||
];
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
fonts = pkgs.liberation_ttf;
|
||||
|
||||
# ── Helper: change 'free' password and save it ─────────────
|
||||
change-free-password = pkgs.writeShellScriptBin "change-free-password" ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Error: must be run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "New password for free: "
|
||||
read -rs NEW_PASS
|
||||
echo
|
||||
echo -n "Confirm password: "
|
||||
read -rs CONFIRM
|
||||
echo
|
||||
|
||||
if [ "$NEW_PASS" != "$CONFIRM" ]; then
|
||||
echo "Passwords do not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$NEW_PASS" ]; then
|
||||
echo "Password cannot be empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "free:$NEW_PASS" | ${pkgs.shadow}/bin/chpasswd
|
||||
mkdir -p /var/lib/secrets
|
||||
echo "$NEW_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Password for 'free' updated and saved."
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ── Make helper available system-wide ───────────────────────
|
||||
environment.systemPackages = [ change-free-password ];
|
||||
|
||||
# ── Shell aliases: intercept 'passwd free' ─────────────────
|
||||
programs.bash.interactiveShellInit = ''
|
||||
passwd() {
|
||||
if [ "$1" = "free" ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub and Magic Keys PDF will NOT be updated. ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
command passwd "$@"
|
||||
}
|
||||
'';
|
||||
|
||||
programs.fish.interactiveShellInit = ''
|
||||
function passwd --wraps passwd
|
||||
if test "$argv[1]" = "free"
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub and Magic Keys PDF will NOT be updated. ║"
|
||||
echo "╚════════════════════════════<EFBFBD><EFBFBD>═════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
end
|
||||
command passwd $argv
|
||||
end
|
||||
'';
|
||||
|
||||
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Generate and set a random root password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
ROOT_PASS=$(pwgen -s 20 1)
|
||||
echo "root:$ROOT_PASS" | chpasswd
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1b. Save 'free' password on first boot ─────────────────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Save the initial 'free' user password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
echo "free" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1c. Save Zeus/lndconnect URL for hub credentials ────────
|
||||
systemd.services.zeus-connect-setup = {
|
||||
description = "Save Zeus lndconnect URL";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "lnd.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
URL=""
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Zeus connect URL saved."
|
||||
else
|
||||
echo "No lndconnect URL available yet."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Refresh Zeus URL periodically (certs/macaroons may rotate)
|
||||
systemd.timers.zeus-connect-setup = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "30min";
|
||||
Unit = "zeus-connect-setup.service";
|
||||
};
|
||||
};
|
||||
|
||||
# ── 2. Timer: Check every 5 minutes ────────────────────────
|
||||
systemd.timers.generate-credentials-pdf = {
|
||||
description = "Periodically check if Magic Keys PDF needs regenerating";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "30s";
|
||||
OnUnitActiveSec = "5min";
|
||||
Unit = "generate-credentials-pdf.service";
|
||||
};
|
||||
};
|
||||
|
||||
# ── 3. Generate the Magic Keys PDF ─────────────────────────
|
||||
systemd.services.generate-credentials-pdf = {
|
||||
description = "Generate Magic Keys PDF for Sovran_SystemsOS";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
};
|
||||
|
||||
path = [
|
||||
pkgs.pandoc
|
||||
pkgs.typst
|
||||
pkgs.coreutils
|
||||
pkgs.qrencode
|
||||
pkgs.gnugrep
|
||||
fonts
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
|
||||
environment = {
|
||||
TYPST_FONT_PATHS = "${fonts}/share/fonts";
|
||||
};
|
||||
|
||||
script = ''
|
||||
DOC_DIR="/home/free/Documents"
|
||||
OUTPUT="$DOC_DIR/Sovran_SystemsOS_Magic_Keys.pdf"
|
||||
WORK_DIR="/tmp/magic_keys_build"
|
||||
FILE="$WORK_DIR/magic_keys.md"
|
||||
HASH_FILE="/var/lib/secrets/.magic-keys-hash"
|
||||
|
||||
FENCE='```'
|
||||
|
||||
# ── Collect all secret sources into a single hash ──
|
||||
SECRET_SOURCES=""
|
||||
for f in \
|
||||
/var/lib/secrets/root-password \
|
||||
/var/lib/secrets/free-password \
|
||||
/etc/nix-bitcoin-secrets/rtl-password \
|
||||
/var/lib/tor/onion/rtl/hostname \
|
||||
/var/lib/tor/onion/electrs/hostname \
|
||||
/var/lib/tor/onion/bitcoind/hostname \
|
||||
/var/lib/secrets/matrix-users \
|
||||
/var/lib/gnome-remote-desktop/rdp-credentials \
|
||||
/var/lib/secrets/nextcloud-admin \
|
||||
/var/lib/secrets/wordpress-admin \
|
||||
/var/lib/secrets/vaultwarden/vaultwarden.env \
|
||||
/var/lib/domains/vaultwarden \
|
||||
/var/lib/domains/btcpayserver \
|
||||
/var/lib/secrets/zeus-connect-url; do
|
||||
if [ -f "$f" ]; then
|
||||
SECRET_SOURCES="$SECRET_SOURCES$(cat "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
# Add lndconnect URL to hash sources (changes if certs/macaroons rotate)
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
SECRET_SOURCES="$SECRET_SOURCES$(lndconnect --url 2>/dev/null || true)"
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
SECRET_SOURCES="$SECRET_SOURCES$(lnconnect-clnrest --url 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
CURRENT_HASH=$(echo -n "$SECRET_SOURCES" | sha256sum | cut -d' ' -f1)
|
||||
OLD_HASH=""
|
||||
if [ -f "$HASH_FILE" ]; then
|
||||
OLD_HASH=$(cat "$HASH_FILE")
|
||||
fi
|
||||
|
||||
# ── Skip if PDF exists and nothing changed ──
|
||||
if [ -f "$OUTPUT" ] && [ "$CURRENT_HASH" = "$OLD_HASH" ]; then
|
||||
echo "No changes detected, skipping PDF regeneration."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changes detected (or PDF missing), regenerating..."
|
||||
mkdir -p "$DOC_DIR" "$WORK_DIR"
|
||||
|
||||
# ── Read secrets (default to placeholder if missing) ──
|
||||
read_secret() { if [ -f "$1" ]; then cat "$1"; else echo "$2"; fi; }
|
||||
|
||||
ROOT_PASS=$(read_secret /var/lib/secrets/root-password "Generating...")
|
||||
FREE_PASS=$(read_secret /var/lib/secrets/free-password "free")
|
||||
RTL_PASS=$(read_secret /etc/nix-bitcoin-secrets/rtl-password "Not found")
|
||||
RTL_ONION=$(read_secret /var/lib/tor/onion/rtl/hostname "Not generated yet")
|
||||
ELECTRS_ONION=$(read_secret /var/lib/tor/onion/electrs/hostname "Not generated yet")
|
||||
BITCOIN_ONION=$(read_secret /var/lib/tor/onion/bitcoind/hostname "Not generated yet")
|
||||
|
||||
# ── Generate Zeus QR code PNG if lndconnect URL is available ──
|
||||
ZEUS_URL=""
|
||||
HAS_ZEUS_QR=""
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
ZEUS_URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
ZEUS_URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$ZEUS_URL" ]; then
|
||||
qrencode -o "$WORK_DIR/zeus-qr.png" -s 4 -m 1 -l H "$ZEUS_URL" 2>/dev/null && HAS_ZEUS_QR="1"
|
||||
fi
|
||||
|
||||
# ── Build the Markdown document ──
|
||||
cat > "$FILE" << ENDOFFILE
|
||||
---
|
||||
title: "Sovran SystemsOS Magic Keys"
|
||||
---
|
||||
|
||||
# Your Sovran SystemsOS Magic Keys! 🗝️
|
||||
|
||||
Welcome to your new computer! We have built a lot of cool secret forts (services) for you. To get into your forts, you need your magic keys (passwords).
|
||||
|
||||
Here are all of your keys in one place. **Keep this document safe and do not share it with strangers!**
|
||||
|
||||
> **How this document works:** This PDF is automatically generated by your computer. If any of your passwords, services, or connection details change, this document will automatically update itself within a few minutes. You can always find the latest version right here in your Documents folder. If you accidentally delete it, don't worry — your computer will recreate it for you!
|
||||
|
||||
## 🖥️ Your Computer
|
||||
These are the master keys to the actual machine.
|
||||
|
||||
### 1. Main Screen Unlock (The 'free' account)
|
||||
When you turn the computer on, it usually logs you in automatically. However, if the screen goes to sleep, or **if you enable Remote Desktop (RDP)**, you will need this to log in:
|
||||
- **Username:** \`free\`
|
||||
- **Password:** \`$FREE_PASS\`
|
||||
|
||||
🚨 **VERY IMPORTANT:** You MUST write this password down and keep it safe! If you lose it, you will be locked out of your computer!
|
||||
|
||||
### 2. The Big Boss (Root)
|
||||
Sometimes a pop-up box might ask for an Administrator (Root) password to change a setting. We created a super-secret password just for this!
|
||||
- **Root Password:** \`$ROOT_PASS\`
|
||||
|
||||
### 3. The Hacker Terminal (\`ssh root@localhost\`)
|
||||
Because your main account is so safe, you cannot just type normal commands to become the boss. If you open a black terminal box and want to make big changes, you must use your special factory key!
|
||||
|
||||
Type this exact command into the terminal:
|
||||
\`ssh root@localhost\`
|
||||
|
||||
When it asks for a passphrase, type:
|
||||
- **Terminal Password:** \`gosovransystems\`
|
||||
ENDOFFILE
|
||||
|
||||
# --- BITCOIN ECOSYSTEM ---
|
||||
if [ -f "/etc/nix-bitcoin-secrets/rtl-password" ] || [ -f "/var/lib/tor/onion/rtl/hostname" ]; then
|
||||
cat >> "$FILE" << BITCOIN
|
||||
|
||||
## ⚡ Your Bitcoin & Lightning Node
|
||||
Your computer is a real Bitcoin node! It talks to the network secretly using Tor. Here is how to connect your wallet apps to it:
|
||||
|
||||
### 1. Ride The Lightning (RTL)
|
||||
*This is the control panel for your Lightning Node.*
|
||||
Open the **Tor Browser** and go to this website. Use this password to log in:
|
||||
- **Website:** \`http://$RTL_ONION\`
|
||||
- **Password:** \`$RTL_PASS\`
|
||||
|
||||
### 2. Electrs (Your Private Bank Teller)
|
||||
*If you use a wallet app on your phone or computer (like Sparrow or BlueWallet), tell it to connect here so nobody can spy on your money!*
|
||||
- **Tor Address:** \`$ELECTRS_ONION\`
|
||||
- **Port:** \`50001\`
|
||||
|
||||
### 3. Bitcoin Core
|
||||
*This is the heartbeat of your node. It uses this address to talk to other Bitcoiners securely.*
|
||||
- **Tor Address:** \`$BITCOIN_ONION\`
|
||||
BITCOIN
|
||||
fi
|
||||
|
||||
# --- ZEUS MOBILE WALLET QR CODE ---
|
||||
if [ "$HAS_ZEUS_QR" = "1" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 📱 Connect Zeus Mobile Wallet" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "Take your Bitcoin Lightning node anywhere in the world! Scan this QR code with the **Zeus** app on your phone to instantly connect your mobile wallet to your Lightning node." >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "1. Download **Zeus** from the App Store or Google Play" >> "$FILE"
|
||||
echo "2. Open Zeus and tap **\"Scan Node Config\"**" >> "$FILE"
|
||||
echo "3. Point your phone's camera at this QR code:" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "{ width=200px }" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "That's it! You're now mobile. Send and receive Bitcoin anywhere in the world, powered by your very own node! ⚡" >> "$FILE"
|
||||
elif [ -n "$ZEUS_URL" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 📱 Connect Zeus Mobile Wallet" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "Take your Bitcoin Lightning node anywhere in the world! Paste this connection URL into the **Zeus** app on your phone:" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "1. Download **Zeus** from the App Store or Google Play" >> "$FILE"
|
||||
echo "2. Open Zeus and tap **\"Scan Node Config\"** then **\"Paste Node Config\"**" >> "$FILE"
|
||||
echo "3. Paste this URL:" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
echo "$ZEUS_URL" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "That's it! You're now mobile. Send and receive Bitcoin anywhere in the world, powered by your very own node! ⚡" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- MATRIX / ELEMENT ---
|
||||
if [ -f "/var/lib/secrets/matrix-users" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 💬 Your Private Chat (Matrix / Element)" >> "$FILE"
|
||||
echo "This is your very own private messaging app! Log in using an app like Element with these details:" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/secrets/matrix-users >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- GNOME RDP ---
|
||||
if [ -f "/var/lib/gnome-remote-desktop/rdp-credentials" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 🌎 Connect from Far Away (Remote Desktop)" >> "$FILE"
|
||||
echo "This lets you control your computer screen from another device!" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/gnome-remote-desktop/rdp-credentials >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- NEXTCLOUD ---
|
||||
if [ -f "/var/lib/secrets/nextcloud-admin" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## ☁️ Your Personal Cloud (Nextcloud)" >> "$FILE"
|
||||
echo "This is like your own private Google Drive!" >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/secrets/nextcloud-admin >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- WORDPRESS ---
|
||||
if [ -f "/var/lib/secrets/wordpress-admin" ]; then
|
||||
echo "" >> "$FILE"
|
||||
echo "## 📝 Your Website (WordPress)" >> "$FILE"
|
||||
echo "This is your very own website where you can write blogs or make pages." >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
cat /var/lib/secrets/wordpress-admin >> "$FILE"
|
||||
echo "$FENCE" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- VAULTWARDEN ---
|
||||
if [ -f "/var/lib/domains/vaultwarden" ]; then
|
||||
DOMAIN=$(cat /var/lib/domains/vaultwarden)
|
||||
VW_ADMIN_TOKEN="Not found"
|
||||
if [ -f "/var/lib/secrets/vaultwarden/vaultwarden.env" ]; then
|
||||
VW_ADMIN_TOKEN=$(grep -oP 'ADMIN_TOKEN=\K.*' /var/lib/secrets/vaultwarden/vaultwarden.env || echo "Not found")
|
||||
fi
|
||||
echo "" >> "$FILE"
|
||||
echo "## 🔐 Your Password Manager (Vaultwarden)" >> "$FILE"
|
||||
echo "This keeps all your other passwords safe! Go to this website to use it:" >> "$FILE"
|
||||
echo "- **Website:** https://$DOMAIN" >> "$FILE"
|
||||
echo "- **Admin Panel:** https://$DOMAIN/admin" >> "$FILE"
|
||||
echo "- **Admin Token:** \`$VW_ADMIN_TOKEN\`" >> "$FILE"
|
||||
echo "" >> "$FILE"
|
||||
echo "*(Create your own account on the main page. Use the Admin Token to access the admin panel and manage your server.)*" >> "$FILE"
|
||||
fi
|
||||
|
||||
# --- BTCPAY SERVER ---
|
||||
if [ -f "/var/lib/domains/btcpayserver" ]; then
|
||||
DOMAIN=$(cat /var/lib/domains/btcpayserver)
|
||||
echo "" >> "$FILE"
|
||||
echo "## ₿ Your Bitcoin Store (BTCPay Server)" >> "$FILE"
|
||||
echo "This lets you accept Bitcoin like a real shop!" >> "$FILE"
|
||||
echo "- **Website:** https://$DOMAIN" >> "$FILE"
|
||||
echo "*(You make up your own Admin Password the first time you visit!)*" >> "$FILE"
|
||||
fi
|
||||
|
||||
# ── Generate PDF (cd into work dir so Typst finds images) ──
|
||||
cd "$WORK_DIR"
|
||||
pandoc magic_keys.md -o "$OUTPUT" --pdf-engine=typst \
|
||||
-V mainfont="Liberation Sans" \
|
||||
-V monofont="Liberation Mono"
|
||||
|
||||
chown free:users "$OUTPUT"
|
||||
|
||||
# ── Save hash so we skip next time if nothing changed ──
|
||||
mkdir -p "$(dirname "$HASH_FILE")"
|
||||
echo "$CURRENT_HASH" > "$HASH_FILE"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
echo "PDF generated successfully."
|
||||
'';
|
||||
};
|
||||
}
|
||||
117
modules/credentials.nix
Normal file
@@ -0,0 +1,117 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
# ── Helper: change 'free' password and save it ─────────────
|
||||
change-free-password = pkgs.writeShellScriptBin "change-free-password" ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Error: must be run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "New password for free: "
|
||||
read -rs NEW_PASS
|
||||
echo
|
||||
echo -n "Confirm password: "
|
||||
read -rs CONFIRM
|
||||
echo
|
||||
|
||||
if [ "$NEW_PASS" != "$CONFIRM" ]; then
|
||||
echo "Passwords do not match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$NEW_PASS" ]; then
|
||||
echo "Password cannot be empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "free:$NEW_PASS" | ${pkgs.shadow}/bin/chpasswd
|
||||
mkdir -p /var/lib/secrets
|
||||
echo "$NEW_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Password for 'free' updated and saved."
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ── Make helper available system-wide ───────────────────────
|
||||
environment.systemPackages = [ change-free-password ];
|
||||
|
||||
# ── Shell aliases: intercept 'passwd free' ─────────────────
|
||||
programs.bash.interactiveShellInit = ''
|
||||
passwd() {
|
||||
if [ "$1" = "free" ]; then
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub credentials view will NOT be updated. ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
command passwd "$@"
|
||||
}
|
||||
'';
|
||||
|
||||
programs.fish.interactiveShellInit = ''
|
||||
function passwd --wraps passwd
|
||||
if test "$argv[1]" = "free"
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ ⚠ Use 'sudo change-free-password' instead. ║"
|
||||
echo "║ ║"
|
||||
echo "║ 'passwd free' only updates /etc/shadow. ║"
|
||||
echo "║ The Hub credentials view will NOT be updated. ║"
|
||||
echo "╚════════════════════════════<EFBFBD><EFBFBD>═════════════════════════╝"
|
||||
echo ""
|
||||
return 1
|
||||
end
|
||||
command passwd $argv
|
||||
end
|
||||
'';
|
||||
|
||||
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Generate and set a random root password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
ROOT_PASS=$(pwgen -s 20 1)
|
||||
echo "root:$ROOT_PASS" | chpasswd
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1b. Save 'free' password on first boot ─────────────────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Save the initial 'free' user password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
echo "free" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
}
|
||||
@@ -11,15 +11,4 @@ lib.mkIf config.sovran_systemsOS.features.mempool {
|
||||
|
||||
nix-bitcoin.onionServices.mempool-frontend.enable = true;
|
||||
|
||||
services.caddy = {
|
||||
virtualHosts = {
|
||||
":60847" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -8,14 +8,16 @@
|
||||
./core/caddy.nix
|
||||
./core/njalla.nix
|
||||
./core/ssh-bootstrap.nix
|
||||
./core/sovran-manage-domains.nix
|
||||
./core/tech-support.nix
|
||||
./core/sovran_systemsos-desktop.nix
|
||||
./core/sshd-localhost.nix
|
||||
./core/sovran-hub.nix
|
||||
./core/factory-seal.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
./Sovran_SystemsOS_File_Fixes_And_New_Services.nix
|
||||
./credentials-pdf.nix
|
||||
./credentials.nix
|
||||
|
||||
# ── Services (default ON — disable in custom.nix) ─────────
|
||||
./synapse.nix
|
||||
@@ -23,6 +25,7 @@
|
||||
./nextcloud.nix
|
||||
./vaultwarden.nix
|
||||
./bitcoinecosystem.nix
|
||||
./wallet-autoconnect.nix
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
@@ -31,5 +34,6 @@
|
||||
./mempool.nix
|
||||
./bitcoin-core.nix
|
||||
./rdp.nix
|
||||
./sshd.nix
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
path = with pkgs; [ curl unzip php pwgen coreutils ];
|
||||
path = with pkgs; [ curl unzip php pwgen coreutils shadow util-linux ];
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
@@ -109,7 +109,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
for i in $(seq 1 30); do
|
||||
if su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then
|
||||
if /run/wrappers/bin/su -s /bin/sh caddy -c "php -r \"new PDO('pgsql:host=$DB_HOST;dbname=$DB_NAME', '$DB_USER', '$DB_PASS');\"" 2>/dev/null; then
|
||||
echo "Database ready."
|
||||
break
|
||||
fi
|
||||
@@ -117,7 +117,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
done
|
||||
|
||||
echo "Running Nextcloud installation..."
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ maintenance:install \
|
||||
--database 'pgsql' \
|
||||
--database-name '$DB_NAME' \
|
||||
@@ -129,19 +129,19 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
--data-dir '$DATA_DIR'
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN'
|
||||
php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN'
|
||||
php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https'
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
|
||||
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||
php $INSTALL_DIR/occ background:cron
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
php $INSTALL_DIR/occ app:install calendar || true
|
||||
php $INSTALL_DIR/occ app:install contacts || true
|
||||
php $INSTALL_DIR/occ app:install tasks || true
|
||||
@@ -187,4 +187,4 @@ CREDS
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{ name = "nextcloud"; label = "Nextcloud"; example = "cloud.yourdomain.com"; }
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
};
|
||||
users.groups.gnome-remote-desktop = {};
|
||||
|
||||
# Enable the GNOME Remote Desktop service at the system level
|
||||
services.gnome.gnome-remote-desktop.enable = true;
|
||||
|
||||
# Open RDP port in the firewall
|
||||
networking.firewall.allowedTCPPorts = [ 3389 ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
@@ -42,20 +48,31 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
|
||||
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
|
||||
|
||||
# Generate TLS certificate if it doesn't exist
|
||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ]; then
|
||||
# Regenerate TLS certificate if missing OR if ownership is wrong
|
||||
# (disable/re-enable cycle can break ownership or grdctl state)
|
||||
NEED_REGEN=0
|
||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
||||
NEED_REGEN=1
|
||||
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
|
||||
NEED_REGEN=1
|
||||
fi
|
||||
|
||||
if [ "$NEED_REGEN" = "1" ]; then
|
||||
mkdir -p "$TLS_DIR"
|
||||
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-sha256 -nodes -days 3650 \
|
||||
-keyout "$TLS_DIR/rdp-tls.key" \
|
||||
-out "$TLS_DIR/rdp-tls.crt" \
|
||||
-subj "/CN=gnome-remote-desktop"
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||
echo "Generated RDP TLS certificate"
|
||||
echo "Generated new RDP TLS certificate"
|
||||
fi
|
||||
|
||||
# Always fix ownership and permissions (handles re-enable after disable)
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||
|
||||
# Configure TLS certificate
|
||||
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
|
||||
20
modules/sshd.nix
Normal file
@@ -0,0 +1,20 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.sshd {
|
||||
|
||||
# Extend to listen on all interfaces for remote access
|
||||
services.openssh.listenAddresses = lib.mkForce [
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
{ addr = "0.0.0.0"; port = 22; }
|
||||
];
|
||||
|
||||
# Only open port 22 when SSH is actually enabled
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
# Fail2Ban protects SSH when it's active
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
ignoreIP = [ "127.0.0.0/8" "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16" ];
|
||||
};
|
||||
|
||||
}
|
||||
@@ -151,10 +151,10 @@ EOF
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.pwgen pkgs.matrix-synapse pkgs.curl pkgs.coreutils pkgs.jq ];
|
||||
path = [ pkgs.pwgen pkgs.matrix-synapse pkgs.curl pkgs.gawk pkgs.coreutils pkgs.jq ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Wait for Synapse to be fully responsive
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:8008/_matrix/client/versions > /dev/null; then
|
||||
@@ -167,26 +167,38 @@ EOF
|
||||
CREDS_FILE="/var/lib/secrets/matrix-users"
|
||||
SECRET=$(cat /var/lib/matrix-synapse/registration-secret)
|
||||
|
||||
# Only run if we haven't already generated the file
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
ADMIN_USER="admin"
|
||||
TEST_USER="test"
|
||||
ADMIN_PASS=""
|
||||
TEST_PASS=""
|
||||
|
||||
# Only run user registration if we haven't already generated the credentials file
|
||||
if [ ! -f "$CREDS_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASS=$(pwgen -s 24 1)
|
||||
|
||||
TEST_USER="test"
|
||||
TEST_PASS=$(pwgen -s 24 1)
|
||||
|
||||
# Create Admin user
|
||||
register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||
-u "$ADMIN_USER" -p "$ADMIN_PASS" -a http://localhost:8008
|
||||
ADMIN_CREATED=true
|
||||
TEST_CREATED=true
|
||||
|
||||
# Create Test user (non-admin)
|
||||
register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||
-u "$TEST_USER" -p "$TEST_PASS" --no-admin http://localhost:8008
|
||||
# Create Admin user (tolerate "already exists")
|
||||
if ! register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||
-u "$ADMIN_USER" -p "$ADMIN_PASS" -a http://localhost:8008 2>&1; then
|
||||
echo "Admin user already exists, skipping."
|
||||
ADMIN_CREATED=false
|
||||
fi
|
||||
|
||||
# Save the credentials
|
||||
cat > "$CREDS_FILE" << CREDS
|
||||
# Create Test user (tolerate "already exists")
|
||||
if ! register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||
-u "$TEST_USER" -p "$TEST_PASS" --no-admin http://localhost:8008 2>&1; then
|
||||
echo "Test user already exists, skipping."
|
||||
TEST_CREATED=false
|
||||
fi
|
||||
|
||||
# Write credentials file
|
||||
if [ "$ADMIN_CREATED" = true ] && [ "$TEST_CREATED" = true ]; then
|
||||
cat > "$CREDS_FILE" << CREDS
|
||||
Matrix (Element) Credentials
|
||||
════════════════════════════
|
||||
Homeserver URL: https://$DOMAIN
|
||||
@@ -199,10 +211,43 @@ Password: $ADMIN_PASS
|
||||
Username: @$TEST_USER:$DOMAIN
|
||||
Password: $TEST_PASS
|
||||
CREDS
|
||||
else
|
||||
cat > "$CREDS_FILE" << CREDS
|
||||
Matrix (Element) Credentials
|
||||
════════════════════════════
|
||||
Homeserver URL: https://$DOMAIN
|
||||
|
||||
[ Admin Account ]
|
||||
Username: @$ADMIN_USER:$DOMAIN
|
||||
Password: $(if [ "$ADMIN_CREATED" = true ]; then echo "$ADMIN_PASS"; else echo "(pre-existing — password set during original setup)"; fi)
|
||||
|
||||
[ Test Account ]
|
||||
Username: @$TEST_USER:$DOMAIN
|
||||
Password: $(if [ "$TEST_CREATED" = true ]; then echo "$TEST_PASS"; else echo "(pre-existing — password set during original setup)"; fi)
|
||||
CREDS
|
||||
fi
|
||||
|
||||
chmod 600 "$CREDS_FILE"
|
||||
echo "Matrix users created successfully."
|
||||
fi
|
||||
|
||||
# Always write individual credential files for the hub UI, even if the bulk
|
||||
# credentials file already existed from a prior run (umask 077 ensures mode 600).
|
||||
# If passwords were not freshly generated above, parse them from the bulk file.
|
||||
if [ -z "$ADMIN_PASS" ]; then
|
||||
ADMIN_PASS=$(awk '/\[ Admin Account \]/{f=1} f && /^Password:/{sub(/^Password: /,""); print; exit}' "$CREDS_FILE")
|
||||
[ -z "$ADMIN_PASS" ] && ADMIN_PASS="Password not available — check $CREDS_FILE"
|
||||
fi
|
||||
if [ -z "$TEST_PASS" ]; then
|
||||
TEST_PASS=$(awk '/\[ Test Account \]/{f=1} f && /^Password:/{sub(/^Password: /,""); print; exit}' "$CREDS_FILE")
|
||||
[ -z "$TEST_PASS" ] && TEST_PASS="Password not available — check $CREDS_FILE"
|
||||
fi
|
||||
(umask 077; echo "https://$DOMAIN" > /var/lib/secrets/matrix-homeserver-url)
|
||||
(umask 077; echo "@$ADMIN_USER:$DOMAIN" > /var/lib/secrets/matrix-admin-username)
|
||||
(umask 077; echo "$ADMIN_PASS" > /var/lib/secrets/matrix-admin-password)
|
||||
(umask 077; echo "@$TEST_USER:$DOMAIN" > /var/lib/secrets/matrix-test-username)
|
||||
(umask 077; echo "$TEST_PASS" > /var/lib/secrets/matrix-test-password)
|
||||
|
||||
echo "Matrix users setup completed."
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
124
modules/wallet-autoconnect.nix
Normal file
@@ -0,0 +1,124 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
# ── Sparrow Wallet Auto-Connect ─────────────────────────────
|
||||
systemd.services.sparrow-autoconnect = {
|
||||
description = "Auto-configure Sparrow Wallet to use local Electrs node";
|
||||
after = [ "electrs.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils pkgs.iproute2 ];
|
||||
script = ''
|
||||
CONFIG_FILE="/home/free/.sparrow/config"
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
echo "Sparrow config already exists, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wait for Electrs to be ready (up to 30 attempts)
|
||||
ATTEMPTS=0
|
||||
until ss -ltn 2>/dev/null | grep -q ':50001' || [ "$ATTEMPTS" -ge 30 ]; do
|
||||
ATTEMPTS=$((ATTEMPTS + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
mkdir -p /home/free/.sparrow
|
||||
|
||||
cat > "$CONFIG_FILE" << 'EOF'
|
||||
{
|
||||
"serverType": "ELECTRUM_SERVER",
|
||||
"electrumServer": "tcp://127.0.0.1:50001",
|
||||
"useProxy": false
|
||||
}
|
||||
EOF
|
||||
|
||||
chown -R free:users /home/free/.sparrow
|
||||
echo "Sparrow auto-configured to use local Electrs node"
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Bisq 1 Auto-Connect ─────────────────────────────────────
|
||||
systemd.services.bisq-autoconnect = {
|
||||
description = "Auto-configure Bisq to use local Bitcoin node";
|
||||
after = [ "bitcoind.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils pkgs.iproute2 ];
|
||||
script = ''
|
||||
BISQ_CONF="/home/free/.local/share/Bisq/bisq.properties"
|
||||
|
||||
if [ -f "$BISQ_CONF" ]; then
|
||||
echo "Bisq config already exists, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wait for bitcoind RPC to be ready (up to 30 attempts)
|
||||
ATTEMPTS=0
|
||||
until ss -ltn 2>/dev/null | grep -q ':8333' || [ "$ATTEMPTS" -ge 30 ]; do
|
||||
ATTEMPTS=$((ATTEMPTS + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
mkdir -p /home/free/.local/share/Bisq
|
||||
|
||||
cat > "$BISQ_CONF" << 'EOF'
|
||||
btcNodes=127.0.0.1:8333
|
||||
useTorForBtc=true
|
||||
useCustomBtcNodes=true
|
||||
EOF
|
||||
|
||||
chown -R free:users /home/free/.local/share/Bisq
|
||||
echo "Bisq auto-configured to use local Bitcoin node"
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Zeus Connect (lndconnect URL for mobile wallet) ──────────
|
||||
systemd.services.zeus-connect-setup = {
|
||||
description = "Save Zeus lndconnect URL";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "lnd.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
||||
script = ''
|
||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
URL=""
|
||||
if command -v lndconnect >/dev/null 2>&1; then
|
||||
URL=$(lndconnect --url 2>/dev/null || true)
|
||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$URL" ]; then
|
||||
echo "$URL" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Zeus connect URL saved."
|
||||
else
|
||||
echo "No lndconnect URL available yet."
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Refresh Zeus URL periodically (certs/macaroons may rotate)
|
||||
systemd.timers.zeus-connect-setup = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "30min";
|
||||
Unit = "zeus-connect-setup.service";
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -60,7 +60,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
path = with pkgs; [ curl unzip wp-cli pwgen php coreutils ];
|
||||
path = with pkgs; [ curl unzip wp-cli pwgen php coreutils shadow util-linux ];
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
@@ -97,7 +97,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
|
||||
echo "Generating wp-config.php..."
|
||||
cd "$INSTALL_DIR"
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp config create \
|
||||
--dbname='$DB_NAME' \
|
||||
--dbuser='$DB_USER' \
|
||||
@@ -108,14 +108,14 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
|
||||
echo "Waiting for database..."
|
||||
for i in $(seq 1 30); do
|
||||
if su -s /bin/sh caddy -c "wp db check" 2>/dev/null; then
|
||||
if /run/wrappers/bin/su -s /bin/sh caddy -c "wp db check" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Running WordPress core install..."
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp core install \
|
||||
--url='https://$DOMAIN' \
|
||||
--title='Sovran_SystemsOS' \
|
||||
@@ -125,7 +125,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
--skip-email
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp option update blogdescription 'Powered by Sovran_SystemsOS'
|
||||
wp option update permalink_structure '/%postname%/'
|
||||
wp option update default_ping_status 'closed'
|
||||
@@ -133,7 +133,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
|
||||
wp rewrite flush
|
||||
"
|
||||
|
||||
su -s /bin/sh caddy -c "
|
||||
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||
wp config set DISALLOW_FILE_EDIT true --raw
|
||||
wp config set WP_AUTO_UPDATE_CORE true --raw
|
||||
wp config set FORCE_SSL_ADMIN true --raw
|
||||
|
||||