mirror of
https://github.com/pelican-dev/panel.git
synced 2026-02-24 19:08:53 +03:00
Compare commits
666 Commits
3.x
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8080435eca | ||
|
|
c5824ff26c | ||
|
|
dd7a01aa04 | ||
|
|
55badb5644 | ||
|
|
93f059025c | ||
|
|
7be0cd6928 | ||
|
|
0156456919 | ||
|
|
b9d1ce4438 | ||
|
|
9ce262bf56 | ||
|
|
7ee52affb2 | ||
|
|
93bfe925b9 | ||
|
|
cc1ac1eba1 | ||
|
|
02d24b8a36 | ||
|
|
16fac3b5c6 | ||
|
|
eb99f53d87 | ||
|
|
643e4168b9 | ||
|
|
51cd7a8e81 | ||
|
|
91bf38b63d | ||
|
|
e3699f34d8 | ||
|
|
dc3da2dc98 | ||
|
|
d245751c97 | ||
|
|
e0d7a094ab | ||
|
|
3010e3d61e | ||
|
|
d68e7218a8 | ||
|
|
a4435a7454 | ||
|
|
df26c4f9f5 | ||
|
|
6f1de67523 | ||
|
|
6f009ee126 | ||
|
|
328e159c6b | ||
|
|
f9fd426aca | ||
|
|
6166fac929 | ||
|
|
4bd1070025 | ||
|
|
2d6e30b646 | ||
|
|
f61c6b9dc2 | ||
|
|
5e29737dc5 | ||
|
|
d996019204 | ||
|
|
91d8dbd084 | ||
|
|
bb03ddda50 | ||
|
|
1c66681c0e | ||
|
|
0728266826 | ||
|
|
d81c9faac6 | ||
|
|
cff54f1969 | ||
|
|
201563a13b | ||
|
|
8f2261f6cd | ||
|
|
29cc92f0dc | ||
|
|
33f10cbcb9 | ||
|
|
b538532e34 | ||
|
|
a892821b4f | ||
|
|
5a3b50b31f | ||
|
|
51b217571b | ||
|
|
6e75c76c60 | ||
|
|
e22c5c3e0a | ||
|
|
f3171939a4 | ||
|
|
189d564f87 | ||
|
|
7926f97c8e | ||
|
|
f4d39c1c68 | ||
|
|
6c2d0a2d50 | ||
|
|
f6899301fd | ||
|
|
cbb4ef1da2 | ||
|
|
f6ef76d98e | ||
|
|
65a697d8f7 | ||
|
|
9515a82a75 | ||
|
|
44f5ea567f | ||
|
|
88f910f3e7 | ||
|
|
020f028008 | ||
|
|
0cb7f737b0 | ||
|
|
53aa52f519 | ||
|
|
e884eda5a7 | ||
|
|
58d1fd3917 | ||
|
|
0b0952650e | ||
|
|
aa55a7ed83 | ||
|
|
c7fa7a1bad | ||
|
|
4a3bdd78ef | ||
|
|
a1067fd4aa | ||
|
|
110cc1248b | ||
|
|
04a1ccc97e | ||
|
|
5e7f5c2a4c | ||
|
|
b804878d7b | ||
|
|
118977c8c5 | ||
|
|
c31b7b8c6a | ||
|
|
eefe59b153 | ||
|
|
cd4b7cbf9e | ||
|
|
67cb3d4816 | ||
|
|
7762e68a6c | ||
|
|
7a327ea378 | ||
|
|
b3ca7b7ac9 | ||
|
|
abc99cd928 | ||
|
|
cb638369cf | ||
|
|
9174de2d8c | ||
|
|
7cda358b66 | ||
|
|
33f6551b21 | ||
|
|
b1928e89b4 | ||
|
|
c956cd0106 | ||
|
|
5081cc3f63 | ||
|
|
8eb2c23420 | ||
|
|
cfe385f53a | ||
|
|
264d3498a6 | ||
|
|
065f3f2468 | ||
|
|
957638d4ac | ||
|
|
7d0ce1627b | ||
|
|
8cec7368ab | ||
|
|
5519931ee5 | ||
|
|
97ac0fe54b | ||
|
|
7657364208 | ||
|
|
ef1a208b95 | ||
|
|
aa82c6dd04 | ||
|
|
8ecabef6b5 | ||
|
|
a6d07ede5a | ||
|
|
f6325c07c4 | ||
|
|
7674ee0e2b | ||
|
|
5760e72b8f | ||
|
|
b6e46f758d | ||
|
|
e980877bbc | ||
|
|
dd223b47c0 | ||
|
|
639fa3399d | ||
|
|
82fd547484 | ||
|
|
d461242f08 | ||
|
|
dec1cf8e74 | ||
|
|
15caac51fb | ||
|
|
183c274a0d | ||
|
|
a8b2fb440f | ||
|
|
f8e4514998 | ||
|
|
deeebf73d3 | ||
|
|
422fc102c9 | ||
|
|
e715e92f9d | ||
|
|
73babfa2b3 | ||
|
|
e0a92d733b | ||
|
|
1e67cd9944 | ||
|
|
3946116dff | ||
|
|
b77fd3d653 | ||
|
|
f4672c6cb1 | ||
|
|
5b9e4b1729 | ||
|
|
48f715ae69 | ||
|
|
51460782cc | ||
|
|
b007e63937 | ||
|
|
4dd833562b | ||
|
|
b579f14f3f | ||
|
|
eadaec1b30 | ||
|
|
a9e58bb493 | ||
|
|
5c33c7495a | ||
|
|
f9aa8cf218 | ||
|
|
da698a3666 | ||
|
|
2808a3dd35 | ||
|
|
7ea365e8de | ||
|
|
ae399f9bad | ||
|
|
53a5ff6e6d | ||
|
|
54ae4b3dc1 | ||
|
|
859a721e17 | ||
|
|
03cbdd5bdd | ||
|
|
4c43fd1683 | ||
|
|
0c61a63191 | ||
|
|
b1f99ca8a3 | ||
|
|
0a5810358a | ||
|
|
1bae239971 | ||
|
|
597f74f105 | ||
|
|
5344d99a40 | ||
|
|
1db1a1a3e0 | ||
|
|
712b6a285b | ||
|
|
38b92ae21d | ||
|
|
c18d291f8f | ||
|
|
45c0cfe4d8 | ||
|
|
89d555f308 | ||
|
|
9b2e00ead2 | ||
|
|
d0997dca1a | ||
|
|
cca723d21e | ||
|
|
68dbd6e329 | ||
|
|
544364f061 | ||
|
|
649f319776 | ||
|
|
6ffe800e0d | ||
|
|
12af8fe51e | ||
|
|
9f5d27896a | ||
|
|
15969da41e | ||
|
|
628a6e54e3 | ||
|
|
edcd406b82 | ||
|
|
1742061807 | ||
|
|
289eda64ad | ||
|
|
cc46c463ac | ||
|
|
e6dded61a4 | ||
|
|
921b76f1e1 | ||
|
|
91a3bb969e | ||
|
|
8b62df6c53 | ||
|
|
3c14e1ffa4 | ||
|
|
095fff7ad6 | ||
|
|
0cd03e95f4 | ||
|
|
00dda7dbe4 | ||
|
|
eee1260fcc | ||
|
|
71c91e086f | ||
|
|
94bbf46659 | ||
|
|
558b8b2ea7 | ||
|
|
4bb16887c4 | ||
|
|
0c72833af7 | ||
|
|
ee3f8cd3ec | ||
|
|
b513366f35 | ||
|
|
b1d056a301 | ||
|
|
c72d3f1338 | ||
|
|
7e6231e6de | ||
|
|
4ad837ff14 | ||
|
|
d7ebde5f5f | ||
|
|
6bdd1b3ccb | ||
|
|
afd9f2eb0e | ||
|
|
8a617d416e | ||
|
|
7f4cc64d11 | ||
|
|
38e117e304 | ||
|
|
4d272a1234 | ||
|
|
e9dc6cd32c | ||
|
|
98ba2c1b8b | ||
|
|
2d643ec79f | ||
|
|
2b4625e9b9 | ||
|
|
9f40ed2f84 | ||
|
|
af797b3018 | ||
|
|
e5146e0dbb | ||
|
|
586d1b413c | ||
|
|
7d239de7f6 | ||
|
|
5f13c15c70 | ||
|
|
4e3f919d8e | ||
|
|
7efa04f1ef | ||
|
|
35ac1f863a | ||
|
|
68195ab0b7 | ||
|
|
f79dac2d13 | ||
|
|
4f176c47d2 | ||
|
|
f7e7864dfe | ||
|
|
2f8a15facd | ||
|
|
7bf175190b | ||
|
|
8866ca3d96 | ||
|
|
703e5480ff | ||
|
|
45a6c37594 | ||
|
|
340085cc9b | ||
|
|
ef9d1ab614 | ||
|
|
55a0bfdf7e | ||
|
|
d6f3934f80 | ||
|
|
f9e8adad30 | ||
|
|
08dc5753d4 | ||
|
|
0c851ea075 | ||
|
|
cf3ea38e65 | ||
|
|
b813de0467 | ||
|
|
399bed7576 | ||
|
|
5aa7128b9c | ||
|
|
92d167eb10 | ||
|
|
f02eb5bfba | ||
|
|
f348ac9f0a | ||
|
|
893b2dca89 | ||
|
|
1f5217a9d9 | ||
|
|
e84b47410a | ||
|
|
76cf4391ae | ||
|
|
7688006574 | ||
|
|
ed36041a7e | ||
|
|
bb52485606 | ||
|
|
615b70f9a2 | ||
|
|
3609873c4c | ||
|
|
7f6e4a18c1 | ||
|
|
c5b441e54a | ||
|
|
30452890f5 | ||
|
|
af18a3704f | ||
|
|
2c98693bd2 | ||
|
|
259599b441 | ||
|
|
2da058be49 | ||
|
|
8d33e20f6c | ||
|
|
b4e8f0586a | ||
|
|
6f8f5e2746 | ||
|
|
37cc3ae20d | ||
|
|
9fc46b9ae5 | ||
|
|
d409ff037c | ||
|
|
000363cd17 | ||
|
|
2beb12c04f | ||
|
|
373ede8548 | ||
|
|
e286100197 | ||
|
|
082163389a | ||
|
|
f9247c9318 | ||
|
|
5a00b8690d | ||
|
|
c0ca189536 | ||
|
|
84a3ceeae3 | ||
|
|
1c499d84cf | ||
|
|
871e93a38c | ||
|
|
1481338eb9 | ||
|
|
1e4fadda24 | ||
|
|
585fe8d1a1 | ||
|
|
f58c697d28 | ||
|
|
b32f8966e1 | ||
|
|
b18ebeefdc | ||
|
|
5fd7e419d9 | ||
|
|
602c1ed9a6 | ||
|
|
39bc87c2e2 | ||
|
|
44cc5a8132 | ||
|
|
0c7ae26313 | ||
|
|
102955bf6a | ||
|
|
72eb3ce467 | ||
|
|
d656f21cd9 | ||
|
|
8da5afb35e | ||
|
|
44e9da93b6 | ||
|
|
29e1bd4757 | ||
|
|
f1493c5139 | ||
|
|
30a668c84a | ||
|
|
9f4bf8777e | ||
|
|
dfe2e9d629 | ||
|
|
607e186082 | ||
|
|
e60c86a87e | ||
|
|
577479edaf | ||
|
|
4423baa1e7 | ||
|
|
d967681227 | ||
|
|
f391edda27 | ||
|
|
255030136f | ||
|
|
f79d304586 | ||
|
|
60772b1775 | ||
|
|
bf3ef435ae | ||
|
|
30411ccd13 | ||
|
|
bd8ca0abcf | ||
|
|
6621ece3a1 | ||
|
|
8f4b68617a | ||
|
|
1b3017222e | ||
|
|
a050fbd2d3 | ||
|
|
fe3bf88ea4 | ||
|
|
94f583fef0 | ||
|
|
8da0017eaf | ||
|
|
4e838201c6 | ||
|
|
111b8b3cda | ||
|
|
d0d388534b | ||
|
|
939b7354e4 | ||
|
|
7077693da2 | ||
|
|
9c047d0a45 | ||
|
|
f13dbfa766 | ||
|
|
d4dd1349da | ||
|
|
1db8c209fb | ||
|
|
852d2b7431 | ||
|
|
6468f85cb0 | ||
|
|
5c61865dfb | ||
|
|
3feb9d2304 | ||
|
|
f17ac6ffac | ||
|
|
04675a73fd | ||
|
|
e2353af0d8 | ||
|
|
8eaf64b5fd | ||
|
|
e5c5bc40d9 | ||
|
|
5286f446dc | ||
|
|
abbf2038a7 | ||
|
|
52026ca9e4 | ||
|
|
0b0c4bb434 | ||
|
|
22aa56d306 | ||
|
|
3b935a1eea | ||
|
|
146421ee52 | ||
|
|
9875942191 | ||
|
|
3d2b18140a | ||
|
|
ec0882cd14 | ||
|
|
48c97ee1cc | ||
|
|
17787fee18 | ||
|
|
f2a59002bc | ||
|
|
98419bc625 | ||
|
|
fb596fa4f9 | ||
|
|
7a4289cee1 | ||
|
|
488acce564 | ||
|
|
3f3b500a14 | ||
|
|
29803bbaf2 | ||
|
|
e07eabc579 | ||
|
|
e82a3b838c | ||
|
|
9761c3762d | ||
|
|
1ecfcc611f | ||
|
|
ad60b437ce | ||
|
|
cc5208cc6b | ||
|
|
a9212d9e7d | ||
|
|
efcf22d837 | ||
|
|
d4a02336aa | ||
|
|
386eba28e6 | ||
|
|
651b887a0e | ||
|
|
2982757649 | ||
|
|
04a8999e0b | ||
|
|
41ee9e563c | ||
|
|
16a16dc390 | ||
|
|
80155a17e5 | ||
|
|
26da0c5e74 | ||
|
|
27059e7b99 | ||
|
|
fdc51e03ac | ||
|
|
549ab12048 | ||
|
|
fb95a3d923 | ||
|
|
70da7c0f51 | ||
|
|
d7051bb7ed | ||
|
|
dd8f28b864 | ||
|
|
8b060e8834 | ||
|
|
7b66e1ce33 | ||
|
|
5d2248ab1f | ||
|
|
7df5f12c75 | ||
|
|
b0dadc60f2 | ||
|
|
36b7998714 | ||
|
|
d42fc88535 | ||
|
|
4ebc67aab0 | ||
|
|
5bef99611b | ||
|
|
191f1456ee | ||
|
|
a839078c7d | ||
|
|
2d7804311d | ||
|
|
6b0c1d136b | ||
|
|
383845ca62 | ||
|
|
a9b755ae2d | ||
|
|
6ed85cfdc6 | ||
|
|
137b6040ab | ||
|
|
da08b60f20 | ||
|
|
267952d750 | ||
|
|
38aea0edbe | ||
|
|
51bb60c3b1 | ||
|
|
8f2413dc7e | ||
|
|
1bdef318f0 | ||
|
|
2efb807f0b | ||
|
|
be35692125 | ||
|
|
076125485d | ||
|
|
1800f105d7 | ||
|
|
933e693897 | ||
|
|
97ff693e5c | ||
|
|
7853cdc9ed | ||
|
|
cba00d822c | ||
|
|
00502f6d4d | ||
|
|
7ffd7019a2 | ||
|
|
ea146f4715 | ||
|
|
85b250d016 | ||
|
|
7bbbba37f5 | ||
|
|
07244c38eb | ||
|
|
50f9dde280 | ||
|
|
76a3197022 | ||
|
|
f26628a546 | ||
|
|
ceb365b95c | ||
|
|
b0a2bae0b5 | ||
|
|
426b82754d | ||
|
|
2f82229048 | ||
|
|
65bfda1034 | ||
|
|
2328f07473 | ||
|
|
ad2e48cfc1 | ||
|
|
cca5e4a4c0 | ||
|
|
17ec5c7acf | ||
|
|
4708105104 | ||
|
|
19d2066a1a | ||
|
|
ae3a355a99 | ||
|
|
1996ffe724 | ||
|
|
edf9bc6f4d | ||
|
|
c31eafaf4f | ||
|
|
1a884c0cdf | ||
|
|
e343de00c0 | ||
|
|
0f360fcdd1 | ||
|
|
a9a18464dd | ||
|
|
e47beb59d2 | ||
|
|
d4ff502e08 | ||
|
|
8c9c2c080a | ||
|
|
aaf7429298 | ||
|
|
329268697b | ||
|
|
0b950832c2 | ||
|
|
788056d55d | ||
|
|
dfaff50ca1 | ||
|
|
145568237c | ||
|
|
9baaff53cd | ||
|
|
3ad622dd69 | ||
|
|
906a1d7f3e | ||
|
|
ba7a5d5126 | ||
|
|
693c65995d | ||
|
|
85f7bf30b9 | ||
|
|
53ad87a349 | ||
|
|
7a034c1abf | ||
|
|
eeee5779ba | ||
|
|
6ff9568760 | ||
|
|
7de4cf1417 | ||
|
|
db67c64da0 | ||
|
|
8476f89f19 | ||
|
|
01f89d7855 | ||
|
|
fa46f78fd5 | ||
|
|
3e239f9caa | ||
|
|
204734914d | ||
|
|
67edf4f472 | ||
|
|
7693106a44 | ||
|
|
d8f5e1506c | ||
|
|
330b3bb496 | ||
|
|
ac3a36e489 | ||
|
|
22c03c8075 | ||
|
|
bc972da982 | ||
|
|
d9738949c1 | ||
|
|
1c0e91a301 | ||
|
|
3db6593b0e | ||
|
|
2bb1caf308 | ||
|
|
807a6f02fd | ||
|
|
367b9bd154 | ||
|
|
d30accbc71 | ||
|
|
716d298b75 | ||
|
|
05c4610654 | ||
|
|
82c294ab63 | ||
|
|
ee142a26b0 | ||
|
|
dea310e9ab | ||
|
|
daf2cb0ebc | ||
|
|
2812129d00 | ||
|
|
89b6f70cde | ||
|
|
ad372a754e | ||
|
|
c0b1345e90 | ||
|
|
11e6430d42 | ||
|
|
1e10f250b4 | ||
|
|
4b23703f99 | ||
|
|
bab4315bb7 | ||
|
|
c4839708ce | ||
|
|
fa379be99b | ||
|
|
12eee6f6a2 | ||
|
|
50240933a0 | ||
|
|
69b70bf649 | ||
|
|
3e01e483fb | ||
|
|
ae189748f1 | ||
|
|
679c72d70e | ||
|
|
49e02a2574 | ||
|
|
ee735c9b77 | ||
|
|
e25ca5dfc1 | ||
|
|
422fc1a6b2 | ||
|
|
0949362da5 | ||
|
|
65f59c446e | ||
|
|
556ab76fc5 | ||
|
|
4c5072b5c0 | ||
|
|
25177d1685 | ||
|
|
5469dce6ca | ||
|
|
d659bf4349 | ||
|
|
c5008a43e7 | ||
|
|
256a961e1b | ||
|
|
d642987df4 | ||
|
|
7d0fc80a80 | ||
|
|
fa0bc96611 | ||
|
|
c492fa285f | ||
|
|
11494bbad6 | ||
|
|
c83cc073f0 | ||
|
|
b7c0829af9 | ||
|
|
e899acbdbe | ||
|
|
1f95430507 | ||
|
|
b70ab0e6cc | ||
|
|
8ec4dc1b6e | ||
|
|
9b9875a31b | ||
|
|
56e2cac85f | ||
|
|
65359f87d0 | ||
|
|
058371ba7d | ||
|
|
dc8d7aa3da | ||
|
|
e1c6545507 | ||
|
|
5c8097d9b7 | ||
|
|
6118ed91fa | ||
|
|
0814b82b7e | ||
|
|
288d3a2cff | ||
|
|
e2399eb4e2 | ||
|
|
bfe8fc66ce | ||
|
|
dd1b25604a | ||
|
|
b488253c76 | ||
|
|
56f96348f4 | ||
|
|
18cbaf7458 | ||
|
|
9add408b6b | ||
|
|
54eaf8ab0f | ||
|
|
32a3b8dd9b | ||
|
|
06c773c3b1 | ||
|
|
18e5c17ebe | ||
|
|
287c657e60 | ||
|
|
f69d0823f4 | ||
|
|
f26373dfd5 | ||
|
|
c54217f236 | ||
|
|
25c1b251a3 | ||
|
|
9fafe2f42c | ||
|
|
f25bd33f06 | ||
|
|
de02e8853d | ||
|
|
4c09905503 | ||
|
|
a9a39ae502 | ||
|
|
99693367d3 | ||
|
|
05e9f12dc4 | ||
|
|
0b1712f653 | ||
|
|
8575f1b036 | ||
|
|
4c2278a7f2 | ||
|
|
af4d1d1fee | ||
|
|
d52cb4c7d7 | ||
|
|
65acb3fd94 | ||
|
|
ecf54a3025 | ||
|
|
180bfc30a8 | ||
|
|
cbc255ddf8 | ||
|
|
15971aaa94 | ||
|
|
695ebd35a4 | ||
|
|
21247e91c7 | ||
|
|
d30471ae6a | ||
|
|
03292bb02d | ||
|
|
297e292e06 | ||
|
|
f32aa8609d | ||
|
|
f8550334dd | ||
|
|
da1bf320dd | ||
|
|
27f05b5f95 | ||
|
|
203289fd38 | ||
|
|
f9b93f284c | ||
|
|
24f9a8aeb1 | ||
|
|
c6eb6dc054 | ||
|
|
a16ef9743b | ||
|
|
6c8816c289 | ||
|
|
d4f325e6c5 | ||
|
|
93ec3bdfc4 | ||
|
|
a4fb9eea40 | ||
|
|
f201a5eaf6 | ||
|
|
6689e796a7 | ||
|
|
b2e5b4862d | ||
|
|
546dc5c449 | ||
|
|
f660611ed3 | ||
|
|
89a507de69 | ||
|
|
3cc29ba7b6 | ||
|
|
1660af94a5 | ||
|
|
c51687246e | ||
|
|
bc72d6103c | ||
|
|
cf44e46490 | ||
|
|
8311669e6c | ||
|
|
195557373c | ||
|
|
e7055242e1 | ||
|
|
6020b8d6a8 | ||
|
|
7c14a2edff | ||
|
|
6307919546 | ||
|
|
2dd53eee27 | ||
|
|
4e0aaedc86 | ||
|
|
970d2b0f0f | ||
|
|
b1cc4ef45c | ||
|
|
17c20d6b91 | ||
|
|
d05332662b | ||
|
|
42728fd9b9 | ||
|
|
732db9a5b4 | ||
|
|
a19c8e72b3 | ||
|
|
b0067c4e4b | ||
|
|
a133503256 | ||
|
|
3465d2fc64 | ||
|
|
05ae2b2ecf | ||
|
|
e00d9ed273 | ||
|
|
bf8f90f479 | ||
|
|
978a4ac0a2 | ||
|
|
aac522232f | ||
|
|
95570724e6 | ||
|
|
a96c53f407 | ||
|
|
f79ec37f5e | ||
|
|
5d38f2ece6 | ||
|
|
cf13bfb1e4 | ||
|
|
051b5d6bea | ||
|
|
a8532d1cd2 | ||
|
|
a7d8f3b79f | ||
|
|
e634dd81b1 | ||
|
|
c6f4ee3d57 | ||
|
|
f47b420785 | ||
|
|
1add3ca605 | ||
|
|
2b172e6d8b | ||
|
|
1728cbf28b | ||
|
|
766c6c08f4 | ||
|
|
400e4d783b | ||
|
|
a8b3e2bfa4 | ||
|
|
57649d1c08 | ||
|
|
dc794c64ce | ||
|
|
eda3959748 | ||
|
|
f5a0a0f8ba | ||
|
|
6e4f3f7191 | ||
|
|
e276a07f1b | ||
|
|
db9b3e9b67 | ||
|
|
d32d0692f8 | ||
|
|
ebdca47fbc | ||
|
|
957a335817 | ||
|
|
71a27862bd | ||
|
|
a2b03895d7 | ||
|
|
a93ebfd7bf | ||
|
|
3c43f3aa18 | ||
|
|
eb5c304f69 | ||
|
|
0925e141b4 | ||
|
|
ad8d087fd9 | ||
|
|
e50e3509bd | ||
|
|
090b2e6f1b | ||
|
|
0af0bea90d | ||
|
|
f452280cdb | ||
|
|
8c892ac05d | ||
|
|
65384250d6 | ||
|
|
3e7bff2446 | ||
|
|
b6d39c66d1 | ||
|
|
03e1733b7d | ||
|
|
039ac40cf7 | ||
|
|
2664ba0774 | ||
|
|
90efb4e827 | ||
|
|
946d597a13 | ||
|
|
0961d6314c | ||
|
|
0ef015bb0e | ||
|
|
36ca708850 | ||
|
|
1c539ff50c | ||
|
|
49d0865010 |
21
.env.example
21
.env.example
@@ -11,24 +11,12 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=panel
|
||||
DB_USERNAME=panel
|
||||
DB_PASSWORD=
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
DB_CONNECTION=sqlite
|
||||
|
||||
CACHE_STORE=file
|
||||
QUEUE_CONNECTION=sync
|
||||
QUEUE_CONNECTION=database
|
||||
SESSION_DRIVER=file
|
||||
|
||||
HASHIDS_SALT=
|
||||
HASHIDS_LENGTH=8
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=25
|
||||
@@ -42,3 +30,8 @@ MAIL_FROM_NAME="Pelican Admin"
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
# Set this to true, and set start & end ports to auto create allocations.
|
||||
PANEL_CLIENT_ALLOCATIONS_ENABLED=false
|
||||
PANEL_CLIENT_ALLOCATIONS_RANGE_START=
|
||||
PANEL_CLIENT_ALLOCATIONS_RANGE_END=
|
||||
|
||||
@@ -39,6 +39,7 @@ module.exports = {
|
||||
'react/display-name': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/no-non-null-assertion': 0,
|
||||
// 'react/no-unknown-property': ['error', { ignore: ['css'] }],
|
||||
// This setup is required to avoid a spam of errors when running eslint about React being
|
||||
// used before it is defined.
|
||||
//
|
||||
|
||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
||||
custom: [https://buy.stripe.com/14kdU99SI4UT7ni9AB, https://buy.stripe.com/14kaHXc0Q9b9372eUU]
|
||||
github: pelican-dev
|
||||
custom: [https://hub.pelican.dev/donors]
|
||||
|
||||
24
.github/docker/default.conf
vendored
24
.github/docker/default.conf
vendored
@@ -4,6 +4,30 @@
|
||||
# If using CentOS this file should be placed in:
|
||||
# /etc/nginx/conf.d/
|
||||
#
|
||||
|
||||
# The MIT License (MIT)
|
||||
#
|
||||
# Pterodactyl®
|
||||
# Copyright © Dane Everitt <dane@daneeveritt.com> and contributors
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
2
.github/docker/entrypoint.sh
vendored
2
.github/docker/entrypoint.sh
vendored
@@ -1,7 +1,7 @@
|
||||
#!/bin/ash -e
|
||||
cd /app
|
||||
|
||||
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php7/ \
|
||||
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php8/ \
|
||||
&& chmod 777 /var/log/panel/logs/ \
|
||||
&& ln -s /app/storage/logs/ /var/log/panel/
|
||||
|
||||
|
||||
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [18]
|
||||
node-version: [18, 20]
|
||||
steps:
|
||||
- name: Code Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
88
.github/workflows/ci.yaml
vendored
88
.github/workflows/ci.yaml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
mysql:
|
||||
name: MySQL
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -23,6 +23,21 @@ jobs:
|
||||
ports:
|
||||
- 3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
env:
|
||||
APP_ENV: testing
|
||||
APP_DEBUG: "false"
|
||||
APP_KEY: ThisIsARandomStringForTests12345
|
||||
APP_TIMEZONE: UTC
|
||||
APP_URL: http://localhost/
|
||||
APP_ENVIRONMENT_ONLY: "true"
|
||||
CACHE_DRIVER: array
|
||||
MAIL_MAILER: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
steps:
|
||||
- name: Code Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -44,22 +59,15 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: bcmath, cli, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
|
||||
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Setup .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --no-suggest --prefer-dist
|
||||
|
||||
- name: Generate App Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Unit tests
|
||||
run: vendor/bin/phpunit tests/Unit
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
DB_HOST: UNIT_NO_DB
|
||||
SKIP_MIGRATIONS: true
|
||||
@@ -69,3 +77,63 @@ jobs:
|
||||
env:
|
||||
DB_PORT: ${{ job.services.database.ports[3306] }}
|
||||
DB_USERNAME: root
|
||||
|
||||
sqlite:
|
||||
name: SQLite
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: [8.2, 8.3]
|
||||
env:
|
||||
APP_ENV: testing
|
||||
APP_DEBUG: "false"
|
||||
APP_KEY: ThisIsARandomStringForTests12345
|
||||
APP_TIMEZONE: UTC
|
||||
APP_URL: http://localhost/
|
||||
APP_ENVIRONMENT_ONLY: "true"
|
||||
CACHE_DRIVER: array
|
||||
MAIL_MAILER: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: testing.sqlite
|
||||
steps:
|
||||
- name: Code Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get cache directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-${{ matrix.php }}-
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --no-suggest --prefer-dist
|
||||
|
||||
- name: Create SQLite file
|
||||
run: touch database/testing.sqlite
|
||||
|
||||
- name: Unit tests
|
||||
run: vendor/bin/phpunit tests/Unit
|
||||
env:
|
||||
DB_HOST: UNIT_NO_DB
|
||||
SKIP_MIGRATIONS: true
|
||||
|
||||
- name: Integration tests
|
||||
run: vendor/bin/phpunit tests/Integration
|
||||
|
||||
8
.github/workflows/cla.yaml
vendored
8
.github/workflows/cla.yaml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.3.0
|
||||
uses: contributor-assistant/github-action@v2.4.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'version1/cla.json'
|
||||
path-to-document: 'https://github.com/pelican-dev/panel/blob/3.x/contributor_license_agreement.md'
|
||||
path-to-document: 'https://github.com/pelican-dev/panel/blob/main/contributor_license_agreement.md'
|
||||
branch: 'main'
|
||||
allowlist: dependabot[bot]
|
||||
remote-organization-name: pelican-dev
|
||||
|
||||
25
.github/workflows/release.yaml
vendored
25
.github/workflows/release.yaml
vendored
@@ -8,15 +8,18 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -30,8 +33,8 @@ jobs:
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
BRANCH=release/${REF:10}
|
||||
git config --local user.email "ci@pelican.dev"
|
||||
git config --local user.name "Pelican CI"
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git checkout -b $BRANCH
|
||||
git push -u origin $BRANCH
|
||||
sed -i "s/ 'version' => 'canary',/ 'version' => '${REF:11}',/" config/app.php
|
||||
@@ -44,16 +47,9 @@ jobs:
|
||||
rm -rf node_modules tests CODE_OF_CONDUCT.md CONTRIBUTING.md flake.lock flake.nix phpunit.xml shell.nix
|
||||
tar -czf panel.tar.gz * .editorconfig .env.example .eslintignore .eslintrc.js .gitignore .prettierrc.json
|
||||
|
||||
- name: Extract changelog
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
sed -n "/^## ${REF:10}/,/^## /{/^## /b;p}" CHANGELOG.md > ./RELEASE_CHANGELOG
|
||||
|
||||
- name: Create checksum and add to changelog
|
||||
- name: Create checksum
|
||||
run: |
|
||||
SUM=`sha256sum panel.tar.gz`
|
||||
echo -e "\n#### SHA256 Checksum\n\n\`\`\`\n$SUM\n\`\`\`\n" >> ./RELEASE_CHANGELOG
|
||||
echo $SUM > checksum.txt
|
||||
|
||||
- name: Create release
|
||||
@@ -64,7 +60,6 @@ jobs:
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ contains(github.ref, 'rc') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
|
||||
body_path: ./RELEASE_CHANGELOG
|
||||
|
||||
- name: Upload release archive
|
||||
id: upload-release-archive
|
||||
|
||||
60
.gitignore
vendored
60
.gitignore
vendored
@@ -1,36 +1,28 @@
|
||||
/vendor
|
||||
*.DS_Store*
|
||||
!.env.ci
|
||||
!.env.example
|
||||
.env*
|
||||
.vagrant/*
|
||||
.vscode/*
|
||||
storage/framework/*
|
||||
/.idea
|
||||
/nbproject
|
||||
/.direnv
|
||||
|
||||
node_modules
|
||||
*.log
|
||||
_ide_helper.php
|
||||
_ide_helper_models.php
|
||||
.phpstorm.meta.php
|
||||
.yarn
|
||||
public/assets/manifest.json
|
||||
|
||||
# For local development with docker
|
||||
# Remove if we ever put the Dockerfile in the repo
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# for image related files
|
||||
misc
|
||||
.php-cs-fixer.cache
|
||||
coverage.xml
|
||||
resources/lang/locales.js
|
||||
.phpunit.result.cache
|
||||
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
result
|
||||
docker-compose.yaml
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/clockwork/*
|
||||
/vendor
|
||||
*.DS_Store*
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
auth.json
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
|
||||
public/assets/manifest.json
|
||||
/database/*.sqlite
|
||||
filament-monaco-editor/
|
||||
_ide_helper*
|
||||
/.phpstorm.meta.php
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Build the assets that are needed for the frontend. This build stage is then discarded
|
||||
# since we won't need NodeJS anymore in the future. This Docker image ships a final production
|
||||
# level distribution
|
||||
FROM --platform=$TARGETOS/$TARGETARCH mhart/alpine-node:14
|
||||
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
RUN yarn install --frozen-lockfile \
|
||||
@@ -10,13 +10,13 @@ RUN yarn install --frozen-lockfile \
|
||||
|
||||
# Stage 1:
|
||||
# Build the actual container with all of the needed PHP dependencies that will run the application.
|
||||
FROM --platform=$TARGETOS/$TARGETARCH php:8.1-fpm-alpine
|
||||
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
|
||||
WORKDIR /app
|
||||
COPY . ./
|
||||
COPY --from=0 /app/public/assets ./public/assets
|
||||
RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev certbot certbot-nginx \
|
||||
RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev icu-dev certbot certbot-nginx \
|
||||
&& docker-php-ext-configure zip \
|
||||
&& docker-php-ext-install bcmath gd pdo_mysql zip \
|
||||
&& docker-php-ext-install bcmath gd intl pdo_mysql zip \
|
||||
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
||||
&& cp .env.example .env \
|
||||
&& mkdir -p bootstrap/cache/ storage/logs storage/framework/sessions storage/framework/views storage/framework/cache \
|
||||
|
||||
@@ -5,38 +5,34 @@ namespace App\Console\Commands\Environment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use App\Traits\Commands\EnvironmentWriterTrait;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class AppSettingsCommand extends Command
|
||||
{
|
||||
use EnvironmentWriterTrait;
|
||||
|
||||
public const CACHE_DRIVERS = [
|
||||
'redis' => 'Redis',
|
||||
'memcached' => 'Memcached',
|
||||
'file' => 'Filesystem (recommended)',
|
||||
'redis' => 'Redis',
|
||||
];
|
||||
|
||||
public const SESSION_DRIVERS = [
|
||||
'redis' => 'Redis',
|
||||
'memcached' => 'Memcached',
|
||||
'database' => 'MySQL Database',
|
||||
'file' => 'Filesystem (recommended)',
|
||||
'redis' => 'Redis',
|
||||
'database' => 'Database',
|
||||
'cookie' => 'Cookie',
|
||||
];
|
||||
|
||||
public const QUEUE_DRIVERS = [
|
||||
'database' => 'Database (recommended)',
|
||||
'redis' => 'Redis',
|
||||
'database' => 'MySQL Database',
|
||||
'sync' => 'Sync (recommended)',
|
||||
'sync' => 'Synchronous',
|
||||
];
|
||||
|
||||
protected $description = 'Configure basic environment settings for the Panel.';
|
||||
|
||||
protected $signature = 'p:environment:setup
|
||||
{--new-salt : Whether or not to generate a new salt for Hashids.}
|
||||
{--author= : The email that services created on this instance should be linked to.}
|
||||
{--url= : The URL that this Panel is running on.}
|
||||
{--timezone= : The timezone to use for Panel times.}
|
||||
{--cache= : The cache driver backend to use.}
|
||||
{--session= : The session driver backend to use.}
|
||||
{--queue= : The queue driver backend to use.}
|
||||
@@ -62,35 +58,14 @@ class AppSettingsCommand extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
if (empty(config('hashids.salt')) || $this->option('new-salt')) {
|
||||
$this->variables['HASHIDS_SALT'] = str_random(20);
|
||||
}
|
||||
$this->variables['APP_TIMEZONE'] = 'UTC';
|
||||
|
||||
$this->output->comment('Provide the email address that eggs exported by this Panel should be from. This should be a valid email address.');
|
||||
$this->variables['APP_SERVICE_AUTHOR'] = $this->option('author') ?? $this->ask(
|
||||
'Egg Author Email',
|
||||
config('panel.service.author', 'unknown@unknown.com')
|
||||
);
|
||||
|
||||
if (!filter_var($this->variables['APP_SERVICE_AUTHOR'], FILTER_VALIDATE_EMAIL)) {
|
||||
$this->output->error('The service author email provided is invalid.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->output->comment('The application URL MUST begin with https:// or http:// depending on if you are using SSL or not. If you do not include the scheme your emails and other content will link to the wrong location.');
|
||||
$this->output->comment(__('commands.appsettings.comment.url'));
|
||||
$this->variables['APP_URL'] = $this->option('url') ?? $this->ask(
|
||||
'Application URL',
|
||||
config('app.url', 'https://example.com')
|
||||
);
|
||||
|
||||
$this->output->comment('The timezone should match one of PHP\'s supported timezones. If you are unsure, please reference https://php.net/manual/en/timezones.php.');
|
||||
$this->variables['APP_TIMEZONE'] = $this->option('timezone') ?? $this->anticipate(
|
||||
'Application Timezone',
|
||||
\DateTimeZone::listIdentifiers(),
|
||||
config('app.timezone')
|
||||
);
|
||||
|
||||
$selected = config('cache.default', 'file');
|
||||
$this->variables['CACHE_STORE'] = $this->option('cache') ?? $this->choice(
|
||||
'Cache Driver',
|
||||
@@ -105,7 +80,7 @@ class AppSettingsCommand extends Command
|
||||
array_key_exists($selected, self::SESSION_DRIVERS) ? $selected : null
|
||||
);
|
||||
|
||||
$selected = config('queue.default', 'sync');
|
||||
$selected = config('queue.default', 'database');
|
||||
$this->variables['QUEUE_CONNECTION'] = $this->option('queue') ?? $this->choice(
|
||||
'Queue Driver',
|
||||
self::QUEUE_DRIVERS,
|
||||
@@ -115,7 +90,7 @@ class AppSettingsCommand extends Command
|
||||
if (!is_null($this->option('settings-ui'))) {
|
||||
$this->variables['APP_ENVIRONMENT_ONLY'] = $this->option('settings-ui') == 'true' ? 'false' : 'true';
|
||||
} else {
|
||||
$this->variables['APP_ENVIRONMENT_ONLY'] = $this->confirm('Enable UI based settings editor?', true) ? 'false' : 'true';
|
||||
$this->variables['APP_ENVIRONMENT_ONLY'] = $this->confirm(__('commands.appsettings.comment.settings_ui'), true) ? 'false' : 'true';
|
||||
}
|
||||
|
||||
// Make sure session cookies are set as "secure" when using HTTPS
|
||||
@@ -123,29 +98,42 @@ class AppSettingsCommand extends Command
|
||||
$this->variables['SESSION_SECURE_COOKIE'] = 'true';
|
||||
}
|
||||
|
||||
$this->checkForRedis();
|
||||
$redisUsed = count(collect($this->variables)->filter(function ($item) {
|
||||
return $item === 'redis';
|
||||
})) !== 0;
|
||||
|
||||
if ($redisUsed) {
|
||||
$this->requestRedisSettings();
|
||||
}
|
||||
|
||||
$path = base_path('.env');
|
||||
if (!file_exists($path)) {
|
||||
copy($path . '.example', $path);
|
||||
}
|
||||
|
||||
$this->writeToEnvironment($this->variables);
|
||||
|
||||
if (!config('app.key')) {
|
||||
Artisan::call('key:generate');
|
||||
}
|
||||
|
||||
if ($this->variables['QUEUE_CONNECTION'] !== 'sync') {
|
||||
$this->call('p:environment:queue-service', [
|
||||
'--use-redis' => $redisUsed,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->info($this->console->output());
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if redis is selected, if so, request connection details and verify them.
|
||||
* Request redis connection details and verify them.
|
||||
*/
|
||||
private function checkForRedis()
|
||||
private function requestRedisSettings(): void
|
||||
{
|
||||
$items = collect($this->variables)->filter(function ($item) {
|
||||
return $item === 'redis';
|
||||
});
|
||||
|
||||
// Redis was not selected, no need to continue.
|
||||
if (count($items) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->note('You\'ve selected the Redis driver for one or more options, please provide valid connection information below. In most cases you can use the defaults provided unless you have modified your setup.');
|
||||
$this->output->note(__('commands.appsettings.redis.note'));
|
||||
$this->variables['REDIS_HOST'] = $this->option('redis-host') ?? $this->ask(
|
||||
'Redis Host',
|
||||
config('database.redis.default.host')
|
||||
@@ -158,7 +146,7 @@ class AppSettingsCommand extends Command
|
||||
}
|
||||
|
||||
if ($askForRedisPassword) {
|
||||
$this->output->comment('By default a Redis server instance has no password as it is running locally and inaccessible to the outside world. If this is the case, simply hit enter without entering a value.');
|
||||
$this->output->comment(__('commands.appsettings.redis.comment'));
|
||||
$this->variables['REDIS_PASSWORD'] = $this->option('redis-pass') ?? $this->output->askHidden(
|
||||
'Redis Password'
|
||||
);
|
||||
|
||||
@@ -11,14 +11,20 @@ class DatabaseSettingsCommand extends Command
|
||||
{
|
||||
use EnvironmentWriterTrait;
|
||||
|
||||
public const DATABASE_DRIVERS = [
|
||||
'sqlite' => 'SQLite (recommended)',
|
||||
'mysql' => 'MySQL',
|
||||
];
|
||||
|
||||
protected $description = 'Configure database settings for the Panel.';
|
||||
|
||||
protected $signature = 'p:environment:database
|
||||
{--driver= : The database driver backend to use.}
|
||||
{--database= : The database to use.}
|
||||
{--host= : The connection address for the MySQL server.}
|
||||
{--port= : The connection port for the MySQL server.}
|
||||
{--database= : The database to use.}
|
||||
{--username= : Username to use when connecting.}
|
||||
{--password= : Password to use for this database.}';
|
||||
{--username= : Username to use when connecting to the MySQL server.}
|
||||
{--password= : Password to use for the MySQL database.}';
|
||||
|
||||
protected array $variables = [];
|
||||
|
||||
@@ -35,51 +41,65 @@ class DatabaseSettingsCommand extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->output->note('It is highly recommended to not use "localhost" as your database host as we have seen frequent socket connection issues. If you want to use a local connection you should be using "127.0.0.1".');
|
||||
$this->variables['DB_HOST'] = $this->option('host') ?? $this->ask(
|
||||
'Database Host',
|
||||
config('database.connections.mysql.host', '127.0.0.1')
|
||||
$selected = config('database.default', 'sqlite');
|
||||
$this->variables['DB_CONNECTION'] = $this->option('driver') ?? $this->choice(
|
||||
'Database Driver',
|
||||
self::DATABASE_DRIVERS,
|
||||
array_key_exists($selected, self::DATABASE_DRIVERS) ? $selected : null
|
||||
);
|
||||
|
||||
$this->variables['DB_PORT'] = $this->option('port') ?? $this->ask(
|
||||
'Database Port',
|
||||
config('database.connections.mysql.port', 3306)
|
||||
);
|
||||
if ($this->variables['DB_CONNECTION'] === 'mysql') {
|
||||
$this->output->note(__('commands.database_settings.DB_HOST_note'));
|
||||
$this->variables['DB_HOST'] = $this->option('host') ?? $this->ask(
|
||||
'Database Host',
|
||||
config('database.connections.mysql.host', '127.0.0.1')
|
||||
);
|
||||
|
||||
$this->variables['DB_DATABASE'] = $this->option('database') ?? $this->ask(
|
||||
'Database Name',
|
||||
config('database.connections.mysql.database', 'panel')
|
||||
);
|
||||
$this->variables['DB_PORT'] = $this->option('port') ?? $this->ask(
|
||||
'Database Port',
|
||||
config('database.connections.mysql.port', 3306)
|
||||
);
|
||||
|
||||
$this->output->note('Using the "root" account for MySQL connections is not only highly frowned upon, it is also not allowed by this application. You\'ll need to have created a MySQL user for this software.');
|
||||
$this->variables['DB_USERNAME'] = $this->option('username') ?? $this->ask(
|
||||
'Database Username',
|
||||
config('database.connections.mysql.username', 'panel')
|
||||
);
|
||||
$this->variables['DB_DATABASE'] = $this->option('database') ?? $this->ask(
|
||||
'Database Name',
|
||||
config('database.connections.mysql.database', 'panel')
|
||||
);
|
||||
|
||||
$askForMySQLPassword = true;
|
||||
if (!empty(config('database.connections.mysql.password')) && $this->input->isInteractive()) {
|
||||
$this->variables['DB_PASSWORD'] = config('database.connections.mysql.password');
|
||||
$askForMySQLPassword = $this->confirm('It appears you already have a MySQL connection password defined, would you like to change it?');
|
||||
}
|
||||
$this->output->note(__('commands.database_settings.DB_USERNAME_note'));
|
||||
$this->variables['DB_USERNAME'] = $this->option('username') ?? $this->ask(
|
||||
'Database Username',
|
||||
config('database.connections.mysql.username', 'pelican')
|
||||
);
|
||||
|
||||
if ($askForMySQLPassword) {
|
||||
$this->variables['DB_PASSWORD'] = $this->option('password') ?? $this->secret('Database Password');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->testMySQLConnection();
|
||||
} catch (\PDOException $exception) {
|
||||
$this->output->error(sprintf('Unable to connect to the MySQL server using the provided credentials. The error returned was "%s".', $exception->getMessage()));
|
||||
$this->output->error('Your connection credentials have NOT been saved. You will need to provide valid connection information before proceeding.');
|
||||
|
||||
if ($this->confirm('Go back and try again?')) {
|
||||
$this->database->disconnect('_panel_command_test');
|
||||
|
||||
return $this->handle();
|
||||
$askForMySQLPassword = true;
|
||||
if (!empty(config('database.connections.mysql.password')) && $this->input->isInteractive()) {
|
||||
$this->variables['DB_PASSWORD'] = config('database.connections.mysql.password');
|
||||
$askForMySQLPassword = $this->confirm(__('commands.database_settings.DB_PASSWORD_note'));
|
||||
}
|
||||
|
||||
return 1;
|
||||
if ($askForMySQLPassword) {
|
||||
$this->variables['DB_PASSWORD'] = $this->option('password') ?? $this->secret('Database Password');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->testMySQLConnection();
|
||||
} catch (\PDOException $exception) {
|
||||
$this->output->error(sprintf('Unable to connect to the MySQL server using the provided credentials. The error returned was "%s".', $exception->getMessage()));
|
||||
$this->output->error(__('commands.database_settings.DB_error_2'));
|
||||
|
||||
if ($this->confirm(__('commands.database_settings.go_back'))) {
|
||||
$this->database->disconnect('_panel_command_test');
|
||||
|
||||
return $this->handle();
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
} elseif ($this->variables['DB_CONNECTION'] === 'sqlite') {
|
||||
$this->variables['DB_DATABASE'] = $this->option('database') ?? $this->ask(
|
||||
'Database Path',
|
||||
env('DB_DATABASE', 'database.sqlite')
|
||||
);
|
||||
}
|
||||
|
||||
$this->writeToEnvironment($this->variables);
|
||||
|
||||
@@ -31,19 +31,20 @@ class EmailSettingsCommand extends Command
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$this->variables['MAIL_DRIVER'] = $this->option('driver') ?? $this->choice(
|
||||
$this->variables['MAIL_MAILER'] = $this->option('driver') ?? $this->choice(
|
||||
trans('command/messages.environment.mail.ask_driver'),
|
||||
[
|
||||
'log' => 'Log',
|
||||
'smtp' => 'SMTP Server',
|
||||
'sendmail' => 'sendmail Binary',
|
||||
'mailgun' => 'Mailgun Transactional Email',
|
||||
'mandrill' => 'Mandrill Transactional Email',
|
||||
'postmark' => 'Postmark Transactional Email',
|
||||
'mailgun' => 'Mailgun',
|
||||
'mandrill' => 'Mandrill',
|
||||
'postmark' => 'Postmark',
|
||||
],
|
||||
config('mail.default', 'smtp')
|
||||
env('MAIL_MAILER', env('MAIL_DRIVER', 'smtp')),
|
||||
);
|
||||
|
||||
$method = 'setup' . studly_case($this->variables['MAIL_DRIVER']) . 'DriverVariables';
|
||||
$method = 'setup' . studly_case($this->variables['MAIL_MAILER']) . 'DriverVariables';
|
||||
if (method_exists($this, $method)) {
|
||||
$this->{$method}();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Environment;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class QueueWorkerServiceCommand extends Command
|
||||
{
|
||||
protected $description = 'Create the service for the queue worker.';
|
||||
|
||||
protected $signature = 'p:environment:queue-service
|
||||
{--service-name= : Name of the queue worker service.}
|
||||
{--user= : The user that PHP runs under.}
|
||||
{--group= : The group that PHP runs under.}
|
||||
{--use-redis : Whether redis is used.}
|
||||
{--overwrite : Force overwrite if the service file already exists.}';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$serviceName = $this->option('service-name') ?? $this->ask('Queue worker service name', 'pelican-queue');
|
||||
$path = '/etc/systemd/system/' . $serviceName . '.service';
|
||||
|
||||
$fileExists = file_exists($path);
|
||||
if ($fileExists && !$this->option('overwrite') && !$this->confirm('The service file already exists. Do you want to overwrite it?')) {
|
||||
$this->line('Creation of queue worker service file aborted because serive file already exists.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->option('user') ?? $this->ask('Webserver User', 'www-data');
|
||||
$group = $this->option('group') ?? $this->ask('Webserver Group', 'www-data');
|
||||
|
||||
$afterRedis = $this->option('use-redis') ? '\nAfter=redis-server.service' : '';
|
||||
|
||||
$basePath = base_path();
|
||||
|
||||
$success = File::put($path, "# Pelican Queue File
|
||||
# ----------------------------------
|
||||
|
||||
[Unit]
|
||||
Description=Pelican Queue Service$afterRedis
|
||||
|
||||
[Service]
|
||||
User=$user
|
||||
Group=$group
|
||||
Restart=always
|
||||
ExecStart=/usr/bin/php $basePath/artisan queue:work --tries=3
|
||||
StartLimitInterval=180
|
||||
StartLimitBurst=30
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
");
|
||||
|
||||
if (!$success) {
|
||||
$this->error('Error creating service file');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($fileExists) {
|
||||
$result = Process::run("systemctl restart $serviceName.service");
|
||||
if ($result->failed()) {
|
||||
$this->error('Error restarting service: ' . $result->errorOutput());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line('Queue worker service file updated successfully.');
|
||||
} else {
|
||||
$result = Process::run("systemctl enable --now $serviceName.service");
|
||||
if ($result->failed()) {
|
||||
$this->error('Error enabling service: ' . $result->errorOutput());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line('Queue worker service file created successfully.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,9 @@ class InfoCommand extends Command
|
||||
{
|
||||
$this->output->title('Version Information');
|
||||
$this->table([], [
|
||||
['Panel Version', config('app.version')],
|
||||
['Panel Version', $this->versionService->versionData()['version']],
|
||||
['Latest Version', $this->versionService->getPanel()],
|
||||
['Up-to-Date', $this->versionService->isLatestPanel() ? 'Yes' : $this->formatText('No', 'bg=red')],
|
||||
['Unique Identifier', config('panel.service.author')],
|
||||
], 'compact');
|
||||
|
||||
$this->output->title('Application Configuration');
|
||||
@@ -38,13 +37,11 @@ class InfoCommand extends Command
|
||||
['Debug Mode', $this->formatText(config('app.debug') ? 'Yes' : 'No', !config('app.debug') ?: 'bg=red')],
|
||||
['Installation URL', config('app.url')],
|
||||
['Installation Directory', base_path()],
|
||||
['Timezone', config('app.timezone')],
|
||||
['Cache Driver', config('cache.default')],
|
||||
['Queue Driver', config('queue.default')],
|
||||
['Session Driver', config('session.driver')],
|
||||
['Filesystem Driver', config('filesystems.default')],
|
||||
['Default Theme', config('themes.active')],
|
||||
['Proxies', config('trustedproxies.proxies')],
|
||||
], 'compact');
|
||||
|
||||
$this->output->title('Database Configuration');
|
||||
|
||||
@@ -20,9 +20,12 @@ class MakeNodeCommand extends Command
|
||||
{--overallocateMemory= : Enter the amount of ram to overallocate (% or -1 to overallocate the maximum).}
|
||||
{--maxDisk= : Set the max disk amount.}
|
||||
{--overallocateDisk= : Enter the amount of disk to overallocate (% or -1 to overallocate the maximum).}
|
||||
{--maxCpu= : Set the max cpu amount.}
|
||||
{--overallocateCpu= : Enter the amount of cpu to overallocate (% or -1 to overallocate the maximum).}
|
||||
{--uploadSize= : Enter the maximum upload filesize.}
|
||||
{--daemonListeningPort= : Enter the daemon listening port.}
|
||||
{--daemonSFTPPort= : Enter the daemon SFTP listening port.}
|
||||
{--daemonSFTPAlias= : Enter the daemon SFTP alias.}
|
||||
{--daemonBase= : Enter the base folder.}';
|
||||
|
||||
protected $description = 'Creates a new node on the system via the CLI.';
|
||||
@@ -42,27 +45,31 @@ class MakeNodeCommand extends Command
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$data['name'] = $this->option('name') ?? $this->ask('Enter a short identifier used to distinguish this node from others');
|
||||
$data['description'] = $this->option('description') ?? $this->ask('Enter a description to identify the node');
|
||||
$data['name'] = $this->option('name') ?? $this->ask(__('commands.make_node.name'));
|
||||
$data['description'] = $this->option('description') ?? $this->ask(__('commands.make_node.description'));
|
||||
$data['scheme'] = $this->option('scheme') ?? $this->anticipate(
|
||||
'Please either enter https for SSL or http for a non-ssl connection',
|
||||
__('commands.make_node.scheme'),
|
||||
['https', 'http'],
|
||||
'https'
|
||||
);
|
||||
$data['fqdn'] = $this->option('fqdn') ?? $this->ask('Enter a domain name (e.g node.example.com) to be used for connecting to the daemon. An IP address may only be used if you are not using SSL for this node');
|
||||
$data['public'] = $this->option('public') ?? $this->confirm('Should this node be public? As a note, setting a node to private you will be denying the ability to auto-deploy to this node.', true);
|
||||
$data['behind_proxy'] = $this->option('proxy') ?? $this->confirm('Is your FQDN behind a proxy?');
|
||||
$data['maintenance_mode'] = $this->option('maintenance') ?? $this->confirm('Should maintenance mode be enabled?');
|
||||
$data['memory'] = $this->option('maxMemory') ?? $this->ask('Enter the maximum amount of memory');
|
||||
$data['memory_overallocate'] = $this->option('overallocateMemory') ?? $this->ask('Enter the amount of memory to over allocate by, -1 will disable checking and 0 will prevent creating new servers');
|
||||
$data['disk'] = $this->option('maxDisk') ?? $this->ask('Enter the maximum amount of disk space');
|
||||
$data['disk_overallocate'] = $this->option('overallocateDisk') ?? $this->ask('Enter the amount of memory to over allocate by, -1 will disable checking and 0 will prevent creating new server');
|
||||
$data['upload_size'] = $this->option('uploadSize') ?? $this->ask('Enter the maximum filesize upload', '100');
|
||||
$data['daemonListen'] = $this->option('daemonListeningPort') ?? $this->ask('Enter the daemon listening port', '8080');
|
||||
$data['daemonSFTP'] = $this->option('daemonSFTPPort') ?? $this->ask('Enter the daemon SFTP listening port', '2022');
|
||||
$data['daemonBase'] = $this->option('daemonBase') ?? $this->ask('Enter the base folder', '/var/lib/panel/volumes');
|
||||
|
||||
$data['fqdn'] = $this->option('fqdn') ?? $this->ask(__('commands.make_node.fqdn'));
|
||||
$data['public'] = $this->option('public') ?? $this->confirm(__('commands.make_node.public'), true);
|
||||
$data['behind_proxy'] = $this->option('proxy') ?? $this->confirm(__('commands.make_node.behind_proxy'));
|
||||
$data['maintenance_mode'] = $this->option('maintenance') ?? $this->confirm(__('commands.make_node.maintenance_mode'));
|
||||
$data['memory'] = $this->option('maxMemory') ?? $this->ask(__('commands.make_node.memory'));
|
||||
$data['memory_overallocate'] = $this->option('overallocateMemory') ?? $this->ask(__('commands.make_node.memory_overallocate'));
|
||||
$data['disk'] = $this->option('maxDisk') ?? $this->ask(__('commands.make_node.disk'));
|
||||
$data['disk_overallocate'] = $this->option('overallocateDisk') ?? $this->ask(__('commands.make_node.disk_overallocate'));
|
||||
$data['cpu'] = $this->option('maxCpu') ?? $this->ask(__('commands.make_node.cpu'));
|
||||
$data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(__('commands.make_node.cpu_overallocate'));
|
||||
$data['upload_size'] = $this->option('uploadSize') ?? $this->ask(__('commands.make_node.upload_size'), '100');
|
||||
$data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(__('commands.make_node.daemonListen'), '8080');
|
||||
$data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(__('commands.make_node.daemonSFTP'), '2022');
|
||||
$data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(__('commands.make_node.daemonSFTPAlias'), '');
|
||||
$data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(__('commands.make_node.daemonBase'), '/var/lib/pelican/volumes');
|
||||
|
||||
$node = $this->creationService->handle($data);
|
||||
$this->line('Successfully created a new node with the name ' . $data['name'] . ' and has an id of ' . $node->id . '.');
|
||||
$this->line(__('commands.make_node.succes1') . $data['name'] . __('commands.make_node.succes2') . $node->id . '.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,14 @@ class NodeConfigurationCommand extends Command
|
||||
|
||||
/** @var \App\Models\Node $node */
|
||||
$node = Node::query()->where($column, $this->argument('node'))->firstOr(function () {
|
||||
$this->error('The selected node does not exist.');
|
||||
$this->error(__('commands.node_config.error_not_exist'));
|
||||
|
||||
exit(1);
|
||||
});
|
||||
|
||||
$format = $this->option('format');
|
||||
if (!in_array($format, ['yaml', 'yml', 'json'])) {
|
||||
$this->error('Invalid format specified. Valid options are "yaml" and "json".');
|
||||
$this->error(__('commands.node_config.error_invalid_format'));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ class KeyGenerateCommand extends BaseKeyGenerateCommand
|
||||
public function handle(): void
|
||||
{
|
||||
if (!empty(config('app.key')) && $this->input->isInteractive()) {
|
||||
$this->output->warning('It appears you have already configured an application encryption key. Continuing with this process with overwrite that key and cause data corruption for any existing encrypted data. DO NOT CONTINUE UNLESS YOU KNOW WHAT YOU ARE DOING.');
|
||||
if (!$this->confirm('I understand the consequences of performing this command and accept all responsibility for the loss of encrypted data.')) {
|
||||
$this->output->warning(__('commands.key_generate.error_already_exist'));
|
||||
if (!$this->confirm(__('commands.key_generate.understand'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->confirm('Are you sure you wish to continue? Changing the application encryption key WILL CAUSE DATA LOSS.')) {
|
||||
if (!$this->confirm(__('commands.key_generate.continue'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use Illuminate\Console\Command;
|
||||
use App\Models\Schedule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use App\Services\Schedules\ProcessScheduleService;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ProcessRunnableCommand extends Command
|
||||
{
|
||||
@@ -23,11 +24,11 @@ class ProcessRunnableCommand extends Command
|
||||
->whereRelation('server', fn (Builder $builder) => $builder->whereNull('status'))
|
||||
->where('is_active', true)
|
||||
->where('is_processing', false)
|
||||
->whereRaw('next_run_at <= NOW()')
|
||||
->whereDate('next_run_at', '<=', Carbon::now()->toDateTimeString())
|
||||
->get();
|
||||
|
||||
if ($schedules->count() < 1) {
|
||||
$this->line('There are no scheduled tasks for servers that need to be run.');
|
||||
$this->line(__('commands.schedule.process.no_tasks'));
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -61,12 +62,12 @@ class ProcessRunnableCommand extends Command
|
||||
|
||||
$this->line(trans('command/messages.schedule.output_line', [
|
||||
'schedule' => $schedule->name,
|
||||
'hash' => $schedule->hashid,
|
||||
'id' => $schedule->id,
|
||||
]));
|
||||
} catch (\Throwable|\Exception $exception) {
|
||||
logger()->error($exception, ['schedule_id' => $schedule->id]);
|
||||
|
||||
$this->error("An error was encountered while processing Schedule #$schedule->id: " . $exception->getMessage());
|
||||
$this->error(__('commands.schedule.process.no_tasks') . " #$schedule->id: " . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,29 +34,30 @@ class UpgradeCommand extends Command
|
||||
{
|
||||
$skipDownload = $this->option('skip-download');
|
||||
if (!$skipDownload) {
|
||||
$this->output->warning('This command does not verify the integrity of downloaded assets. Please ensure that you trust the download source before continuing. If you do not wish to download an archive, please indicate that using the --skip-download flag, or answering "no" to the question below.');
|
||||
$this->output->comment('Download Source (set with --url=):');
|
||||
$this->output->warning(__('commands.upgrade.integrity'));
|
||||
$this->output->comment(__('commands.upgrade.source_url'));
|
||||
$this->line($this->getUrl());
|
||||
}
|
||||
|
||||
if (version_compare(PHP_VERSION, '7.4.0') < 0) {
|
||||
$this->error('Cannot execute self-upgrade process. The minimum required PHP version required is 7.4.0, you have [' . PHP_VERSION . '].');
|
||||
$this->error(__('commands.upgrade.php_version') . ' [' . PHP_VERSION . '].');
|
||||
}
|
||||
|
||||
$user = 'www-data';
|
||||
$group = 'www-data';
|
||||
if ($this->input->isInteractive()) {
|
||||
if (!$skipDownload) {
|
||||
$skipDownload = !$this->confirm('Would you like to download and unpack the archive files for the latest version?', true);
|
||||
$skipDownload = !$this->confirm(__('commands.upgrade.skipDownload'), true);
|
||||
}
|
||||
|
||||
if (is_null($this->option('user'))) {
|
||||
$userDetails = function_exists('posix_getpwuid') ? posix_getpwuid(fileowner('public')) : [];
|
||||
$user = $userDetails['name'] ?? 'www-data';
|
||||
|
||||
if (!$this->confirm("Your webserver user has been detected as <fg=blue>[{$user}]:</> is this correct?", true)) {
|
||||
$message = __('commands.upgrade.webserver_user', ['user' => $user]);
|
||||
if (!$this->confirm($message, true)) {
|
||||
$user = $this->anticipate(
|
||||
'Please enter the name of the user running your webserver process. This varies from system to system, but is generally "www-data", "nginx", or "apache".',
|
||||
__('commands.upgrade.name_webserver'),
|
||||
[
|
||||
'www-data',
|
||||
'nginx',
|
||||
@@ -70,9 +71,10 @@ class UpgradeCommand extends Command
|
||||
$groupDetails = function_exists('posix_getgrgid') ? posix_getgrgid(filegroup('public')) : [];
|
||||
$group = $groupDetails['name'] ?? 'www-data';
|
||||
|
||||
if (!$this->confirm("Your webserver group has been detected as <fg=blue>[{$group}]:</> is this correct?", true)) {
|
||||
$message = __('commands.upgrade.group_webserver', ['group' => $user]);
|
||||
if (!$this->confirm($message, true)) {
|
||||
$group = $this->anticipate(
|
||||
'Please enter the name of the group running your webserver process. Normally this is the same as your user.',
|
||||
__('commands.upgrade.group_webserver_question'),
|
||||
[
|
||||
'www-data',
|
||||
'nginx',
|
||||
@@ -82,8 +84,8 @@ class UpgradeCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->confirm('Are you sure you want to run the upgrade process for your Panel?')) {
|
||||
$this->warn('Upgrade process terminated by user.');
|
||||
if (!$this->confirm(__('commands.upgrade.are_your_sure'))) {
|
||||
$this->warn(__('commands.upgrade.terminated'));
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +175,7 @@ class UpgradeCommand extends Command
|
||||
});
|
||||
|
||||
$this->newLine(2);
|
||||
$this->info('Panel has been successfully upgraded. Please ensure you also update any Daemon instances');
|
||||
$this->info(__('commands.upgrade.success'));
|
||||
}
|
||||
|
||||
protected function withProgress(ProgressBar $bar, \Closure $callback)
|
||||
|
||||
@@ -11,7 +11,7 @@ class MakeUserCommand extends Command
|
||||
{
|
||||
protected $description = 'Creates a user on the system via the CLI.';
|
||||
|
||||
protected $signature = 'p:user:make {--email=} {--username=} {--name-first=} {--name-last=} {--password=} {--admin=} {--no-password}';
|
||||
protected $signature = 'p:user:make {--email=} {--username=} {--password=} {--admin=} {--no-password}';
|
||||
|
||||
/**
|
||||
* MakeUserCommand constructor.
|
||||
@@ -30,7 +30,7 @@ class MakeUserCommand extends Command
|
||||
public function handle(): int
|
||||
{
|
||||
try {
|
||||
DB::select('select 1 where 1');
|
||||
DB::connection()->getPdo();
|
||||
} catch (Exception $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
@@ -40,8 +40,6 @@ class MakeUserCommand extends Command
|
||||
$root_admin = $this->option('admin') ?? $this->confirm(trans('command/messages.user.ask_admin'));
|
||||
$email = $this->option('email') ?? $this->ask(trans('command/messages.user.ask_email'));
|
||||
$username = $this->option('username') ?? $this->ask(trans('command/messages.user.ask_username'));
|
||||
$name_first = $this->option('name-first') ?? $this->ask(trans('command/messages.user.ask_name_first'));
|
||||
$name_last = $this->option('name-last') ?? $this->ask(trans('command/messages.user.ask_name_last'));
|
||||
|
||||
if (is_null($password = $this->option('password')) && !$this->option('no-password')) {
|
||||
$this->warn(trans('command/messages.user.ask_password_help'));
|
||||
@@ -49,12 +47,11 @@ class MakeUserCommand extends Command
|
||||
$password = $this->secret(trans('command/messages.user.ask_password'));
|
||||
}
|
||||
|
||||
$user = $this->creationService->handle(compact('email', 'username', 'name_first', 'name_last', 'password', 'root_admin'));
|
||||
$user = $this->creationService->handle(compact('email', 'username', 'password', 'root_admin'));
|
||||
$this->table(['Field', 'Value'], [
|
||||
['UUID', $user->uuid],
|
||||
['Email', $user->email],
|
||||
['Username', $user->username],
|
||||
['Name', $user->name],
|
||||
['Admin', $user->root_admin ? 'Yes' : 'No'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Extensions;
|
||||
|
||||
use Hashids\HashidsInterface as VendorHashidsInterface;
|
||||
|
||||
interface HashidsInterface extends VendorHashidsInterface
|
||||
{
|
||||
/**
|
||||
* Decode an encoded hashid and return the first result.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function decodeFirst(string $encoded, string $default = null): mixed;
|
||||
}
|
||||
46
app/Enums/ContainerStatus.php
Normal file
46
app/Enums/ContainerStatus.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ContainerStatus: string
|
||||
{
|
||||
// Docker Based
|
||||
case Created = 'created';
|
||||
case Running = 'running';
|
||||
case Restarting = 'restarting';
|
||||
case Exited = 'exited';
|
||||
case Paused = 'paused';
|
||||
case Dead = 'dead';
|
||||
case Removing = 'removing';
|
||||
|
||||
// HTTP Based
|
||||
case Missing = 'missing';
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Created => 'tabler-heart-plus',
|
||||
self::Running => 'tabler-heartbeat',
|
||||
self::Restarting => 'tabler-heart-bolt',
|
||||
self::Exited => 'tabler-heart-exclamation',
|
||||
self::Paused => 'tabler-heart-pause',
|
||||
self::Dead => 'tabler-heart-x',
|
||||
self::Removing => 'tabler-heart-down',
|
||||
self::Missing => 'tabler-heart-question',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Created => 'primary',
|
||||
self::Running => 'success',
|
||||
self::Restarting => 'info',
|
||||
self::Exited => 'danger',
|
||||
self::Paused => 'warning',
|
||||
self::Dead => 'danger',
|
||||
self::Removing => 'warning',
|
||||
self::Missing => 'danger',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
app/Enums/HttpStatusCode.php
Normal file
31
app/Enums/HttpStatusCode.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum HttpStatusCode: int
|
||||
{
|
||||
// Client
|
||||
case BadRequest = 400;
|
||||
case Unauthorized = 401;
|
||||
case Forbidden = 403;
|
||||
case NotFound = 404;
|
||||
case MethodNotAllowed = 405;
|
||||
case NotAcceptable = 406;
|
||||
case ProxyAuthenticationRequired = 407;
|
||||
case RequestTimeout = 408;
|
||||
case Conflict = 409;
|
||||
case Gone = 410;
|
||||
case LengthRequired = 411;
|
||||
case PreconditionFailed = 412;
|
||||
case PayloadTooLarge = 413;
|
||||
case UriTooLong = 414;
|
||||
case UnsupportedMediaType = 415;
|
||||
|
||||
// Server
|
||||
case InternalServerError = 500;
|
||||
case NotImplemented = 501;
|
||||
case BadGateway = 502;
|
||||
case ServiceUnavailable = 503;
|
||||
case GatewayTimeout = 504;
|
||||
case HTTPVersionNotSupported = 505;
|
||||
}
|
||||
37
app/Enums/ServerState.php
Normal file
37
app/Enums/ServerState.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ServerState: string
|
||||
{
|
||||
case Normal = 'normal';
|
||||
case Installing = 'installing';
|
||||
case InstallFailed = 'install_failed';
|
||||
case ReinstallFailed = 'reinstall_failed';
|
||||
case Suspended = 'suspended';
|
||||
case RestoringBackup = 'restoring_backup';
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Normal => 'tabler-heart',
|
||||
self::Installing => 'tabler-heart-bolt',
|
||||
self::InstallFailed => 'tabler-heart-x',
|
||||
self::ReinstallFailed => 'tabler-heart-x',
|
||||
self::Suspended => 'tabler-heart-cancel',
|
||||
self::RestoringBackup => 'tabler-heart-up',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Normal => 'primary',
|
||||
self::Installing => 'primary',
|
||||
self::InstallFailed => 'danger',
|
||||
self::ReinstallFailed => 'danger',
|
||||
self::Suspended => 'warning',
|
||||
self::RestoringBackup => 'primary',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,11 @@
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Http\Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Prologue\Alerts\AlertsMessageBag;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
@@ -47,8 +46,18 @@ class DisplayException extends PanelException implements HttpExceptionInterface
|
||||
* and then redirecting them back to the page that they came from. If the
|
||||
* request originated from an API hit, return the error in JSONAPI spec format.
|
||||
*/
|
||||
public function render(Request $request): JsonResponse|RedirectResponse
|
||||
public function render(Request $request)
|
||||
{
|
||||
if ($request->is('livewire/update')) {
|
||||
Notification::make()
|
||||
->title(static::class)
|
||||
->body($this->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(Handler::toArray($this), $this->getStatusCode(), $this->getHeaders());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Exceptions\Http\Server;
|
||||
|
||||
use App\Enums\ServerState;
|
||||
use App\Models\Server;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
|
||||
@@ -20,7 +21,7 @@ class ServerStateConflictException extends ConflictHttpException
|
||||
$message = 'The node of this server is currently under maintenance and the functionality requested is unavailable.';
|
||||
} elseif (!$server->isInstalled()) {
|
||||
$message = 'This server has not yet completed its installation process, please try again later.';
|
||||
} elseif ($server->status === Server::STATUS_RESTORING_BACKUP) {
|
||||
} elseif ($server->status === ServerState::RestoringBackup) {
|
||||
$message = 'This server is currently restoring from a backup, please try again later.';
|
||||
} elseif (!is_null($server->transfer)) {
|
||||
$message = 'This server is currently being transferred to a new machine, please try again later.';
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Service\Deployment;
|
||||
|
||||
use App\Exceptions\DisplayException;
|
||||
|
||||
class NoViableNodeException extends DisplayException
|
||||
{
|
||||
}
|
||||
@@ -6,9 +6,9 @@ use App\Exceptions\DisplayException;
|
||||
|
||||
class TwoFactorAuthenticationTokenInvalid extends DisplayException
|
||||
{
|
||||
/**
|
||||
* TwoFactorAuthenticationTokenInvalid constructor.
|
||||
*/
|
||||
public string $title = 'Invalid 2FA Code';
|
||||
public string $icon = 'tabler-2fa';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('The provided two-factor authentication token was not valid.');
|
||||
|
||||
@@ -97,7 +97,7 @@ class BackupManager
|
||||
/**
|
||||
* Creates a new daemon adapter.
|
||||
*/
|
||||
public function createDaemonAdapter(array $config): FilesystemAdapter
|
||||
public function createWingsAdapter(array $config): FilesystemAdapter
|
||||
{
|
||||
return new InMemoryFilesystemAdapter();
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class DynamicDatabaseConnection
|
||||
'port' => $host->port,
|
||||
'database' => $database,
|
||||
'username' => $host->username,
|
||||
'password' => decrypt($host->password),
|
||||
'password' => $host->password,
|
||||
'charset' => self::DB_CHARSET,
|
||||
'collation' => self::DB_COLLATION,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
<?php
|
||||
|
||||
/* The MIT License (MIT)
|
||||
|
||||
Pterodactyl®
|
||||
Copyright © Dane Everitt <dane@daneeveritt.com> and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE. */
|
||||
|
||||
namespace App\Extensions\Filesystem;
|
||||
|
||||
use Aws\S3\S3ClientInterface;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions;
|
||||
|
||||
use Hashids\Hashids as VendorHashids;
|
||||
use App\Contracts\Extensions\HashidsInterface;
|
||||
|
||||
class Hashids extends VendorHashids implements HashidsInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function decodeFirst(string $encoded, string $default = null): mixed
|
||||
{
|
||||
$result = $this->decode($encoded);
|
||||
if (!is_array($result)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return array_first($result, null, $default);
|
||||
}
|
||||
}
|
||||
10
app/Filament/Clusters/Settings.php
Normal file
10
app/Filament/Clusters/Settings.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Clusters;
|
||||
|
||||
use Filament\Clusters\Cluster;
|
||||
|
||||
class Settings extends Cluster
|
||||
{
|
||||
protected static ?string $navigationIcon = 'tabler-settings';
|
||||
}
|
||||
81
app/Filament/Pages/Dashboard.php
Normal file
81
app/Filament/Pages/Dashboard.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\NodeResource\Pages\ListNodes;
|
||||
use App\Models\Egg;
|
||||
use App\Models\Node;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\Helpers\SoftwareVersionService;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class Dashboard extends Page
|
||||
{
|
||||
protected static ?string $navigationIcon = 'tabler-layout-dashboard';
|
||||
|
||||
protected static string $view = 'filament.pages.dashboard';
|
||||
|
||||
protected ?string $heading = '';
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return trans('strings.dashboard');
|
||||
}
|
||||
|
||||
protected static ?string $slug = '/';
|
||||
|
||||
public string $activeTab = 'nodes';
|
||||
|
||||
public function getViewData(): array
|
||||
{
|
||||
/** @var SoftwareVersionService $softwareVersionService */
|
||||
$softwareVersionService = app(SoftwareVersionService::class);
|
||||
|
||||
return [
|
||||
'inDevelopment' => config('app.version') === 'canary',
|
||||
'version' => $softwareVersionService->versionData()['version'],
|
||||
'latestVersion' => $softwareVersionService->getPanel(),
|
||||
'isLatest' => $softwareVersionService->isLatestPanel(),
|
||||
'eggsCount' => Egg::query()->count(),
|
||||
'nodesList' => ListNodes::getUrl(),
|
||||
'nodesCount' => Node::query()->count(),
|
||||
'serversCount' => Server::query()->count(),
|
||||
'usersCount' => User::query()->count(),
|
||||
|
||||
'devActions' => [
|
||||
CreateAction::make()
|
||||
->label('Bugs & Features')
|
||||
->icon('tabler-brand-github')
|
||||
->url('https://github.com/pelican-dev/panel/discussions', true),
|
||||
],
|
||||
'updateActions' => [
|
||||
CreateAction::make()
|
||||
->label('Read Documentation')
|
||||
->icon('tabler-clipboard-text')
|
||||
->url('https://pelican.dev/docs/panel/update', true)
|
||||
->color('warning'),
|
||||
],
|
||||
'nodeActions' => [
|
||||
CreateAction::make()
|
||||
->label(trans('dashboard/index.sections.intro-first-node.button_label'))
|
||||
->icon('tabler-server-2')
|
||||
->url(route('filament.admin.resources.nodes.create')),
|
||||
],
|
||||
'supportActions' => [
|
||||
CreateAction::make()
|
||||
->label(trans('dashboard/index.sections.intro-support.button_donate'))
|
||||
->icon('tabler-cash')
|
||||
->url($softwareVersionService->getDonations(), true)
|
||||
->color('success'),
|
||||
],
|
||||
'helpActions' => [
|
||||
CreateAction::make()
|
||||
->label(trans('dashboard/index.sections.intro-help.button_docs'))
|
||||
->icon('tabler-speedboat')
|
||||
->url('https://pelican.dev/docs', true),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Filament/Resources/ApiKeyResource.php
Normal file
39
app/Filament/Resources/ApiKeyResource.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ApiKeyResource\Pages;
|
||||
use App\Models\ApiKey;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class ApiKeyResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ApiKey::class;
|
||||
protected static ?string $label = 'API Key';
|
||||
protected static ?string $navigationIcon = 'tabler-key';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::where('key_type', '2')->count() ?: null;
|
||||
}
|
||||
|
||||
public static function canEdit($record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListApiKeys::route('/'),
|
||||
'create' => Pages\CreateApiKey::route('/create'),
|
||||
];
|
||||
}
|
||||
}
|
||||
88
app/Filament/Resources/ApiKeyResource/Pages/CreateApiKey.php
Normal file
88
app/Filament/Resources/ApiKeyResource/Pages/CreateApiKey.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ApiKeyResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ApiKeyResource;
|
||||
use App\Models\ApiKey;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Forms;
|
||||
|
||||
class CreateApiKey extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ApiKeyResource::class;
|
||||
|
||||
protected ?string $heading = 'Create Application API Key';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Hidden::make('identifier')->default(ApiKey::generateTokenIdentifier(ApiKey::TYPE_APPLICATION)),
|
||||
Forms\Components\Hidden::make('token')->default(str_random(ApiKey::KEY_LENGTH)),
|
||||
|
||||
Forms\Components\Hidden::make('user_id')
|
||||
->default(auth()->user()->id)
|
||||
->required(),
|
||||
|
||||
Forms\Components\Hidden::make('key_type')
|
||||
->inlineLabel()
|
||||
->default(ApiKey::TYPE_APPLICATION)
|
||||
->required(),
|
||||
|
||||
Forms\Components\Fieldset::make('Permissions')
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 2,
|
||||
])
|
||||
->schema(
|
||||
collect(ApiKey::RESOURCES)->map(fn ($resource) => Forms\Components\ToggleButtons::make("r_$resource")
|
||||
->label(str($resource)->replace('_', ' ')->title())->inline()
|
||||
->options([
|
||||
0 => 'None',
|
||||
1 => 'Read',
|
||||
// 2 => 'Write',
|
||||
3 => 'Read & Write',
|
||||
])
|
||||
->icons([
|
||||
0 => 'tabler-book-off',
|
||||
1 => 'tabler-book',
|
||||
2 => 'tabler-writing',
|
||||
3 => 'tabler-writing',
|
||||
])
|
||||
->colors([
|
||||
0 => 'success',
|
||||
1 => 'warning',
|
||||
2 => 'danger',
|
||||
3 => 'danger',
|
||||
])
|
||||
->required()
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
])
|
||||
->default(0),
|
||||
)->all(),
|
||||
),
|
||||
|
||||
Forms\Components\TagsInput::make('allowed_ips')
|
||||
->placeholder('Example: 127.0.0.1 or 192.168.1.1')
|
||||
->label('Whitelisted IPv4 Addresses')
|
||||
->helperText('Press enter to add a new IP address or leave blank to allow any IP address')
|
||||
->columnSpanFull()
|
||||
->hidden()
|
||||
->default(null),
|
||||
|
||||
Forms\Components\Textarea::make('memo')
|
||||
->required()
|
||||
->label('Description')
|
||||
->helperText('
|
||||
Once you have assigned permissions and created this set of credentials you will be unable to come back and edit it.
|
||||
If you need to make changes down the road you will need to create a new set of credentials.
|
||||
')
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
app/Filament/Resources/ApiKeyResource/Pages/ListApiKeys.php
Normal file
62
app/Filament/Resources/ApiKeyResource/Pages/ListApiKeys.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ApiKeyResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ApiKeyResource;
|
||||
use App\Models\ApiKey;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables;
|
||||
|
||||
class ListApiKeys extends ListRecords
|
||||
{
|
||||
protected static string $resource = ApiKeyResource::class;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->modifyQueryUsing(fn ($query) => $query->where('key_type', ApiKey::TYPE_APPLICATION))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('key')
|
||||
->copyable()
|
||||
->icon('tabler-clipboard-text')
|
||||
->state(fn (ApiKey $key) => $key->identifier . $key->token),
|
||||
|
||||
Tables\Columns\TextColumn::make('memo')
|
||||
->label('Description')
|
||||
->wrap()
|
||||
->limit(50),
|
||||
|
||||
Tables\Columns\TextColumn::make('identifier')
|
||||
->hidden()
|
||||
->searchable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('last_used_at')
|
||||
->label('Last Used')
|
||||
->placeholder('Not Used')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('user.username')
|
||||
->label('Created By')
|
||||
->url(fn (ApiKey $apiKey): string => route('filament.admin.resources.users.edit', ['record' => $apiKey->user])),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Filament/Resources/DatabaseHostResource.php
Normal file
37
app/Filament/Resources/DatabaseHostResource.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\DatabaseHostResource\Pages;
|
||||
use App\Models\DatabaseHost;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class DatabaseHostResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DatabaseHost::class;
|
||||
|
||||
protected static ?string $label = 'Databases';
|
||||
|
||||
protected static ?string $navigationIcon = 'tabler-database';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count() ?: null;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListDatabaseHosts::route('/'),
|
||||
'create' => Pages\CreateDatabaseHost::route('/create'),
|
||||
'edit' => Pages\EditDatabaseHost::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseHostResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseHostResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Form;
|
||||
|
||||
class CreateDatabaseHost extends CreateRecord
|
||||
{
|
||||
protected static string $resource = DatabaseHostResource::class;
|
||||
|
||||
protected ?string $heading = 'Database Hosts';
|
||||
|
||||
protected static bool $canCreateAnother = false;
|
||||
|
||||
protected ?string $subheading = '(database servers that can have individual databases)';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Section::make()
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'lg' => 4,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('host')
|
||||
->columnSpan(2)
|
||||
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('port')
|
||||
->columnSpan(1)
|
||||
->helperText('The port that MySQL is running on for this host.')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(3306)
|
||||
->minValue(0)
|
||||
->maxValue(65535),
|
||||
Forms\Components\TextInput::make('max_databases')
|
||||
->label('Max databases')
|
||||
->helpertext('Blank is unlimited.')
|
||||
->numeric(),
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Display Name')
|
||||
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
|
||||
->required()
|
||||
->maxLength(60),
|
||||
Forms\Components\TextInput::make('username')
|
||||
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('password')
|
||||
->helperText('The password for the database user.')
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(191)
|
||||
->required(),
|
||||
Forms\Components\Select::make('node_id')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
|
||||
->label('Linked Node')
|
||||
->relationship('node', 'name'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->getCreateFormAction()->formId('form'),
|
||||
];
|
||||
|
||||
}
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseHostResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseHostResource;
|
||||
use App\Models\DatabaseHost;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Form;
|
||||
|
||||
class EditDatabaseHost extends EditRecord
|
||||
{
|
||||
protected static string $resource = DatabaseHostResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Section::make()
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'lg' => 4,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('host')
|
||||
->columnSpan(2)
|
||||
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('port')
|
||||
->columnSpan(1)
|
||||
->helperText('The port that MySQL is running on for this host.')
|
||||
->required()
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(65535),
|
||||
Forms\Components\TextInput::make('max_databases')
|
||||
->label('Max databases')
|
||||
->helpertext('Blank is unlimited.')
|
||||
->numeric(),
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Display Name')
|
||||
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
|
||||
->required()
|
||||
->maxLength(60),
|
||||
Forms\Components\TextInput::make('username')
|
||||
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('password')
|
||||
->helperText('The password for the database user.')
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(191)
|
||||
->required(),
|
||||
Forms\Components\Select::make('node_id')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
|
||||
->label('Linked Node')
|
||||
->relationship('node', 'name'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->label(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0 ? 'Database Host Has Databases' : 'Delete')
|
||||
->disabled(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0),
|
||||
$this->getSaveFormAction()->formId('form'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getRelationManagers(): array
|
||||
{
|
||||
return [
|
||||
DatabaseHostResource\RelationManagers\DatabasesRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseHostResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseHostResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ListDatabaseHosts extends ListRecords
|
||||
{
|
||||
protected static string $resource = DatabaseHostResource::class;
|
||||
|
||||
protected ?string $heading = 'Database Hosts';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('host')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('port')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('username')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('max_databases')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('node.name')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make('create')->label('New Database Host'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseHostResource\RelationManagers;
|
||||
|
||||
use App\Models\Database;
|
||||
use App\Services\Databases\DatabasePasswordService;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Actions\Action;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DatabasesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'databases';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('database')->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('username'),
|
||||
Forms\Components\TextInput::make('password')
|
||||
->hintAction(
|
||||
Action::make('rotate')
|
||||
->icon('tabler-refresh')
|
||||
->requiresConfirmation()
|
||||
->action(fn (DatabasePasswordService $service, Database $database, $set, $get) => $this->rotatePassword($service, $database, $set, $get))
|
||||
)
|
||||
->formatStateUsing(fn (Database $database) => $database->password),
|
||||
Forms\Components\TextInput::make('remote')->label('Connections From'),
|
||||
Forms\Components\TextInput::make('max_connections'),
|
||||
Forms\Components\TextInput::make('JDBC')
|
||||
->label('JDBC Connection String')
|
||||
->columnSpanFull()
|
||||
->formatStateUsing(fn (Forms\Get $get, Database $database) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($database->password) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database')),
|
||||
]);
|
||||
}
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('servers')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('database')->icon('tabler-database'),
|
||||
Tables\Columns\TextColumn::make('username')->icon('tabler-user'),
|
||||
//Tables\Columns\TextColumn::make('password'),
|
||||
Tables\Columns\TextColumn::make('remote'),
|
||||
Tables\Columns\TextColumn::make('server.name')
|
||||
->icon('tabler-brand-docker')
|
||||
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
|
||||
Tables\Columns\TextColumn::make('max_connections'),
|
||||
Tables\Columns\TextColumn::make('created_at')->dateTime(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
Tables\Actions\ViewAction::make()->color('primary'),
|
||||
//Tables\Actions\EditAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function rotatePassword(DatabasePasswordService $service, Database $database, $set, $get): void
|
||||
{
|
||||
$newPassword = $service->handle($database);
|
||||
$jdbcString = 'jdbc:mysql://' . $get('username') . ':' . urlencode($newPassword) . '@' . $database->host->host . ':' . $database->host->port . '/' . $get('database');
|
||||
|
||||
$set('password', $newPassword);
|
||||
$set('JDBC', $jdbcString);
|
||||
}
|
||||
}
|
||||
37
app/Filament/Resources/DatabaseResource.php
Normal file
37
app/Filament/Resources/DatabaseResource.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\DatabaseResource\Pages;
|
||||
use App\Models\Database;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class DatabaseResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Database::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'tabler-database';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count() ?: null;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListDatabases::route('/'),
|
||||
'create' => Pages\CreateDatabase::route('/create'),
|
||||
'edit' => Pages\EditDatabase::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseResource;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Forms;
|
||||
|
||||
class CreateDatabase extends CreateRecord
|
||||
{
|
||||
protected static string $resource = DatabaseResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Select::make('server_id')
|
||||
->relationship('server', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('database_host_id')
|
||||
->required()
|
||||
->numeric(),
|
||||
Forms\Components\TextInput::make('database')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('remote')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->default('%'),
|
||||
Forms\Components\TextInput::make('username')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('password')
|
||||
->password()
|
||||
->revealable()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('max_connections')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Filament\Forms;
|
||||
|
||||
class EditDatabase extends EditRecord
|
||||
{
|
||||
protected static string $resource = DatabaseResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Select::make('server_id')
|
||||
->relationship('server', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('database_host_id')
|
||||
->required()
|
||||
->numeric(),
|
||||
Forms\Components\TextInput::make('database')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('remote')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->default('%'),
|
||||
Forms\Components\TextInput::make('username')
|
||||
->required()
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('password')
|
||||
->password()
|
||||
->revealable()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('max_connections')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DatabaseResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DatabaseResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables;
|
||||
|
||||
class ListDatabases extends ListRecords
|
||||
{
|
||||
protected static string $resource = DatabaseResource::class;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('server.name')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('database_host_id')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('database')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('username')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('remote')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('max_connections')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Filament/Resources/EggResource.php
Normal file
44
app/Filament/Resources/EggResource.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\EggResource\Pages;
|
||||
use App\Models\Egg;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class EggResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Egg::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'tabler-eggs';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
protected static ?string $recordRouteKeyName = 'id';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count() ?: null;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['name', 'tags', 'uuid', 'id'];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEggs::route('/'),
|
||||
'create' => Pages\CreateEgg::route('/create'),
|
||||
'edit' => Pages\EditEgg::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
213
app/Filament/Resources/EggResource/Pages/CreateEgg.php
Normal file
213
app/Filament/Resources/EggResource/Pages/CreateEgg.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EggResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EggResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreateEgg extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EggResource::class;
|
||||
|
||||
protected static bool $canCreateAnother = false;
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Tabs::make()->tabs([
|
||||
Forms\Components\Tabs\Tab::make('Configuration')
|
||||
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
|
||||
->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
|
||||
Forms\Components\TextInput::make('author')
|
||||
->maxLength(191)
|
||||
->required()
|
||||
->email()
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
|
||||
->helperText('The author of this version of the Egg.'),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->rows(3)
|
||||
->columnSpanFull()
|
||||
->helperText('A description of this Egg that will be displayed throughout the Panel as needed.'),
|
||||
Forms\Components\Textarea::make('startup')
|
||||
->rows(3)
|
||||
->columnSpanFull()
|
||||
->required()
|
||||
->placeholder(implode("\n", [
|
||||
'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}',
|
||||
]))
|
||||
->helperText('The default startup command that should be used for new servers using this Egg.'),
|
||||
Forms\Components\TagsInput::make('features')
|
||||
->placeholder('Add Feature')
|
||||
->helperText('')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
|
||||
Forms\Components\Toggle::make('force_outgoing_ip')
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's primary allocation IP.
|
||||
Required for certain games to work properly when the Node has multiple public IP addresses.
|
||||
Enabling this option will disable internal networking for any servers using this egg, causing them to be unable to internally access other servers on the same node."),
|
||||
Forms\Components\Hidden::make('script_is_privileged')
|
||||
->default(1),
|
||||
Forms\Components\TagsInput::make('tags')
|
||||
->placeholder('Add Tags')
|
||||
->helperText('')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
|
||||
Forms\Components\TextInput::make('update_url')
|
||||
->disabled()
|
||||
->helperText('Not implemented.')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
|
||||
Forms\Components\KeyValue::make('docker_images')
|
||||
->live()
|
||||
->columnSpanFull()
|
||||
->required()
|
||||
->addActionLabel('Add Image')
|
||||
->keyLabel('Name')
|
||||
->keyPlaceholder('Java 21')
|
||||
->valueLabel('Image URI')
|
||||
->valuePlaceholder('ghcr.io/parkervcp/yolks:java_21')
|
||||
->helperText('The docker images available to servers using this egg.'),
|
||||
]),
|
||||
|
||||
Forms\Components\Tabs\Tab::make('Process Management')
|
||||
->columns()
|
||||
->schema([
|
||||
Forms\Components\Hidden::make('config_from')
|
||||
->default(null)
|
||||
->label('Copy Settings From')
|
||||
// ->placeholder('None')
|
||||
// ->relationship('configFrom', 'name', ignoreRecord: true)
|
||||
->helperText('If you would like to default to settings from another Egg select it from the menu above.'),
|
||||
Forms\Components\TextInput::make('config_stop')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->label('Stop Command')
|
||||
->helperText('The command that should be sent to server processes to stop them gracefully. If you need to send a SIGINT you should enter ^C here.'),
|
||||
Forms\Components\Textarea::make('config_startup')->rows(10)->json()
|
||||
->label('Start Configuration')
|
||||
->default('{}')
|
||||
->helperText('List of values the daemon should be looking for when booting a server to determine completion.'),
|
||||
Forms\Components\Textarea::make('config_files')->rows(10)->json()
|
||||
->label('Configuration Files')
|
||||
->default('{}')
|
||||
->helperText('This should be a JSON representation of configuration files to modify and what parts should be changed.'),
|
||||
Forms\Components\Textarea::make('config_logs')->rows(10)->json()
|
||||
->label('Log Configuration')
|
||||
->default('{}')
|
||||
->helperText('This should be a JSON representation of where log files are stored, and whether or not the daemon should be creating custom logs.'),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('Egg Variables')
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\Repeater::make('variables')
|
||||
->label('')
|
||||
->addActionLabel('Add New Egg Variable')
|
||||
->grid()
|
||||
->relationship('variables')
|
||||
->name('name')
|
||||
->reorderable()->orderColumn()
|
||||
->collapsible()->collapsed()
|
||||
->columnSpan(2)
|
||||
->defaultItems(0)
|
||||
->itemLabel(fn (array $state) => $state['name'])
|
||||
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
|
||||
$data['default_value'] ??= '';
|
||||
$data['description'] ??= '';
|
||||
$data['rules'] ??= '';
|
||||
$data['user_viewable'] ??= '';
|
||||
$data['user_editable'] ??= '';
|
||||
|
||||
return $data;
|
||||
})
|
||||
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
|
||||
$data['default_value'] ??= '';
|
||||
$data['description'] ??= '';
|
||||
$data['rules'] ??= '';
|
||||
$data['user_viewable'] ??= '';
|
||||
$data['user_editable'] ??= '';
|
||||
|
||||
return $data;
|
||||
})
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->live()
|
||||
->debounce(750)
|
||||
->maxLength(191)
|
||||
->columnSpanFull()
|
||||
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
|
||||
)
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('description')->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('env_variable')
|
||||
->label('Environment Variable')
|
||||
->maxLength(191)
|
||||
->prefix('{{')
|
||||
->suffix('}}')
|
||||
->hintIcon('tabler-code')
|
||||
->hintIconTooltip(fn ($state) => "{{{$state}}}")
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('default_value')->maxLength(191),
|
||||
Forms\Components\Fieldset::make('User Permissions')
|
||||
->schema([
|
||||
Forms\Components\Checkbox::make('user_viewable')->label('Viewable'),
|
||||
Forms\Components\Checkbox::make('user_editable')->label('Editable'),
|
||||
]),
|
||||
Forms\Components\Textarea::make('rules')->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('Install Script')
|
||||
->columns(3)
|
||||
->schema([
|
||||
|
||||
Forms\Components\Hidden::make('copy_script_from'),
|
||||
//->placeholder('None')
|
||||
//->relationship('scriptFrom', 'name', ignoreRecord: true),
|
||||
|
||||
Forms\Components\TextInput::make('script_container')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->default('alpine:3.4'),
|
||||
|
||||
Forms\Components\Select::make('script_entry')
|
||||
->selectablePlaceholder(false)
|
||||
->default('bash')
|
||||
->options(['bash', 'ash', '/bin/bash'])
|
||||
->required(),
|
||||
|
||||
MonacoEditor::make('script_install')
|
||||
->columnSpanFull()
|
||||
->fontSize('16px')
|
||||
->language('shell')
|
||||
->lazy()
|
||||
->view('filament.plugins.monaco-editor'),
|
||||
]),
|
||||
|
||||
])->columnSpanFull()->persistTabInQueryString(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
$data['uuid'] ??= Str::uuid()->toString();
|
||||
|
||||
if (is_array($data['config_startup'])) {
|
||||
$data['config_startup'] = json_encode($data['config_startup']);
|
||||
}
|
||||
|
||||
if (is_array($data['config_logs'])) {
|
||||
$data['config_logs'] = json_encode($data['config_logs']);
|
||||
}
|
||||
|
||||
logger()->info('new egg', $data);
|
||||
|
||||
return parent::handleRecordCreation($data);
|
||||
}
|
||||
}
|
||||
225
app/Filament/Resources/EggResource/Pages/EditEgg.php
Normal file
225
app/Filament/Resources/EggResource/Pages/EditEgg.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EggResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EggResource;
|
||||
use App\Models\Egg;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
|
||||
class EditEgg extends EditRecord
|
||||
{
|
||||
protected static string $resource = EggResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Tabs::make()->tabs([
|
||||
Forms\Components\Tabs\Tab::make('Configuration')
|
||||
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1])
|
||||
->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
|
||||
Forms\Components\TextInput::make('uuid')
|
||||
->label('Egg UUID')
|
||||
->disabled()
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
|
||||
->helperText('This is the globally unique identifier for this Egg which Wings uses as an identifier.'),
|
||||
Forms\Components\TextInput::make('id')
|
||||
->label('Egg ID')
|
||||
->disabled(),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->rows(3)
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
|
||||
->helperText('A description of this Egg that will be displayed throughout the Panel as needed.'),
|
||||
Forms\Components\TextInput::make('author')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->email()
|
||||
->disabled()
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
|
||||
->helperText('The author of this version of the Egg. Uploading a new Egg configuration from a different author will change this.'),
|
||||
Forms\Components\Textarea::make('startup')
|
||||
->rows(2)
|
||||
->columnSpanFull()
|
||||
->required()
|
||||
->helperText('The default startup command that should be used for new servers using this Egg.'),
|
||||
Forms\Components\TagsInput::make('file_denylist')
|
||||
->hidden() // latest wings breaks it.
|
||||
->placeholder('denied-file.txt')
|
||||
->helperText('A list of files that the end user is not allowed to edit.')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
|
||||
Forms\Components\TagsInput::make('features')
|
||||
->placeholder('Add Feature')
|
||||
->helperText('')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
|
||||
Forms\Components\Toggle::make('force_outgoing_ip')
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's primary allocation IP.
|
||||
Required for certain games to work properly when the Node has multiple public IP addresses.
|
||||
Enabling this option will disable internal networking for any servers using this egg, causing them to be unable to internally access other servers on the same node."),
|
||||
Forms\Components\Hidden::make('script_is_privileged')
|
||||
->helperText('The docker images available to servers using this egg.'),
|
||||
Forms\Components\TagsInput::make('tags')
|
||||
->placeholder('Add Tags')
|
||||
->helperText('')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
|
||||
Forms\Components\TextInput::make('update_url')
|
||||
->disabled()
|
||||
->helperText('Not implemented.')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
|
||||
Forms\Components\KeyValue::make('docker_images')
|
||||
->live()
|
||||
->columnSpanFull()
|
||||
->required()
|
||||
->addActionLabel('Add Image')
|
||||
->keyLabel('Name')
|
||||
->valueLabel('Image URI')
|
||||
->helperText('The docker images available to servers using this egg.'),
|
||||
]),
|
||||
|
||||
Forms\Components\Tabs\Tab::make('Process Management')
|
||||
->columns()
|
||||
->schema([
|
||||
Forms\Components\Select::make('config_from')
|
||||
->label('Copy Settings From')
|
||||
->placeholder('None')
|
||||
->relationship('configFrom', 'name', ignoreRecord: true)
|
||||
->helperText('If you would like to default to settings from another Egg select it from the menu above.'),
|
||||
Forms\Components\TextInput::make('config_stop')
|
||||
->maxLength(191)
|
||||
->label('Stop Command')
|
||||
->helperText('The command that should be sent to server processes to stop them gracefully. If you need to send a SIGINT you should enter ^C here.'),
|
||||
Forms\Components\Textarea::make('config_startup')->rows(10)->json()
|
||||
->label('Start Configuration')
|
||||
->helperText('List of values the daemon should be looking for when booting a server to determine completion.'),
|
||||
Forms\Components\Textarea::make('config_files')->rows(10)->json()
|
||||
->label('Configuration Files')
|
||||
->helperText('This should be a JSON representation of configuration files to modify and what parts should be changed.'),
|
||||
Forms\Components\Textarea::make('config_logs')->rows(10)->json()
|
||||
->label('Log Configuration')
|
||||
->helperText('This should be a JSON representation of where log files are stored, and whether or not the daemon should be creating custom logs.'),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('Egg Variables')
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\Repeater::make('variables')
|
||||
->label('')
|
||||
->grid()
|
||||
->relationship('variables')
|
||||
->name('name')
|
||||
->reorderable()
|
||||
->collapsible()->collapsed()
|
||||
->orderColumn()
|
||||
->addActionLabel('New Variable')
|
||||
->itemLabel(fn (array $state) => $state['name'])
|
||||
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
|
||||
$data['default_value'] ??= '';
|
||||
$data['description'] ??= '';
|
||||
$data['rules'] ??= '';
|
||||
$data['user_viewable'] ??= '';
|
||||
$data['user_editable'] ??= '';
|
||||
|
||||
return $data;
|
||||
})
|
||||
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
|
||||
$data['default_value'] ??= '';
|
||||
$data['description'] ??= '';
|
||||
$data['rules'] ??= '';
|
||||
$data['user_viewable'] ??= '';
|
||||
$data['user_editable'] ??= '';
|
||||
|
||||
return $data;
|
||||
})
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->live()
|
||||
->debounce(750)
|
||||
->maxLength(191)
|
||||
->columnSpanFull()
|
||||
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
|
||||
)
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('description')->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('env_variable')
|
||||
->label('Environment Variable')
|
||||
->maxLength(191)
|
||||
->prefix('{{')
|
||||
->suffix('}}')
|
||||
->hintIcon('tabler-code')
|
||||
->hintIconTooltip(fn ($state) => "{{{$state}}}")
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('default_value')->maxLength(191),
|
||||
Forms\Components\Fieldset::make('User Permissions')
|
||||
->schema([
|
||||
Forms\Components\Checkbox::make('user_viewable')->label('Viewable'),
|
||||
Forms\Components\Checkbox::make('user_editable')->label('Editable'),
|
||||
]),
|
||||
Forms\Components\TextInput::make('rules')->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('Install Script')
|
||||
->columns(3)
|
||||
->schema([
|
||||
|
||||
Forms\Components\Select::make('copy_script_from')
|
||||
->placeholder('None')
|
||||
->relationship('scriptFrom', 'name', ignoreRecord: true),
|
||||
|
||||
Forms\Components\TextInput::make('script_container')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->default('alpine:3.4'),
|
||||
|
||||
Forms\Components\TextInput::make('script_entry')
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->default('ash'),
|
||||
|
||||
MonacoEditor::make('script_install')
|
||||
->label('Install Script')
|
||||
->columnSpanFull()
|
||||
->fontSize('16px')
|
||||
->language('shell')
|
||||
->view('filament.plugins.monaco-editor'),
|
||||
]),
|
||||
|
||||
])->columnSpanFull()->persistTabInQueryString(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
|
||||
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete Egg' : 'Egg In Use'),
|
||||
Actions\ExportAction::make()
|
||||
->icon('tabler-download')
|
||||
->label('Export Egg')
|
||||
->color('primary')
|
||||
// TODO uses old admin panel export service
|
||||
->url(fn (Egg $egg): string => route('admin.eggs.export', ['egg' => $egg['id']])),
|
||||
$this->getSaveFormAction()->formId('form'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getRelationManagers(): array
|
||||
{
|
||||
return [
|
||||
EggResource\RelationManagers\ServersRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Filament/Resources/EggResource/Pages/ListEggs.php
Normal file
137
app/Filament/Resources/EggResource/Pages/ListEggs.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EggResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EggResource;
|
||||
use App\Models\Egg;
|
||||
use App\Services\Eggs\Sharing\EggImporterService;
|
||||
use Exception;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Tabs;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Table;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Filament\Tables;
|
||||
|
||||
class ListEggs extends ListRecords
|
||||
{
|
||||
protected static string $resource = EggResource::class;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->defaultPaginationPageOption(25)
|
||||
->checkIfRecordIsSelectableUsing(fn (Egg $egg) => $egg->servers_count <= 0)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('Id')
|
||||
->hidden()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->icon('tabler-egg')
|
||||
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
|
||||
->wrap()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('servers_count')
|
||||
->counts('servers')
|
||||
->icon('tabler-server')
|
||||
->label('Servers'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\ExportAction::make()
|
||||
->icon('tabler-download')
|
||||
->label('Export')
|
||||
->color('primary')
|
||||
// TODO uses old admin panel export service
|
||||
->url(fn (Egg $egg): string => route('admin.eggs.export', ['egg' => $egg])),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make('create')->label('Create Egg'),
|
||||
|
||||
Actions\Action::make('import')
|
||||
->label('Import')
|
||||
->form([
|
||||
Tabs::make('Tabs')
|
||||
->tabs([
|
||||
Tabs\Tab::make('From File')
|
||||
->icon('tabler-file-upload')
|
||||
->schema([
|
||||
Forms\Components\FileUpload::make('egg')
|
||||
->label('Egg')
|
||||
->hint('This should be the json file ( egg-minecraft.json )')
|
||||
->acceptedFileTypes(['application/json'])
|
||||
->storeFiles(false)
|
||||
->multiple(),
|
||||
]),
|
||||
Tabs\Tab::make('From URL')
|
||||
->icon('tabler-world-upload')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('url')
|
||||
->label('URL')
|
||||
->hint('This URL should point to a single json file')
|
||||
->url(),
|
||||
]),
|
||||
])
|
||||
->contained(false),
|
||||
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
|
||||
/** @var EggImporterService $eggImportService */
|
||||
$eggImportService = resolve(EggImporterService::class);
|
||||
|
||||
if (!empty($data['egg'])) {
|
||||
/** @var TemporaryUploadedFile[] $eggFile */
|
||||
$eggFile = $data['egg'];
|
||||
|
||||
foreach ($eggFile as $file) {
|
||||
try {
|
||||
$eggImportService->fromFile($file);
|
||||
} catch (Exception $exception) {
|
||||
Notification::make()
|
||||
->title('Import Failed')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
report($exception);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data['url'])) {
|
||||
try {
|
||||
$eggImportService->fromUrl($data['url']);
|
||||
} catch (Exception $exception) {
|
||||
Notification::make()
|
||||
->title('Import Failed')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
report($exception);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Import Success')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EggResource\RelationManagers;
|
||||
|
||||
use App\Models\Server;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ServersRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'servers';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('servers')
|
||||
->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned this egg.')
|
||||
->searchable(false)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.username')
|
||||
->label('Owner')
|
||||
->icon('tabler-user')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->icon('tabler-brand-docker')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('node.name')
|
||||
->icon('tabler-server-2')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])),
|
||||
Tables\Columns\TextColumn::make('image')
|
||||
->label('Docker Image'),
|
||||
Tables\Columns\SelectColumn::make('allocation.id')
|
||||
->label('Primary Allocation')
|
||||
->options(fn ($state, Server $server) => [$server->allocation->id => $server->allocation->address])
|
||||
->selectablePlaceholder(false)
|
||||
->sortable(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
app/Filament/Resources/MountResource.php
Normal file
35
app/Filament/Resources/MountResource.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\MountResource\Pages;
|
||||
use App\Models\Mount;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class MountResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Mount::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'tabler-layers-linked';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count() ?: null;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListMounts::route('/'),
|
||||
'create' => Pages\CreateMount::route('/create'),
|
||||
'edit' => Pages\EditMount::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Filament/Resources/MountResource/Pages/CreateMount.php
Normal file
108
app/Filament/Resources/MountResource/Pages/CreateMount.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\MountResource\Pages;
|
||||
|
||||
use App\Filament\Resources\MountResource;
|
||||
use Filament\Forms\Components\Group;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Filament\Forms;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreateMount extends CreateRecord
|
||||
{
|
||||
protected static string $resource = MountResource::class;
|
||||
|
||||
protected static bool $canCreateAnother = false;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Section::make()->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->helperText('Unique name used to separate this mount from another.')
|
||||
->maxLength(64),
|
||||
Forms\Components\ToggleButtons::make('read_only')
|
||||
->label('Read only?')
|
||||
->helperText('Is the mount read only inside the container?')
|
||||
->options([
|
||||
false => 'Writeable',
|
||||
true => 'Read only',
|
||||
])
|
||||
->icons([
|
||||
false => 'tabler-writing',
|
||||
true => 'tabler-writing-off',
|
||||
])
|
||||
->colors([
|
||||
false => 'warning',
|
||||
true => 'success',
|
||||
])
|
||||
->inline()
|
||||
->default(false)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('source')
|
||||
->required()
|
||||
->helperText('File path on the host system to mount to a container.')
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('target')
|
||||
->required()
|
||||
->helperText('Where the mount will be accessible inside a container.')
|
||||
->maxLength(191),
|
||||
Forms\Components\ToggleButtons::make('user_mountable')
|
||||
->hidden()
|
||||
->label('User mountable?')
|
||||
->options([
|
||||
false => 'No',
|
||||
true => 'Yes',
|
||||
])
|
||||
->icons([
|
||||
false => 'tabler-user-cancel',
|
||||
true => 'tabler-user-bolt',
|
||||
])
|
||||
->colors([
|
||||
false => 'success',
|
||||
true => 'warning',
|
||||
])
|
||||
->default(false)
|
||||
->inline()
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->helperText('A longer description for this mount.')
|
||||
->columnSpanFull(),
|
||||
Forms\Components\Hidden::make('user_mountable')->default(1),
|
||||
])->columnSpan(1)->columns([
|
||||
'default' => 1,
|
||||
'lg' => 2,
|
||||
]),
|
||||
Group::make()->schema([
|
||||
Section::make()->schema([
|
||||
Select::make('eggs')->multiple()
|
||||
->relationship('eggs', 'name')
|
||||
->preload(),
|
||||
Select::make('nodes')->multiple()
|
||||
->relationship('nodes', 'name')
|
||||
->searchable(['name', 'fqdn'])
|
||||
->preload(),
|
||||
]),
|
||||
])->columns([
|
||||
'default' => 1,
|
||||
'lg' => 2,
|
||||
]),
|
||||
])->columns([
|
||||
'default' => 1,
|
||||
'lg' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
$data['uuid'] ??= Str::uuid()->toString();
|
||||
|
||||
return parent::handleRecordCreation($data);
|
||||
}
|
||||
}
|
||||
109
app/Filament/Resources/MountResource/Pages/EditMount.php
Normal file
109
app/Filament/Resources/MountResource/Pages/EditMount.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\MountResource\Pages;
|
||||
|
||||
use App\Filament\Resources\MountResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Group;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
|
||||
class EditMount extends EditRecord
|
||||
{
|
||||
protected static string $resource = MountResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Section::make()->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->helperText('Unique name used to separate this mount from another.')
|
||||
->maxLength(64),
|
||||
Forms\Components\ToggleButtons::make('read_only')
|
||||
->label('Read only?')
|
||||
->helperText('Is the mount read only inside the container?')
|
||||
->options([
|
||||
false => 'Writeable',
|
||||
true => 'Read only',
|
||||
])
|
||||
->icons([
|
||||
false => 'tabler-writing',
|
||||
true => 'tabler-writing-off',
|
||||
])
|
||||
->colors([
|
||||
false => 'warning',
|
||||
true => 'success',
|
||||
])
|
||||
->inline()
|
||||
->default(false)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('source')
|
||||
->required()
|
||||
->helperText('File path on the host system to mount to a container.')
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('target')
|
||||
->required()
|
||||
->helperText('Where the mount will be accessible inside a container.')
|
||||
->maxLength(191),
|
||||
Forms\Components\ToggleButtons::make('user_mountable')
|
||||
->hidden()
|
||||
->label('User mountable?')
|
||||
->options([
|
||||
false => 'No',
|
||||
true => 'Yes',
|
||||
])
|
||||
->icons([
|
||||
false => 'tabler-user-cancel',
|
||||
true => 'tabler-user-bolt',
|
||||
])
|
||||
->colors([
|
||||
false => 'success',
|
||||
true => 'warning',
|
||||
])
|
||||
->default(false)
|
||||
->inline()
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->helperText('A longer description for this mount.')
|
||||
->columnSpanFull(),
|
||||
])->columnSpan(1)->columns([
|
||||
'default' => 1,
|
||||
'lg' => 2,
|
||||
]),
|
||||
Group::make()->schema([
|
||||
Section::make()->schema([
|
||||
Select::make('eggs')->multiple()
|
||||
->relationship('eggs', 'name')
|
||||
->preload(),
|
||||
Select::make('nodes')->multiple()
|
||||
->relationship('nodes', 'name')
|
||||
->searchable(['name', 'fqdn'])
|
||||
->preload(),
|
||||
]),
|
||||
])->columns([
|
||||
'default' => 1,
|
||||
'lg' => 2,
|
||||
]),
|
||||
])->columns([
|
||||
'default' => 1,
|
||||
'lg' => 2,
|
||||
]);
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
$this->getSaveFormAction()->formId('form'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
65
app/Filament/Resources/MountResource/Pages/ListMounts.php
Normal file
65
app/Filament/Resources/MountResource/Pages/ListMounts.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\MountResource\Pages;
|
||||
|
||||
use App\Filament\Resources\MountResource;
|
||||
use App\Models\Mount;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Actions\CreateAction;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables;
|
||||
|
||||
class ListMounts extends ListRecords
|
||||
{
|
||||
protected static string $resource = MountResource::class;
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('source')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('target')
|
||||
->searchable(),
|
||||
Tables\Columns\IconColumn::make('read_only')
|
||||
->icon(fn (bool $state) => $state ? 'tabler-circle-check-filled' : 'tabler-circle-x-filled')
|
||||
->color(fn (bool $state) => $state ? 'success' : 'danger')
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('user_mountable')
|
||||
->hidden()
|
||||
->icon(fn (bool $state) => $state ? 'tabler-circle-check-filled' : 'tabler-circle-x-filled')
|
||||
->color(fn (bool $state) => $state ? 'success' : 'danger')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->emptyStateIcon('tabler-layers-linked')
|
||||
->emptyStateDescription('')
|
||||
->emptyStateHeading('No Mounts')
|
||||
->emptyStateActions([
|
||||
CreateAction::make('create')
|
||||
->label('Create Mount')
|
||||
->button(),
|
||||
]);
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('Create Mount')
|
||||
->hidden(fn () => Mount::count() <= 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Filament/Resources/NodeResource.php
Normal file
39
app/Filament/Resources/NodeResource.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\NodeResource\Pages;
|
||||
use App\Filament\Resources\NodeResource\RelationManagers;
|
||||
use App\Models\Node;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class NodeResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Node::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'tabler-server-2';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count() ?: null;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\AllocationsRelationManager::class,
|
||||
RelationManagers\NodesRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListNodes::route('/'),
|
||||
'create' => Pages\CreateNode::route('/create'),
|
||||
'edit' => Pages\EditNode::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
366
app/Filament/Resources/NodeResource/Pages/CreateNode.php
Normal file
366
app/Filament/Resources/NodeResource/Pages/CreateNode.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NodeResource\Pages;
|
||||
|
||||
use App\Filament\Resources\NodeResource;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Tabs;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CreateNode extends CreateRecord
|
||||
{
|
||||
protected static string $resource = NodeResource::class;
|
||||
|
||||
protected static bool $canCreateAnother = false;
|
||||
|
||||
protected ?string $subheading = 'which is a machine that runs your Servers';
|
||||
|
||||
public function form(Forms\Form $form): Forms\Form
|
||||
{
|
||||
return $form->schema([
|
||||
Tabs::make('Tabs')
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'lg' => 4,
|
||||
])
|
||||
->persistTabInQueryString()
|
||||
->columnSpanFull()
|
||||
->tabs([
|
||||
Tabs\Tab::make('Basic Settings')
|
||||
->icon('tabler-server')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('fqdn')
|
||||
->columnSpan(2)
|
||||
->required()
|
||||
->autofocus()
|
||||
->live(debounce: 1500)
|
||||
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
|
||||
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
|
||||
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
|
||||
->helperText(function ($state) {
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return '
|
||||
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
|
||||
You must use a domain name, because you cannot get SSL certificates for IP Addresses
|
||||
';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return "
|
||||
This is the domain name that points to your node's IP Address.
|
||||
If you've already set up this, you can verify it by checking the next field!
|
||||
";
|
||||
})
|
||||
->hintColor('danger')
|
||||
->hint(function ($state) {
|
||||
if (is_ip($state) && request()->isSecure()) {
|
||||
return 'You cannot connect to an IP Address over SSL';
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
->afterStateUpdated(function (Forms\Set $set, ?string $state) {
|
||||
$set('dns', null);
|
||||
$set('ip', null);
|
||||
|
||||
[$subdomain] = str($state)->explode('.', 2);
|
||||
if (!is_numeric($subdomain)) {
|
||||
$set('name', $subdomain);
|
||||
}
|
||||
|
||||
if (!$state || is_ip($state)) {
|
||||
$set('dns', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$validRecords = gethostbynamel($state);
|
||||
if ($validRecords) {
|
||||
$set('dns', true);
|
||||
|
||||
$set('ip', collect($validRecords)->first());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$set('dns', false);
|
||||
})
|
||||
->maxLength(191),
|
||||
|
||||
Forms\Components\TextInput::make('ip')
|
||||
->disabled()
|
||||
->hidden(),
|
||||
|
||||
Forms\Components\ToggleButtons::make('dns')
|
||||
->label('DNS Record Check')
|
||||
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
|
||||
->disabled()
|
||||
->inline()
|
||||
->default(null)
|
||||
->hint(fn (Forms\Get $get) => $get('ip'))
|
||||
->hintColor('success')
|
||||
->options([
|
||||
true => 'Valid',
|
||||
false => 'Invalid',
|
||||
])
|
||||
->colors([
|
||||
true => 'success',
|
||||
false => 'danger',
|
||||
])
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
]),
|
||||
|
||||
Forms\Components\TextInput::make('daemon_listen')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
])
|
||||
->label(trans('strings.port'))
|
||||
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
|
||||
->minValue(0)
|
||||
->maxValue(65536)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer(),
|
||||
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Display Name')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 2,
|
||||
])
|
||||
->required()
|
||||
->regex('/[a-zA-Z0-9_\.\- ]+/')
|
||||
->helperText('This name is for display only and can be changed later.')
|
||||
->maxLength(100),
|
||||
|
||||
Forms\Components\ToggleButtons::make('scheme')
|
||||
->label('Communicate over SSL')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
])
|
||||
->required()
|
||||
->inline()
|
||||
->helperText(function (Forms\Get $get) {
|
||||
if (request()->isSecure()) {
|
||||
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
|
||||
}
|
||||
|
||||
if (is_ip($get('fqdn'))) {
|
||||
return 'An IP address cannot use SSL.';
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
|
||||
->options([
|
||||
'http' => 'HTTP',
|
||||
'https' => 'HTTPS (SSL)',
|
||||
])
|
||||
->colors([
|
||||
'http' => 'warning',
|
||||
'https' => 'success',
|
||||
])
|
||||
->icons([
|
||||
'http' => 'tabler-lock-open-off',
|
||||
'https' => 'tabler-lock',
|
||||
])
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'),
|
||||
]),
|
||||
Tabs\Tab::make('Advanced Settings')
|
||||
->icon('tabler-server-cog')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('upload_size')
|
||||
->label('Upload Limit')
|
||||
->helperText('Enter the maximum size of files that can be uploaded through the web-based file manager.')
|
||||
->columnSpan(1)
|
||||
->numeric()->required()
|
||||
->default(256)
|
||||
->minValue(1)
|
||||
->maxValue(1024)
|
||||
->suffix('MiB'),
|
||||
Forms\Components\ToggleButtons::make('public')
|
||||
->label('Automatic Allocation')->inline()
|
||||
->default(true)
|
||||
->columnSpan(1)
|
||||
->options([
|
||||
true => 'Yes',
|
||||
false => 'No',
|
||||
])
|
||||
->colors([
|
||||
true => 'success',
|
||||
false => 'danger',
|
||||
]),
|
||||
Forms\Components\ToggleButtons::make('maintenance_mode')
|
||||
->label('Maintenance Mode')->inline()
|
||||
->columnSpan(1)
|
||||
->default(false)
|
||||
->hinticon('tabler-question-mark')
|
||||
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
|
||||
->options([
|
||||
true => 'Enable',
|
||||
false => 'Disable',
|
||||
])
|
||||
->colors([
|
||||
true => 'danger',
|
||||
false => 'success',
|
||||
]),
|
||||
Forms\Components\TagsInput::make('tags')
|
||||
->label('Tags')
|
||||
->disabled()
|
||||
->placeholder('Not Implemented')
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('Not Implemented')
|
||||
->columnSpan(1),
|
||||
Forms\Components\Grid::make()
|
||||
->columns(6)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_mem')
|
||||
->label('Memory')->inlineLabel()->inline()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('memory_overallocate', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0)
|
||||
->live()
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
Forms\Components\TextInput::make('memory')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
|
||||
->label('Memory Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
Forms\Components\TextInput::make('memory_overallocate')
|
||||
->dehydratedWhenHidden()
|
||||
->label('Overallocate')->inlineLabel()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('The % allowable to go over the set limit.')
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(-1)
|
||||
->maxValue(100)
|
||||
->default(0)
|
||||
->suffix('%'),
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columns(6)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_disk')
|
||||
->label('Disk')->inlineLabel()->inline()
|
||||
->live()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('disk_overallocate', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0)
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
Forms\Components\TextInput::make('disk')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
|
||||
->label('Disk Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0),
|
||||
Forms\Components\TextInput::make('disk_overallocate')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
|
||||
->label('Overallocate')->inlineLabel()
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('The % allowable to go over the set limit.')
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(-1)
|
||||
->maxValue(100)
|
||||
->default(0)
|
||||
->suffix('%'),
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columns(6)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_cpu')
|
||||
->label('CPU')->inlineLabel()->inline()
|
||||
->live()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0))
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu_overallocate', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0)
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
Forms\Components\TextInput::make('cpu')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
|
||||
->label('CPU Limit')->inlineLabel()
|
||||
->suffix('%')
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0),
|
||||
Forms\Components\TextInput::make('cpu_overallocate')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
|
||||
->label('Overallocate')->inlineLabel()
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('The % allowable to go over the set limit.')
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(-1)
|
||||
->maxValue(100)
|
||||
->suffix('%'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getRedirectUrlParameters(): array
|
||||
{
|
||||
return [
|
||||
'tab' => '-configuration-tab',
|
||||
];
|
||||
}
|
||||
}
|
||||
443
app/Filament/Resources/NodeResource/Pages/EditNode.php
Normal file
443
app/Filament/Resources/NodeResource/Pages/EditNode.php
Normal file
@@ -0,0 +1,443 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NodeResource\Pages;
|
||||
|
||||
use App\Filament\Resources\NodeResource;
|
||||
use App\Filament\Resources\NodeResource\Widgets\NodeMemoryChart;
|
||||
use App\Filament\Resources\NodeResource\Widgets\NodeStorageChart;
|
||||
use App\Models\Node;
|
||||
use App\Services\Nodes\NodeUpdateService;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Tabs;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
|
||||
|
||||
class EditNode extends EditRecord
|
||||
{
|
||||
protected static string $resource = NodeResource::class;
|
||||
|
||||
public function form(Forms\Form $form): Forms\Form
|
||||
{
|
||||
return $form->schema([
|
||||
Tabs::make('Tabs')
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'lg' => 4,
|
||||
])
|
||||
->persistTabInQueryString()
|
||||
->columnSpanFull()
|
||||
->tabs([
|
||||
Tabs\Tab::make('Basic Settings')
|
||||
->icon('tabler-server')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('fqdn')
|
||||
->columnSpan(2)
|
||||
->required()
|
||||
->autofocus()
|
||||
->live(debounce: 1500)
|
||||
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
|
||||
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
|
||||
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
|
||||
->helperText(function ($state) {
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return '
|
||||
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
|
||||
You must use a domain name, because you cannot get SSL certificates for IP Addresses
|
||||
';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
return "
|
||||
This is the domain name that points to your node's IP Address.
|
||||
If you've already set up this, you can verify it by checking the next field!
|
||||
";
|
||||
})
|
||||
->hintColor('danger')
|
||||
->hint(function ($state) {
|
||||
if (is_ip($state) && request()->isSecure()) {
|
||||
return 'You cannot connect to an IP Address over SSL';
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
->afterStateUpdated(function (Forms\Set $set, ?string $state) {
|
||||
$set('dns', null);
|
||||
$set('ip', null);
|
||||
|
||||
[$subdomain] = str($state)->explode('.', 2);
|
||||
if (!is_numeric($subdomain)) {
|
||||
$set('name', $subdomain);
|
||||
}
|
||||
|
||||
if (!$state || is_ip($state)) {
|
||||
$set('dns', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$validRecords = gethostbynamel($state);
|
||||
if ($validRecords) {
|
||||
$set('dns', true);
|
||||
|
||||
$set('ip', collect($validRecords)->first());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$set('dns', false);
|
||||
})
|
||||
->maxLength(191),
|
||||
|
||||
Forms\Components\TextInput::make('ip')
|
||||
->disabled()
|
||||
->hidden(),
|
||||
|
||||
Forms\Components\ToggleButtons::make('dns')
|
||||
->label('DNS Record Check')
|
||||
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
|
||||
->disabled()
|
||||
->inline()
|
||||
->default(null)
|
||||
->hint(fn (Forms\Get $get) => $get('ip'))
|
||||
->hintColor('success')
|
||||
->options([
|
||||
true => 'Valid',
|
||||
false => 'Invalid',
|
||||
])
|
||||
->colors([
|
||||
true => 'success',
|
||||
false => 'danger',
|
||||
])
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
]),
|
||||
|
||||
Forms\Components\TextInput::make('daemon_listen')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
])
|
||||
->label(trans('strings.port'))
|
||||
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
|
||||
->minValue(0)
|
||||
->maxValue(65536)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer(),
|
||||
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Display Name')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 2,
|
||||
])
|
||||
->required()
|
||||
->regex('/[a-zA-Z0-9_\.\- ]+/')
|
||||
->helperText('This name is for display only and can be changed later.')
|
||||
->maxLength(100),
|
||||
|
||||
Forms\Components\ToggleButtons::make('scheme')
|
||||
->label('Communicate over SSL')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
])
|
||||
->required()
|
||||
->inline()
|
||||
->helperText(function (Forms\Get $get) {
|
||||
if (request()->isSecure()) {
|
||||
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
|
||||
}
|
||||
|
||||
if (is_ip($get('fqdn'))) {
|
||||
return 'An IP address cannot use SSL.';
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
|
||||
->options([
|
||||
'http' => 'HTTP',
|
||||
'https' => 'HTTPS (SSL)',
|
||||
])
|
||||
->colors([
|
||||
'http' => 'warning',
|
||||
'https' => 'success',
|
||||
])
|
||||
->icons([
|
||||
'http' => 'tabler-lock-open-off',
|
||||
'https' => 'tabler-lock',
|
||||
])
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]),
|
||||
Tabs\Tab::make('Advanced Settings')
|
||||
->columns(['default' => 1, 'sm' => 1, 'md' => 4, 'lg' => 6])
|
||||
->icon('tabler-server-cog')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('id')
|
||||
->label('Node ID')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1])
|
||||
->disabled(),
|
||||
Forms\Components\TextInput::make('uuid')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
|
||||
->label('Node UUID')
|
||||
->hintAction(CopyAction::make())
|
||||
->disabled(),
|
||||
Forms\Components\TagsInput::make('tags')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
|
||||
->label('Tags')
|
||||
->disabled()
|
||||
->placeholder('Not Implemented')
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('Not Implemented'),
|
||||
Forms\Components\TextInput::make('upload_size')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1])
|
||||
->label('Upload Limit')
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.')
|
||||
->numeric()->required()
|
||||
->minValue(1)
|
||||
->maxValue(1024)
|
||||
->suffix('MiB'),
|
||||
Forms\Components\TextInput::make('daemon_sftp')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3])
|
||||
->label('SFTP Port')
|
||||
->minValue(0)
|
||||
->maxValue(65536)
|
||||
->default(2022)
|
||||
->required()
|
||||
->integer(),
|
||||
Forms\Components\TextInput::make('daemon_sftp_alias')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3])
|
||||
->label('SFTP Alias')
|
||||
->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'),
|
||||
Forms\Components\ToggleButtons::make('public')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3])
|
||||
->label('Automatic Allocation')->inline()
|
||||
->options([
|
||||
true => 'Yes',
|
||||
false => 'No',
|
||||
])
|
||||
->colors([
|
||||
true => 'success',
|
||||
false => 'danger',
|
||||
]),
|
||||
Forms\Components\ToggleButtons::make('maintenance_mode')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3])
|
||||
->label('Maintenance Mode')->inline()
|
||||
->hinticon('tabler-question-mark')
|
||||
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
|
||||
->options([
|
||||
false => 'Disable',
|
||||
true => 'Enable',
|
||||
])
|
||||
->colors([
|
||||
false => 'success',
|
||||
true => 'danger',
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6])
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_mem')
|
||||
->label('Memory')->inlineLabel()->inline()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('memory_overallocate', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0)
|
||||
->live()
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]),
|
||||
Forms\Components\TextInput::make('memory')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
|
||||
->label('Memory Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->required()
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
Forms\Components\TextInput::make('memory_overallocate')
|
||||
->dehydratedWhenHidden()
|
||||
->label('Overallocate')->inlineLabel()
|
||||
->required()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('The % allowable to go over the set limit.')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
|
||||
->numeric()
|
||||
->minValue(-1)
|
||||
->maxValue(100)
|
||||
->suffix('%'),
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6])
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_disk')
|
||||
->label('Disk')->inlineLabel()->inline()
|
||||
->live()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('disk_overallocate', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0)
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]),
|
||||
Forms\Components\TextInput::make('disk')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
|
||||
->label('Disk Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->required()
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
Forms\Components\TextInput::make('disk_overallocate')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
|
||||
->label('Overallocate')->inlineLabel()
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('The % allowable to go over the set limit.')
|
||||
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
|
||||
->required()
|
||||
->numeric()
|
||||
->minValue(-1)
|
||||
->maxValue(100)
|
||||
->suffix('%'),
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columns(6)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_cpu')
|
||||
->label('CPU')->inlineLabel()->inline()
|
||||
->live()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0))
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu_overallocate', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0)
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
Forms\Components\TextInput::make('cpu')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
|
||||
->label('CPU Limit')->inlineLabel()
|
||||
->suffix('%')
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
Forms\Components\TextInput::make('cpu_overallocate')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
|
||||
->label('Overallocate')->inlineLabel()
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('The % allowable to go over the set limit.')
|
||||
->columnSpan(2)
|
||||
->required()
|
||||
->numeric()
|
||||
->minValue(-1)
|
||||
->maxValue(100)
|
||||
->suffix('%'),
|
||||
]),
|
||||
]),
|
||||
Tabs\Tab::make('Configuration File')
|
||||
->icon('tabler-code')
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('instructions')
|
||||
->columnSpanFull()
|
||||
->content(new HtmlString('
|
||||
Save this file to your <span title="usually /etc/pelican/">daemon\'s root directory</span>, named <code>config.yml</code>
|
||||
')),
|
||||
Forms\Components\Textarea::make('config')
|
||||
->label('/etc/pelican/config.yml')
|
||||
->disabled()
|
||||
->rows(19)
|
||||
->hintAction(CopyAction::make())
|
||||
->columnSpanFull(),
|
||||
Forms\Components\Actions::make([
|
||||
Forms\Components\Actions\Action::make('resetKey')
|
||||
->label('Reset Daemon Token')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Reset Daemon Token?')
|
||||
->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.')
|
||||
->action(fn (NodeUpdateService $nodeUpdateService, Node $node) => $nodeUpdateService->handle($node, [], true)
|
||||
&& Notification::make()->success()->title('Daemon Key Reset')->send()
|
||||
&& $this->fillForm()
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
$node = Node::findOrFail($data['id']);
|
||||
|
||||
$data['config'] = $node->getYamlConfiguration();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->disabled(fn (Node $node) => $node->servers()->count() > 0)
|
||||
->label(fn (Node $node) => $node->servers()->count() > 0 ? 'Node Has Servers' : 'Delete'),
|
||||
$this->getSaveFormAction()->formId('form'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFooterWidgets(): array
|
||||
{
|
||||
return [
|
||||
NodeStorageChart::class,
|
||||
NodeMemoryChart::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$this->fillForm();
|
||||
}
|
||||
}
|
||||
107
app/Filament/Resources/NodeResource/Pages/ListNodes.php
Normal file
107
app/Filament/Resources/NodeResource/Pages/ListNodes.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NodeResource\Pages;
|
||||
|
||||
use App\Filament\Resources\NodeResource;
|
||||
use App\Models\Node;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Actions\CreateAction;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables;
|
||||
|
||||
class ListNodes extends ListRecords
|
||||
{
|
||||
protected static string $resource = NodeResource::class;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->checkIfRecordIsSelectableUsing(fn (Node $node) => $node->servers_count <= 0)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('uuid')
|
||||
->label('UUID')
|
||||
->searchable()
|
||||
->hidden(),
|
||||
Tables\Columns\IconColumn::make('health')
|
||||
->alignCenter()
|
||||
->state(fn (Node $node) => $node)
|
||||
->view('livewire.columns.version-column'),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->icon('tabler-server-2')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('fqdn')
|
||||
->visibleFrom('md')
|
||||
->label('Address')
|
||||
->icon('tabler-network')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('memory')
|
||||
->visibleFrom('sm')
|
||||
->icon('tabler-device-desktop-analytics')
|
||||
->numeric()
|
||||
->suffix(' GiB')
|
||||
->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('disk')
|
||||
->visibleFrom('sm')
|
||||
->icon('tabler-file')
|
||||
->numeric()
|
||||
->suffix(' GiB')
|
||||
->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('cpu')
|
||||
->visibleFrom('sm')
|
||||
->icon('tabler-file')
|
||||
->numeric()
|
||||
->suffix(' %')
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('scheme')
|
||||
->visibleFrom('xl')
|
||||
->label('SSL')
|
||||
->trueIcon('tabler-lock')
|
||||
->falseIcon('tabler-lock-open-off')
|
||||
->state(fn (Node $node) => $node->scheme === 'https'),
|
||||
Tables\Columns\IconColumn::make('public')
|
||||
->visibleFrom('lg')
|
||||
->trueIcon('tabler-eye-check')
|
||||
->falseIcon('tabler-eye-cancel'),
|
||||
Tables\Columns\TextColumn::make('servers_count')
|
||||
->visibleFrom('sm')
|
||||
->counts('servers')
|
||||
->label('Servers')
|
||||
->sortable()
|
||||
->icon('tabler-brand-docker'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->emptyStateIcon('tabler-server-2')
|
||||
->emptyStateDescription('')
|
||||
->emptyStateHeading('No Nodes')
|
||||
->emptyStateActions([
|
||||
CreateAction::make('create')
|
||||
->label('Create Node')
|
||||
->button(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('Create Node')
|
||||
->hidden(fn () => Node::count() <= 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NodeResource\RelationManagers;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Server;
|
||||
use App\Services\Allocations\AssignmentService;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class AllocationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'allocations';
|
||||
|
||||
protected static ?string $icon = 'tabler-plug-connected';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('ip')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('ip')
|
||||
|
||||
// Non Primary Allocations
|
||||
// ->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->id !== $allocation->server?->allocation_id)
|
||||
|
||||
// All assigned allocations
|
||||
->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null)
|
||||
->searchable()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id'),
|
||||
Tables\Columns\TextColumn::make('port')
|
||||
->searchable()
|
||||
->label('Port'),
|
||||
Tables\Columns\TextColumn::make('server.name')
|
||||
->label('Server')
|
||||
->icon('tabler-brand-docker')
|
||||
->searchable()
|
||||
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
|
||||
Tables\Columns\TextInputColumn::make('ip_alias')
|
||||
->searchable()
|
||||
->label('Alias'),
|
||||
Tables\Columns\TextInputColumn::make('ip')
|
||||
->searchable()
|
||||
->label('IP'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\Action::make('create new allocation')->label('Create Allocations')
|
||||
->form(fn () => [
|
||||
Forms\Components\TextInput::make('allocation_ip')
|
||||
->datalist($this->getOwnerRecord()->ipAddresses() ?? [])
|
||||
->label('IP Address')
|
||||
->inlineLabel()
|
||||
->ipv4()
|
||||
->helperText("Usually your machine's public IP unless you are port forwarding.")
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('allocation_alias')
|
||||
->label('Alias')
|
||||
->inlineLabel()
|
||||
->default(null)
|
||||
->helperText('Optional display name to help you remember what these are.')
|
||||
->required(false),
|
||||
Forms\Components\TagsInput::make('allocation_ports')
|
||||
->placeholder('Examples: 27015, 27017-27019')
|
||||
->helperText(new HtmlString('
|
||||
These are the ports that users can connect to this Server through.
|
||||
<br />
|
||||
You would have to port forward these on your home network.
|
||||
'))
|
||||
->label('Ports')
|
||||
->inlineLabel()
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, Forms\Set $set) {
|
||||
$ports = collect();
|
||||
$update = false;
|
||||
foreach ($state as $portEntry) {
|
||||
if (!str_contains($portEntry, '-')) {
|
||||
if (is_numeric($portEntry)) {
|
||||
$ports->push((int) $portEntry);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Do not add non numerical ports
|
||||
$update = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$update = true;
|
||||
[$start, $end] = explode('-', $portEntry);
|
||||
if (!is_numeric($start) || !is_numeric($end)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = max((int) $start, 0);
|
||||
$end = min((int) $end, 2 ** 16 - 1);
|
||||
foreach (range($start, $end) as $i) {
|
||||
$ports->push($i);
|
||||
}
|
||||
}
|
||||
|
||||
$uniquePorts = $ports->unique()->values();
|
||||
if ($ports->count() > $uniquePorts->count()) {
|
||||
$update = true;
|
||||
$ports = $uniquePorts;
|
||||
}
|
||||
|
||||
$sortedPorts = $ports->sort()->values();
|
||||
if ($sortedPorts->all() !== $ports->all()) {
|
||||
$update = true;
|
||||
$ports = $sortedPorts;
|
||||
}
|
||||
|
||||
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
|
||||
|
||||
if ($update) {
|
||||
$set('allocation_ports', $ports->all());
|
||||
}
|
||||
})
|
||||
->splitKeys(['Tab', ' ', ','])
|
||||
->required(),
|
||||
])
|
||||
->action(fn (array $data) => resolve(AssignmentService::class)->handle($this->getOwnerRecord(), $data)),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
// Tables\Actions\DissociateBulkAction::make(),
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NodeResource\RelationManagers;
|
||||
|
||||
use App\Models\Server;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
|
||||
class NodesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'servers';
|
||||
|
||||
protected static ?string $icon = 'tabler-brand-docker';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.username')
|
||||
->label('Owner')
|
||||
->icon('tabler-user')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->icon('tabler-brand-docker')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('egg.name')
|
||||
->icon('tabler-egg')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->user]))
|
||||
->sortable(),
|
||||
Tables\Columns\SelectColumn::make('allocation.id')
|
||||
->label('Primary Allocation')
|
||||
->options(fn ($state, Server $server) => [$server->allocation->id => $server->allocation->address])
|
||||
->selectablePlaceholder(false)
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('memory')->icon('tabler-device-desktop-analytics'),
|
||||
Tables\Columns\TextColumn::make('cpu')->icon('tabler-cpu'),
|
||||
Tables\Columns\TextColumn::make('databases_count')
|
||||
->counts('databases')
|
||||
->label('Databases')
|
||||
->icon('tabler-database')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('backups_count')
|
||||
->counts('backups')
|
||||
->label('Backups')
|
||||
->icon('tabler-file-download')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NodeResource\Widgets;
|
||||
|
||||
use App\Models\Node;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NodeMemoryChart extends ChartWidget
|
||||
{
|
||||
protected static ?string $heading = 'Memory';
|
||||
|
||||
protected static ?string $pollingInterval = '60s';
|
||||
|
||||
public ?Model $record = null;
|
||||
|
||||
protected static ?array $options = [
|
||||
'scales' => [
|
||||
'x' => [
|
||||
'grid' => [
|
||||
'display' => false,
|
||||
],
|
||||
'ticks' => [
|
||||
'display' => false,
|
||||
],
|
||||
],
|
||||
'y' => [
|
||||
'grid' => [
|
||||
'display' => false,
|
||||
],
|
||||
'ticks' => [
|
||||
'display' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
/** @var Node $node */
|
||||
$node = $this->record;
|
||||
|
||||
$total = ($node->statistics()['memory_total'] ?? 0) / 1024 / 1024 / 1024;
|
||||
$used = ($node->statistics()['memory_used'] ?? 0) / 1024 / 1024 / 1024;
|
||||
$unused = $total - $used;
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Data Cool',
|
||||
'data' => [$used, $unused],
|
||||
'backgroundColor' => [
|
||||
'rgb(255, 99, 132)',
|
||||
'rgb(54, 162, 235)',
|
||||
'rgb(255, 205, 86)',
|
||||
],
|
||||
],
|
||||
// 'backgroundColor' => [],
|
||||
],
|
||||
'labels' => ['Used', 'Unused'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'pie';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NodeResource\Widgets;
|
||||
|
||||
use App\Models\Node;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NodeStorageChart extends ChartWidget
|
||||
{
|
||||
protected static ?string $heading = 'Storage';
|
||||
|
||||
protected static ?string $pollingInterval = '60s';
|
||||
|
||||
public ?Model $record = null;
|
||||
|
||||
protected static ?array $options = [
|
||||
'scales' => [
|
||||
'x' => [
|
||||
'grid' => [
|
||||
'display' => false,
|
||||
],
|
||||
'ticks' => [
|
||||
'display' => false,
|
||||
],
|
||||
],
|
||||
'y' => [
|
||||
'grid' => [
|
||||
'display' => false,
|
||||
],
|
||||
'ticks' => [
|
||||
'display' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
/** @var Node $node */
|
||||
$node = $this->record;
|
||||
|
||||
$total = ($node->statistics()['disk_total'] ?? 0) / 1024 / 1024 / 1024;
|
||||
$used = ($node->statistics()['disk_used'] ?? 0) / 1024 / 1024 / 1024;
|
||||
$unused = $total - $used;
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Data Cool',
|
||||
'data' => [$used, $unused],
|
||||
'backgroundColor' => [
|
||||
'rgb(255, 99, 132)',
|
||||
'rgb(54, 162, 235)',
|
||||
'rgb(255, 205, 86)',
|
||||
],
|
||||
],
|
||||
// 'backgroundColor' => [],
|
||||
],
|
||||
'labels' => ['Used', 'Unused'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'pie';
|
||||
}
|
||||
}
|
||||
37
app/Filament/Resources/ServerResource.php
Normal file
37
app/Filament/Resources/ServerResource.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ServerResource\Pages;
|
||||
use App\Models\Server;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class ServerResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Server::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'tabler-brand-docker';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count() ?: null;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListServers::route('/'),
|
||||
'create' => Pages\CreateServer::route('/create'),
|
||||
'edit' => Pages\EditServer::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
751
app/Filament/Resources/ServerResource/Pages/CreateServer.php
Normal file
751
app/Filament/Resources/ServerResource/Pages/CreateServer.php
Normal file
@@ -0,0 +1,751 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ServerResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ServerResource;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Egg;
|
||||
use App\Models\Node;
|
||||
use App\Services\Allocations\AssignmentService;
|
||||
use App\Services\Servers\RandomWordService;
|
||||
use App\Services\Servers\ServerCreationService;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Closure;
|
||||
use Filament\Forms;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CreateServer extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ServerResource::class;
|
||||
protected static bool $canCreateAnother = false;
|
||||
|
||||
public ?Node $node = null;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('external_id')
|
||||
->maxLength(191)
|
||||
->hidden(),
|
||||
|
||||
Forms\Components\TextInput::make('name')
|
||||
->prefixIcon('tabler-server')
|
||||
->label('Display Name')
|
||||
->suffixAction(Forms\Components\Actions\Action::make('random')
|
||||
->icon('tabler-dice-' . random_int(1, 6))
|
||||
->action(function (Forms\Set $set, Forms\Get $get) {
|
||||
$egg = Egg::find($get('egg_id'));
|
||||
$prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : '';
|
||||
|
||||
$word = (new RandomWordService())->word();
|
||||
|
||||
$set('name', $prefix . $word);
|
||||
}))
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->required()
|
||||
->maxLength(191),
|
||||
|
||||
Forms\Components\Select::make('owner_id')
|
||||
->prefixIcon('tabler-user')
|
||||
->default(auth()->user()->id)
|
||||
->label('Owner')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->relationship('user', 'username')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
|
||||
Forms\Components\Select::make('node_id')
|
||||
->disabledOn('edit')
|
||||
->prefixIcon('tabler-server-2')
|
||||
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
|
||||
->columnSpan(2)
|
||||
->live()
|
||||
->relationship('node', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->afterStateUpdated(function (Forms\Set $set, $state) {
|
||||
$set('allocation_id', null);
|
||||
$this->node = Node::find($state);
|
||||
})
|
||||
->required(),
|
||||
|
||||
Forms\Components\Select::make('allocation_id')
|
||||
->preload()
|
||||
->live()
|
||||
->prefixIcon('tabler-network')
|
||||
->label('Primary Allocation')
|
||||
->columnSpan(2)
|
||||
->disabled(fn (Forms\Get $get) => $get('node_id') === null)
|
||||
->searchable(['ip', 'port', 'ip_alias'])
|
||||
->afterStateUpdated(function (Forms\Set $set) {
|
||||
$set('allocation_additional', null);
|
||||
$set('allocation_additional.needstobeastringhere.extra_allocations', null);
|
||||
})
|
||||
->getOptionLabelFromRecordUsing(
|
||||
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
|
||||
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
|
||||
)
|
||||
->placeholder(function (Forms\Get $get) {
|
||||
$node = Node::find($get('node_id'));
|
||||
|
||||
if ($node?->allocations) {
|
||||
return 'Select an Allocation';
|
||||
}
|
||||
|
||||
return 'Create a New Allocation';
|
||||
})
|
||||
->relationship(
|
||||
'allocation',
|
||||
'ip',
|
||||
fn (Builder $query, Forms\Get $get) => $query
|
||||
->where('node_id', $get('node_id'))
|
||||
->whereNull('server_id'),
|
||||
)
|
||||
->createOptionForm(fn (Forms\Get $get) => [
|
||||
Forms\Components\TextInput::make('allocation_ip')
|
||||
->datalist(Node::find($get('node_id'))?->ipAddresses() ?? [])
|
||||
->label('IP Address')
|
||||
->inlineLabel()
|
||||
->ipv4()
|
||||
->helperText("Usually your machine's public IP unless you are port forwarding.")
|
||||
// ->selectablePlaceholder(false)
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('allocation_alias')
|
||||
->label('Alias')
|
||||
->inlineLabel()
|
||||
->default(null)
|
||||
->datalist([
|
||||
$get('name'),
|
||||
Egg::find($get('egg_id'))?->name,
|
||||
])
|
||||
->helperText('Optional display name to help you remember what these are.')
|
||||
->required(false),
|
||||
Forms\Components\TagsInput::make('allocation_ports')
|
||||
->placeholder('Examples: 27015, 27017-27019')
|
||||
->helperText(new HtmlString('
|
||||
These are the ports that users can connect to this Server through.
|
||||
<br />
|
||||
You would have to port forward these on your home network.
|
||||
'))
|
||||
->label('Ports')
|
||||
->inlineLabel()
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, Forms\Set $set) {
|
||||
$ports = collect();
|
||||
$update = false;
|
||||
foreach ($state as $portEntry) {
|
||||
if (!str_contains($portEntry, '-')) {
|
||||
if (is_numeric($portEntry)) {
|
||||
$ports->push((int) $portEntry);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Do not add non-numerical ports
|
||||
$update = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$update = true;
|
||||
[$start, $end] = explode('-', $portEntry);
|
||||
if (!is_numeric($start) || !is_numeric($end)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = max((int) $start, 0);
|
||||
$end = min((int) $end, 2 ** 16 - 1);
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
$ports->push($i);
|
||||
}
|
||||
}
|
||||
|
||||
$uniquePorts = $ports->unique()->values();
|
||||
if ($ports->count() > $uniquePorts->count()) {
|
||||
$update = true;
|
||||
$ports = $uniquePorts;
|
||||
}
|
||||
|
||||
$sortedPorts = $ports->sort()->values();
|
||||
if ($sortedPorts->all() !== $ports->all()) {
|
||||
$update = true;
|
||||
$ports = $sortedPorts;
|
||||
}
|
||||
|
||||
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
|
||||
|
||||
if ($update) {
|
||||
$set('allocation_ports', $ports->all());
|
||||
}
|
||||
})
|
||||
->splitKeys(['Tab', ' ', ','])
|
||||
->required(),
|
||||
])
|
||||
->createOptionUsing(function (array $data, Forms\Get $get): int {
|
||||
return collect(
|
||||
resolve(AssignmentService::class)->handle(Node::find($get('node_id')), $data)
|
||||
)->first();
|
||||
})
|
||||
->required(),
|
||||
|
||||
Forms\Components\Repeater::make('allocation_additional')
|
||||
->label('Additional Allocations')
|
||||
->columnSpan(2)
|
||||
->addActionLabel('Add Allocation')
|
||||
->disabled(fn (Forms\Get $get) => $get('allocation_id') === null)
|
||||
// ->addable() TODO disable when all allocations are taken
|
||||
// ->addable() TODO disable until first additional allocation is selected
|
||||
->simple(
|
||||
Forms\Components\Select::make('extra_allocations')
|
||||
->live()
|
||||
->preload()
|
||||
->disableOptionsWhenSelectedInSiblingRepeaterItems()
|
||||
->prefixIcon('tabler-network')
|
||||
->label('Additional Allocations')
|
||||
->columnSpan(2)
|
||||
->disabled(fn (Forms\Get $get) => $get('../../node_id') === null)
|
||||
->searchable(['ip', 'port', 'ip_alias'])
|
||||
->getOptionLabelFromRecordUsing(
|
||||
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
|
||||
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
|
||||
)
|
||||
->placeholder('Select additional Allocations')
|
||||
->disableOptionsWhenSelectedInSiblingRepeaterItems()
|
||||
->relationship(
|
||||
'allocations',
|
||||
'ip',
|
||||
fn (Builder $query, Forms\Get $get, Forms\Components\Select $component, $state) => $query
|
||||
->where('node_id', $get('../../node_id'))
|
||||
->whereNot('id', $get('../../allocation_id'))
|
||||
->whereNull('server_id'),
|
||||
),
|
||||
),
|
||||
|
||||
Forms\Components\Textarea::make('description')
|
||||
->hidden()
|
||||
->default('')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
Forms\Components\Select::make('egg_id')
|
||||
->disabledOn('edit')
|
||||
->prefixIcon('tabler-egg')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 5,
|
||||
])
|
||||
->relationship('egg', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, Forms\Set $set, Forms\Get $get, $old) {
|
||||
$egg = Egg::query()->find($state);
|
||||
$set('startup', $egg->startup);
|
||||
$set('image', '');
|
||||
|
||||
$variables = $egg->variables ?? [];
|
||||
$serverVariables = collect();
|
||||
foreach ($variables as $variable) {
|
||||
$serverVariables->add($variable->toArray());
|
||||
}
|
||||
|
||||
$variables = [];
|
||||
$set($path = 'server_variables', $serverVariables->sortBy(['sort'])->all());
|
||||
for ($i = 0; $i < $serverVariables->count(); $i++) {
|
||||
$set("$path.$i.variable_value", $serverVariables[$i]['default_value']);
|
||||
$set("$path.$i.variable_id", $serverVariables[$i]['id']);
|
||||
$variables[$serverVariables[$i]['env_variable']] = $serverVariables[$i]['default_value'];
|
||||
}
|
||||
|
||||
$set('environment', $variables);
|
||||
|
||||
$previousEgg = Egg::query()->find($old);
|
||||
if (!$get('name') || $previousEgg?->getKebabName() === $get('name')) {
|
||||
$set('name', $egg->getKebabName());
|
||||
}
|
||||
})
|
||||
->required(),
|
||||
|
||||
Forms\Components\ToggleButtons::make('skip_scripts')
|
||||
->label('Run Egg Install Script?')
|
||||
->default(false)
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
])
|
||||
->options([
|
||||
false => 'Yes',
|
||||
true => 'Skip',
|
||||
])
|
||||
->colors([
|
||||
false => 'primary',
|
||||
true => 'danger',
|
||||
])
|
||||
->icons([
|
||||
false => 'tabler-code',
|
||||
true => 'tabler-code-off',
|
||||
])
|
||||
->inline()
|
||||
->required(),
|
||||
|
||||
Forms\Components\Textarea::make('startup')
|
||||
->hintIcon('tabler-code')
|
||||
->label('Startup Command')
|
||||
->required()
|
||||
->live()
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->rows(function ($state) {
|
||||
return str($state)->explode("\n")->reduce(
|
||||
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
|
||||
0
|
||||
);
|
||||
}),
|
||||
|
||||
Forms\Components\Hidden::make('environment')->default([]),
|
||||
|
||||
Forms\Components\Hidden::make('start_on_completion')->default(true),
|
||||
|
||||
Forms\Components\Section::make('Egg Variables')
|
||||
->icon('tabler-eggs')
|
||||
->iconColor('primary')
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->columnSpan(([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
]))
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('Select an egg first to show its variables!')
|
||||
->hidden(fn (Forms\Get $get) => $get('egg_id')),
|
||||
|
||||
Forms\Components\Placeholder::make('The selected egg has no variables!')
|
||||
->hidden(fn (Forms\Get $get) => !$get('egg_id') ||
|
||||
Egg::query()->find($get('egg_id'))?->variables()?->count()
|
||||
),
|
||||
|
||||
Forms\Components\Repeater::make('server_variables')
|
||||
->relationship('serverVariables')
|
||||
->saveRelationshipsBeforeChildrenUsing(null)
|
||||
->saveRelationshipsUsing(null)
|
||||
->grid(2)
|
||||
->reorderable(false)
|
||||
->addable(false)
|
||||
->deletable(false)
|
||||
->default([])
|
||||
->hidden(fn ($state) => empty($state))
|
||||
->schema(function () {
|
||||
|
||||
$text = Forms\Components\TextInput::make('variable_value')
|
||||
->hidden($this->shouldHideComponent(...))
|
||||
->maxLength(191)
|
||||
->required(fn (Forms\Get $get) => in_array('required', explode('|', $get('rules'))))
|
||||
->rules(
|
||||
fn (Forms\Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) {
|
||||
$validator = Validator::make(['validatorkey' => $value], [
|
||||
'validatorkey' => $get('rules'),
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$message = str($validator->errors()->first())->replace('validatorkey', $get('name'))->toString();
|
||||
|
||||
$fail($message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$select = Forms\Components\Select::make('variable_value')
|
||||
->hidden($this->shouldHideComponent(...))
|
||||
->options($this->getSelectOptionsFromRules(...))
|
||||
->selectablePlaceholder(false);
|
||||
|
||||
$components = [$text, $select];
|
||||
|
||||
/** @var Forms\Components\Component $component */
|
||||
foreach ($components as &$component) {
|
||||
$component = $component
|
||||
->live(onBlur: true)
|
||||
->hintIcon('tabler-code')
|
||||
->label(fn (Forms\Get $get) => $get('name'))
|
||||
->hintIconTooltip(fn (Forms\Get $get) => $get('rules'))
|
||||
->prefix(fn (Forms\Get $get) => '{{' . $get('env_variable') . '}}')
|
||||
->helperText(fn (Forms\Get $get) => empty($get('description')) ? '—' : $get('description'))
|
||||
->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
|
||||
$environment = $get($envPath = '../../environment');
|
||||
$environment[$get('env_variable')] = $state;
|
||||
$set($envPath, $environment);
|
||||
});
|
||||
}
|
||||
|
||||
return $components;
|
||||
})
|
||||
->columnSpan(2),
|
||||
]),
|
||||
|
||||
Forms\Components\Section::make('Environment Management')
|
||||
->collapsed()
|
||||
->icon('tabler-server-cog')
|
||||
->iconColor('primary')
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 4,
|
||||
])
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\Fieldset::make('Resource Limits')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 3,
|
||||
'lg' => 3,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_mem')
|
||||
->label('Memory')->inlineLabel()->inline()
|
||||
->default(true)
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
|
||||
->live()
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
|
||||
Forms\Components\TextInput::make('memory')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
|
||||
->label('Memory Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->default(0)
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
]),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_disk')
|
||||
->label('Disk Space')->inlineLabel()->inline()
|
||||
->default(true)
|
||||
->live()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
|
||||
Forms\Components\TextInput::make('disk')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
|
||||
->label('Disk Space Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->default(0)
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
]),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_cpu')
|
||||
->label('CPU')->inlineLabel()->inline()
|
||||
->default(true)
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0))
|
||||
->live()
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
|
||||
Forms\Components\TextInput::make('cpu')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
|
||||
->label('CPU Limit')->inlineLabel()
|
||||
->suffix('%')
|
||||
->default(0)
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText('100% equals one CPU core.'),
|
||||
]),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('swap_support')
|
||||
->live()
|
||||
->label('Enable Swap Memory')
|
||||
->inlineLabel()
|
||||
->inline()
|
||||
->columnSpan(2)
|
||||
->default('disabled')
|
||||
->afterStateUpdated(function ($state, Forms\Set $set) {
|
||||
$value = match ($state) {
|
||||
'unlimited' => -1,
|
||||
'disabled' => 0,
|
||||
'limited' => 128,
|
||||
};
|
||||
|
||||
$set('swap', $value);
|
||||
})
|
||||
->options([
|
||||
'unlimited' => 'Unlimited',
|
||||
'limited' => 'Limited',
|
||||
'disabled' => 'Disabled',
|
||||
])
|
||||
->colors([
|
||||
'unlimited' => 'primary',
|
||||
'limited' => 'warning',
|
||||
'disabled' => 'danger',
|
||||
]),
|
||||
|
||||
Forms\Components\TextInput::make('swap')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) {
|
||||
'disabled', 'unlimited' => true,
|
||||
'limited' => false,
|
||||
})
|
||||
->label('Swap Memory')
|
||||
->default(0)
|
||||
->suffix('MiB')
|
||||
->minValue(-1)
|
||||
->columnSpan(2)
|
||||
->inlineLabel()
|
||||
->required()
|
||||
->integer(),
|
||||
]),
|
||||
|
||||
Forms\Components\Hidden::make('io')
|
||||
->helperText('The IO performance relative to other running containers')
|
||||
->label('Block IO Proportion')
|
||||
->default(500),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('oom_killer')
|
||||
->label('OOM Killer')
|
||||
->inlineLabel()->inline()
|
||||
->default(false)
|
||||
->columnSpan(2)
|
||||
->options([
|
||||
false => 'Disabled',
|
||||
true => 'Enabled',
|
||||
])
|
||||
->colors([
|
||||
false => 'success',
|
||||
true => 'danger',
|
||||
]),
|
||||
|
||||
Forms\Components\TextInput::make('oom_disabled_hidden')
|
||||
->hidden(),
|
||||
]),
|
||||
]),
|
||||
|
||||
Forms\Components\Fieldset::make('Feature Limits')
|
||||
->inlineLabel()
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 3,
|
||||
'lg' => 3,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('allocation_limit')
|
||||
->suffixIcon('tabler-network')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
Forms\Components\TextInput::make('database_limit')
|
||||
->suffixIcon('tabler-database')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
Forms\Components\TextInput::make('backup_limit')
|
||||
->suffixIcon('tabler-copy-check')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0),
|
||||
]),
|
||||
Forms\Components\Fieldset::make('Docker Settings')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 3,
|
||||
'lg' => 3,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\Select::make('select_image')
|
||||
->label('Image Name')
|
||||
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state))
|
||||
->options(function ($state, Forms\Get $get, Forms\Set $set) {
|
||||
$egg = Egg::query()->find($get('egg_id'));
|
||||
$images = $egg->docker_images ?? [];
|
||||
|
||||
$currentImage = $get('image');
|
||||
if (!$currentImage && $images) {
|
||||
$defaultImage = collect($images)->first();
|
||||
$set('image', $defaultImage);
|
||||
$set('select_image', $defaultImage);
|
||||
}
|
||||
|
||||
return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image'];
|
||||
})
|
||||
->selectablePlaceholder(false)
|
||||
->columnSpan(1),
|
||||
|
||||
Forms\Components\TextInput::make('image')
|
||||
->label('Image')
|
||||
->debounce(500)
|
||||
->afterStateUpdated(function ($state, Forms\Get $get, Forms\Set $set) {
|
||||
$egg = Egg::query()->find($get('egg_id'));
|
||||
$images = $egg->docker_images ?? [];
|
||||
|
||||
if (in_array($state, $images)) {
|
||||
$set('select_image', $state);
|
||||
} else {
|
||||
$set('select_image', 'ghcr.io/custom-image');
|
||||
}
|
||||
})
|
||||
->placeholder('Enter a custom Image')
|
||||
->columnSpan(1),
|
||||
|
||||
Forms\Components\KeyValue::make('docker_labels')
|
||||
->label('Container Labels')
|
||||
->keyLabel('Title')
|
||||
->valueLabel('Description')
|
||||
->columnSpan(3),
|
||||
|
||||
Forms\Components\CheckboxList::make('mounts')
|
||||
->live()
|
||||
->relationship('mounts')
|
||||
->options(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]) ?? [])
|
||||
->descriptions(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]) ?? [])
|
||||
->label('Mounts')
|
||||
->helperText(fn () => $this->node?->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
$data['allocation_additional'] = collect($data['allocation_additional'])->filter()->all();
|
||||
|
||||
/** @var ServerCreationService $service */
|
||||
$service = resolve(ServerCreationService::class);
|
||||
|
||||
return $service->handle($data);
|
||||
}
|
||||
|
||||
private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool
|
||||
{
|
||||
$containsRuleIn = str($get('rules'))->explode('|')->reduce(
|
||||
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
|
||||
);
|
||||
|
||||
if ($component instanceof Forms\Components\Select) {
|
||||
return $containsRuleIn;
|
||||
}
|
||||
|
||||
if ($component instanceof Forms\Components\TextInput) {
|
||||
return !$containsRuleIn;
|
||||
}
|
||||
|
||||
throw new \Exception('Component type not supported: ' . $component::class);
|
||||
}
|
||||
|
||||
private function getSelectOptionsFromRules(Forms\Get $get): array
|
||||
{
|
||||
$inRule = str($get('rules'))->explode('|')->reduce(
|
||||
fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, ''
|
||||
);
|
||||
|
||||
return str($inRule)
|
||||
->after('in:')
|
||||
->explode(',')
|
||||
->each(fn ($value) => str($value)->trim())
|
||||
->mapWithKeys(fn ($value) => [$value => $value])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
785
app/Filament/Resources/ServerResource/Pages/EditServer.php
Normal file
785
app/Filament/Resources/ServerResource/Pages/EditServer.php
Normal file
@@ -0,0 +1,785 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ServerResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ServerResource;
|
||||
use App\Http\Controllers\Admin\ServersController;
|
||||
use App\Services\Servers\RandomWordService;
|
||||
use App\Services\Servers\SuspensionService;
|
||||
use App\Services\Servers\TransferServerService;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use App\Enums\ContainerStatus;
|
||||
use App\Enums\ServerState;
|
||||
use App\Models\Egg;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerVariable;
|
||||
use App\Repositories\Daemon\DaemonServerRepository;
|
||||
use App\Services\Servers\ServerDeletionService;
|
||||
use Filament\Forms\Components\Tabs;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Closure;
|
||||
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
|
||||
|
||||
class EditServer extends EditRecord
|
||||
{
|
||||
protected static string $resource = ServerResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 4,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('docker')
|
||||
->label('Container Status')->inline()->inlineLabel()
|
||||
->formatStateUsing(function ($state, Server $server) {
|
||||
if ($server->node_id === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** @var DaemonServerRepository $service */
|
||||
$service = resolve(DaemonServerRepository::class);
|
||||
$details = $service->setServer($server)->getDetails();
|
||||
|
||||
return $details['state'] ?? 'unknown';
|
||||
})
|
||||
->options(fn ($state) => collect(ContainerStatus::cases())->filter(fn ($containerStatus) => $containerStatus->value === $state)->mapWithKeys(
|
||||
fn (ContainerStatus $state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()]
|
||||
))
|
||||
->colors(collect(ContainerStatus::cases())->mapWithKeys(
|
||||
fn (ContainerStatus $status) => [$status->value => $status->color()]
|
||||
))
|
||||
->icons(collect(ContainerStatus::cases())->mapWithKeys(
|
||||
fn (ContainerStatus $status) => [$status->value => $status->icon()]
|
||||
))
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 2,
|
||||
]),
|
||||
|
||||
Forms\Components\ToggleButtons::make('status')
|
||||
->label('Server State')->inline()->inlineLabel()
|
||||
->helperText('')
|
||||
->formatStateUsing(fn ($state) => $state ?? ServerState::Normal)
|
||||
->options(fn ($state) => collect(ServerState::cases())->filter(fn ($serverState) => $serverState->value === $state)->mapWithKeys(
|
||||
fn (ServerState $state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()]
|
||||
))
|
||||
->colors(collect(ServerState::cases())->mapWithKeys(
|
||||
fn (ServerState $state) => [$state->value => $state->color()]
|
||||
))
|
||||
->icons(collect(ServerState::cases())->mapWithKeys(
|
||||
fn (ServerState $state) => [$state->value => $state->icon()]
|
||||
))
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 2,
|
||||
]),
|
||||
|
||||
Tabs::make('Tabs')
|
||||
->persistTabInQueryString()
|
||||
->columnSpan(6)
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->tabs([
|
||||
Tabs\Tab::make('Information')
|
||||
->icon('tabler-info-circle')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->prefixIcon('tabler-server')
|
||||
->label('Display Name')
|
||||
->suffixAction(Forms\Components\Actions\Action::make('random')
|
||||
->icon('tabler-dice-' . random_int(1, 6))
|
||||
->action(function (Forms\Set $set, Forms\Get $get) {
|
||||
$egg = Egg::find($get('egg_id'));
|
||||
$prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : '';
|
||||
|
||||
$word = (new RandomWordService())->word();
|
||||
|
||||
$set('name', $prefix . $word);
|
||||
}))
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->required()
|
||||
->maxLength(191),
|
||||
|
||||
Forms\Components\Select::make('owner_id')
|
||||
->prefixIcon('tabler-user')
|
||||
->label('Owner')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->relationship('user', 'username')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('Description')
|
||||
->columnSpanFull(),
|
||||
|
||||
Forms\Components\TextInput::make('uuid')
|
||||
->hintAction(CopyAction::make())
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->readOnly(),
|
||||
Forms\Components\TextInput::make('uuid_short')
|
||||
->label('Short UUID')
|
||||
->hintAction(CopyAction::make())
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->readOnly(),
|
||||
Forms\Components\TextInput::make('external_id')
|
||||
->label('External ID')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->maxLength(191),
|
||||
Forms\Components\Select::make('node_id')
|
||||
->label('Node')
|
||||
->relationship('node', 'name')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 3,
|
||||
])
|
||||
->disabled(),
|
||||
]),
|
||||
Tabs\Tab::make('Environment')
|
||||
->icon('tabler-brand-docker')
|
||||
->schema([
|
||||
Forms\Components\Fieldset::make('Resource Limits')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 3,
|
||||
'lg' => 3,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_mem')
|
||||
->label('Memory')->inlineLabel()->inline()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0)
|
||||
->live()
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
|
||||
Forms\Components\TextInput::make('memory')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
|
||||
->label('Memory Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
]),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_disk')
|
||||
->label('Disk Space')->inlineLabel()->inline()
|
||||
->live()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0)
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
|
||||
Forms\Components\TextInput::make('disk')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
|
||||
->label('Disk Space Limit')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
]),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('unlimited_cpu')
|
||||
->label('CPU')->inlineLabel()->inline()
|
||||
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0))
|
||||
->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0)
|
||||
->live()
|
||||
->options([
|
||||
true => 'Unlimited',
|
||||
false => 'Limited',
|
||||
])
|
||||
->colors([
|
||||
true => 'primary',
|
||||
false => 'warning',
|
||||
])
|
||||
->columnSpan(2),
|
||||
|
||||
Forms\Components\TextInput::make('cpu')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu'))
|
||||
->label('CPU Limit')->inlineLabel()
|
||||
->suffix('%')
|
||||
->required()
|
||||
->columnSpan(2)
|
||||
->numeric()
|
||||
->minValue(0),
|
||||
]),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('swap_support')
|
||||
->live()
|
||||
->label('Enable Swap Memory')->inlineLabel()->inline()
|
||||
->columnSpan(2)
|
||||
->afterStateUpdated(function ($state, Forms\Set $set) {
|
||||
$value = match ($state) {
|
||||
'unlimited' => -1,
|
||||
'disabled' => 0,
|
||||
'limited' => 128,
|
||||
};
|
||||
|
||||
$set('swap', $value);
|
||||
})
|
||||
->formatStateUsing(function (Forms\Get $get) {
|
||||
return match (true) {
|
||||
$get('swap') > 0 => 'limited',
|
||||
$get('swap') == 0 => 'disabled',
|
||||
$get('swap') < 0 => 'unlimited',
|
||||
};
|
||||
})
|
||||
->options([
|
||||
'unlimited' => 'Unlimited',
|
||||
'limited' => 'Limited',
|
||||
'disabled' => 'Disabled',
|
||||
])
|
||||
->colors([
|
||||
'unlimited' => 'primary',
|
||||
'limited' => 'warning',
|
||||
'disabled' => 'danger',
|
||||
]),
|
||||
|
||||
Forms\Components\TextInput::make('swap')
|
||||
->dehydratedWhenHidden()
|
||||
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) {
|
||||
'disabled', 'unlimited', true => true,
|
||||
'limited', false => false,
|
||||
})
|
||||
->label('Swap Memory')->inlineLabel()
|
||||
->suffix('MiB')
|
||||
->minValue(-1)
|
||||
->columnSpan(2)
|
||||
->required()
|
||||
->integer(),
|
||||
]),
|
||||
|
||||
Forms\Components\Hidden::make('io')
|
||||
->helperText('The IO performance relative to other running containers')
|
||||
->label('Block IO Proportion'),
|
||||
|
||||
Forms\Components\Grid::make()
|
||||
->columns(4)
|
||||
->columnSpanFull()
|
||||
->schema([
|
||||
Forms\Components\ToggleButtons::make('oom_killer')
|
||||
->label('OOM Killer')->inlineLabel()->inline()
|
||||
->columnSpan(2)
|
||||
->options([
|
||||
false => 'Disabled',
|
||||
true => 'Enabled',
|
||||
])
|
||||
->colors([
|
||||
false => 'success',
|
||||
true => 'danger',
|
||||
]),
|
||||
|
||||
Forms\Components\TextInput::make('oom_disabled_hidden')
|
||||
->hidden(),
|
||||
]),
|
||||
]),
|
||||
|
||||
Forms\Components\Fieldset::make('Feature Limits')
|
||||
->inlineLabel()
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 3,
|
||||
'lg' => 3,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('allocation_limit')
|
||||
->suffixIcon('tabler-network')
|
||||
->required()
|
||||
->numeric(),
|
||||
Forms\Components\TextInput::make('database_limit')
|
||||
->suffixIcon('tabler-database')
|
||||
->required()
|
||||
->numeric(),
|
||||
Forms\Components\TextInput::make('backup_limit')
|
||||
->suffixIcon('tabler-copy-check')
|
||||
->required()
|
||||
->numeric(),
|
||||
]),
|
||||
Forms\Components\Fieldset::make('Docker Settings')
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 3,
|
||||
'lg' => 3,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\Select::make('select_image')
|
||||
->label('Image Name')
|
||||
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state))
|
||||
->options(function ($state, Forms\Get $get, Forms\Set $set) {
|
||||
$egg = Egg::query()->find($get('egg_id'));
|
||||
$images = $egg->docker_images ?? [];
|
||||
|
||||
$currentImage = $get('image');
|
||||
if (!$currentImage && $images) {
|
||||
$defaultImage = collect($images)->first();
|
||||
$set('image', $defaultImage);
|
||||
$set('select_image', $defaultImage);
|
||||
}
|
||||
|
||||
return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image'];
|
||||
})
|
||||
->selectablePlaceholder(false)
|
||||
->columnSpan(1),
|
||||
|
||||
Forms\Components\TextInput::make('image')
|
||||
->label('Image')
|
||||
->debounce(500)
|
||||
->afterStateUpdated(function ($state, Forms\Get $get, Forms\Set $set) {
|
||||
$egg = Egg::query()->find($get('egg_id'));
|
||||
$images = $egg->docker_images ?? [];
|
||||
|
||||
if (in_array($state, $images)) {
|
||||
$set('select_image', $state);
|
||||
} else {
|
||||
$set('select_image', 'ghcr.io/custom-image');
|
||||
}
|
||||
})
|
||||
->placeholder('Enter a custom Image')
|
||||
->columnSpan(2),
|
||||
|
||||
Forms\Components\KeyValue::make('docker_labels')
|
||||
->label('Container Labels')
|
||||
->keyLabel('Label Name')
|
||||
->valueLabel('Label Description')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
Tabs\Tab::make('Egg')
|
||||
->icon('tabler-egg')
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'lg' => 5,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\Select::make('egg_id')
|
||||
->disabledOn('edit')
|
||||
->prefixIcon('tabler-egg')
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'lg' => 5,
|
||||
])
|
||||
->relationship('egg', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
|
||||
Forms\Components\ToggleButtons::make('skip_scripts')
|
||||
->label('Run Egg Install Script?')->inline()
|
||||
->options([
|
||||
false => 'Yes',
|
||||
true => 'Skip',
|
||||
])
|
||||
->colors([
|
||||
false => 'primary',
|
||||
true => 'danger',
|
||||
])
|
||||
->icons([
|
||||
false => 'tabler-code',
|
||||
true => 'tabler-code-off',
|
||||
])
|
||||
->required(),
|
||||
|
||||
Forms\Components\Textarea::make('startup')
|
||||
->label('Startup Command')
|
||||
->required()
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
])
|
||||
->rows(function ($state) {
|
||||
return str($state)->explode("\n")->reduce(
|
||||
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
|
||||
0
|
||||
);
|
||||
}),
|
||||
|
||||
Forms\Components\Textarea::make('defaultStartup')
|
||||
->hintAction(CopyAction::make())
|
||||
->label('Default Startup Command')
|
||||
->disabled()
|
||||
->formatStateUsing(function ($state, Forms\Get $get, Forms\Set $set) {
|
||||
$egg = Egg::query()->find($get('egg_id'));
|
||||
|
||||
return $egg->startup;
|
||||
})
|
||||
->columnSpan([
|
||||
'default' => 2,
|
||||
'sm' => 4,
|
||||
'md' => 4,
|
||||
'lg' => 6,
|
||||
]),
|
||||
|
||||
Forms\Components\Repeater::make('server_variables')
|
||||
->relationship('serverVariables')
|
||||
->grid()
|
||||
->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
|
||||
foreach ($data as $key => $value) {
|
||||
if (!isset($data['variable_value'])) {
|
||||
$data['variable_value'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
})
|
||||
->reorderable(false)->addable(false)->deletable(false)
|
||||
->schema(function () {
|
||||
|
||||
$text = Forms\Components\TextInput::make('variable_value')
|
||||
->hidden($this->shouldHideComponent(...))
|
||||
->rules([
|
||||
fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
|
||||
$validator = Validator::make(['validatorkey' => $value], [
|
||||
'validatorkey' => $serverVariable->variable->rules,
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
|
||||
|
||||
$fail($message);
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
$select = Forms\Components\Select::make('variable_value')
|
||||
->hidden($this->shouldHideComponent(...))
|
||||
->options($this->getSelectOptionsFromRules(...))
|
||||
->selectablePlaceholder(false);
|
||||
|
||||
$components = [$text, $select];
|
||||
|
||||
/** @var Forms\Components\Component $component */
|
||||
foreach ($components as &$component) {
|
||||
$component = $component
|
||||
->live(onBlur: true)
|
||||
->hintIcon('tabler-code')
|
||||
->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name)
|
||||
->hintIconTooltip(fn (ServerVariable $serverVariable) => $serverVariable->variable->rules)
|
||||
->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}')
|
||||
->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description);
|
||||
}
|
||||
|
||||
return $components;
|
||||
})
|
||||
->columnSpan(6),
|
||||
]),
|
||||
Tabs\Tab::make('Mounts')
|
||||
->icon('tabler-layers-linked')
|
||||
->schema([
|
||||
Forms\Components\CheckboxList::make('mounts')
|
||||
->relationship('mounts')
|
||||
->options(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]))
|
||||
->descriptions(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]))
|
||||
->label('Mounts')
|
||||
->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Tabs\Tab::make('Databases')
|
||||
->icon('tabler-database')
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('soon')
|
||||
->label('Soon™'),
|
||||
]),
|
||||
Tabs\Tab::make('Actions')
|
||||
->icon('tabler-settings')
|
||||
->schema([
|
||||
Forms\Components\Fieldset::make('Server Actions')
|
||||
->columns([
|
||||
'default' => 1,
|
||||
'sm' => 2,
|
||||
'md' => 2,
|
||||
'lg' => 6,
|
||||
])
|
||||
->schema([
|
||||
Forms\Components\Grid::make()
|
||||
->columnSpan(3)
|
||||
->schema([
|
||||
Forms\Components\Actions::make([
|
||||
Forms\Components\Actions\Action::make('toggleInstall')
|
||||
->label('Toggle Install Status')
|
||||
->disabled(fn (Server $server) => $server->isSuspended())
|
||||
->action(function (ServersController $serversController, Server $server) {
|
||||
$serversController->toggleInstall($server);
|
||||
|
||||
return $this->refreshFormData(['status', 'docker']);
|
||||
}),
|
||||
])->fullWidth(),
|
||||
Forms\Components\ToggleButtons::make('')
|
||||
->hint('If you need to change the install status from uninstalled to installed, or vice versa, you may do so with this button.'),
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columnSpan(3)
|
||||
->schema([
|
||||
Forms\Components\Actions::make([
|
||||
Forms\Components\Actions\Action::make('toggleSuspend')
|
||||
->label('Suspend')
|
||||
->color('warning')
|
||||
->hidden(fn (Server $server) => $server->isSuspended())
|
||||
->action(function (SuspensionService $suspensionService, Server $server) {
|
||||
$suspensionService->toggle($server, 'suspend');
|
||||
Notification::make()->success()->title('Server Suspended!')->send();
|
||||
|
||||
return $this->refreshFormData(['status', 'docker']);
|
||||
}),
|
||||
Forms\Components\Actions\Action::make('toggleUnsuspend')
|
||||
->label('Unsuspend')
|
||||
->color('success')
|
||||
->hidden(fn (Server $server) => !$server->isSuspended())
|
||||
->action(function (SuspensionService $suspensionService, Server $server) {
|
||||
$suspensionService->toggle($server, 'unsuspend');
|
||||
Notification::make()->success()->title('Server Unsuspended!')->send();
|
||||
|
||||
return $this->refreshFormData(['status', 'docker']);
|
||||
}),
|
||||
])->fullWidth(),
|
||||
Forms\Components\ToggleButtons::make('')
|
||||
->hidden(fn (Server $server) => $server->isSuspended())
|
||||
->hint('This will suspend the server, stop any running processes, and immediately block the user from being able to access their files or otherwise manage the server through the panel or API.'),
|
||||
Forms\Components\ToggleButtons::make('')
|
||||
->hidden(fn (Server $server) => !$server->isSuspended())
|
||||
->hint('This will unsuspend the server and restore normal user access.'),
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columnSpan(3)
|
||||
->schema([
|
||||
Forms\Components\Actions::make([
|
||||
Forms\Components\Actions\Action::make('transfer')
|
||||
->label('Transfer Soon™')
|
||||
->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, $data))
|
||||
->disabled() //TODO!
|
||||
->form([ //TODO!
|
||||
Forms\Components\Select::make('newNode')
|
||||
->label('New Node')
|
||||
->required()
|
||||
->options([
|
||||
true => 'on',
|
||||
false => 'off',
|
||||
]),
|
||||
Forms\Components\Select::make('newMainAllocation')
|
||||
->label('New Main Allocation')
|
||||
->required()
|
||||
->options([
|
||||
true => 'on',
|
||||
false => 'off',
|
||||
]),
|
||||
Forms\Components\Select::make('newAdditionalAllocation')
|
||||
->label('New Additional Allocations')
|
||||
->options([
|
||||
true => 'on',
|
||||
false => 'off',
|
||||
]),
|
||||
])
|
||||
->modalHeading('Transfer'),
|
||||
])->fullWidth(),
|
||||
Forms\Components\ToggleButtons::make('')
|
||||
->hint('Transfer this server to another node connected to this panel. Warning! This feature has not been fully tested and may have bugs.'),
|
||||
]),
|
||||
Forms\Components\Grid::make()
|
||||
->columnSpan(3)
|
||||
->schema([
|
||||
Forms\Components\Actions::make([
|
||||
Forms\Components\Actions\Action::make('reinstall')
|
||||
->label('Reinstall')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Are you sure you want to reinstall this server?')
|
||||
->modalDescription('!! This can result in unrecoverable data loss !!')
|
||||
->disabled(fn (Server $server) => $server->isSuspended())
|
||||
->action(fn (ServersController $serversController, Server $server) => $serversController->reinstallServer($server)),
|
||||
])->fullWidth(),
|
||||
Forms\Components\ToggleButtons::make('')
|
||||
->hint('This will reinstall the server with the assigned egg install script.'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function transferServer(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('toNode')
|
||||
->label('New Node'),
|
||||
Forms\Components\TextInput::make('newAllocation')
|
||||
->label('Allocation'),
|
||||
]);
|
||||
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make('Delete')
|
||||
->successRedirectUrl(route('filament.admin.resources.servers.index'))
|
||||
->color('danger')
|
||||
->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server))
|
||||
->requiresConfirmation(),
|
||||
Actions\Action::make('console')
|
||||
->label('Console')
|
||||
->icon('tabler-terminal')
|
||||
->url(fn (Server $server) => "/server/$server->uuid_short"),
|
||||
$this->getSaveFormAction()->formId('form'),
|
||||
];
|
||||
|
||||
}
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (!isset($data['description'])) {
|
||||
$data['description'] = '';
|
||||
}
|
||||
|
||||
unset($data['docker'], $data['status']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getRelationManagers(): array
|
||||
{
|
||||
return [
|
||||
ServerResource\RelationManagers\AllocationsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool
|
||||
{
|
||||
$containsRuleIn = str($get('rules'))->explode('|')->reduce(
|
||||
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
|
||||
);
|
||||
|
||||
if ($component instanceof Forms\Components\Select) {
|
||||
return $containsRuleIn;
|
||||
}
|
||||
|
||||
if ($component instanceof Forms\Components\TextInput) {
|
||||
return !$containsRuleIn;
|
||||
}
|
||||
|
||||
throw new \Exception('Component type not supported: ' . $component::class);
|
||||
}
|
||||
|
||||
private function getSelectOptionsFromRules(Forms\Get $get): array
|
||||
{
|
||||
$inRule = str($get('rules'))->explode('|')->reduce(
|
||||
fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, ''
|
||||
);
|
||||
|
||||
return str($inRule)
|
||||
->after('in:')
|
||||
->explode(',')
|
||||
->each(fn ($value) => str($value)->trim())
|
||||
->mapWithKeys(fn ($value) => [$value => $value])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
112
app/Filament/Resources/ServerResource/Pages/ListServers.php
Normal file
112
app/Filament/Resources/ServerResource/Pages/ListServers.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ServerResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ServerResource;
|
||||
use App\Models\Server;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Actions\CreateAction;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables;
|
||||
|
||||
class ListServers extends ListRecords
|
||||
{
|
||||
protected static string $resource = ServerResource::class;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->default('unknown')
|
||||
->badge()
|
||||
->default(function (Server $server) {
|
||||
if ($server->status !== null) {
|
||||
return $server->status;
|
||||
}
|
||||
|
||||
return $server->retrieveStatus() ?? 'node_fail';
|
||||
})
|
||||
->icon(fn ($state) => match ($state) {
|
||||
'node_fail' => 'tabler-server-off',
|
||||
'running' => 'tabler-heartbeat',
|
||||
'removing' => 'tabler-heart-x',
|
||||
'offline' => 'tabler-heart-off',
|
||||
'paused' => 'tabler-heart-pause',
|
||||
'installing' => 'tabler-heart-bolt',
|
||||
'suspended' => 'tabler-heart-cancel',
|
||||
default => 'tabler-heart-question',
|
||||
})
|
||||
->color(fn ($state): string => match ($state) {
|
||||
'running' => 'success',
|
||||
'installing', 'restarting' => 'primary',
|
||||
'paused', 'removing' => 'warning',
|
||||
'node_fail', 'install_failed', 'suspended' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\TextColumn::make('uuid')
|
||||
->hidden()
|
||||
->label('UUID')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->icon('tabler-brand-docker')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('node.name')
|
||||
->icon('tabler-server-2')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('egg.name')
|
||||
->icon('tabler-egg')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('user.username')
|
||||
->icon('tabler-user')
|
||||
->label('Owner')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
|
||||
->sortable(),
|
||||
Tables\Columns\SelectColumn::make('allocation_id')
|
||||
->label('Primary Allocation')
|
||||
->options(fn ($state, Server $server) => $server->allocations->mapWithKeys(
|
||||
fn ($allocation) => [$allocation->id => $allocation->address])
|
||||
)
|
||||
->selectablePlaceholder(false)
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('image')->hidden(),
|
||||
Tables\Columns\TextColumn::make('backups_count')
|
||||
->counts('backups')
|
||||
->label('Backups')
|
||||
->icon('tabler-file-download')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\Action::make('View')
|
||||
->icon('tabler-terminal')
|
||||
->url(fn (Server $server) => "/server/$server->uuid_short"),
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->emptyStateIcon('tabler-brand-docker')
|
||||
->emptyStateDescription('')
|
||||
->emptyStateHeading('No Servers')
|
||||
->emptyStateActions([
|
||||
CreateAction::make('create')
|
||||
->label('Create Server')
|
||||
->button(),
|
||||
]);
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('Create Server')
|
||||
->hidden(fn () => Server::count() <= 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ServerResource\RelationManagers;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class AllocationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'allocations';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('ip')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('ip')
|
||||
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port")
|
||||
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
|
||||
// ->actions
|
||||
// ->groups
|
||||
->inverseRelationship('server')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('ip')->label('IP'),
|
||||
Tables\Columns\TextColumn::make('port')->label('Port'),
|
||||
Tables\Columns\TextInputColumn::make('ip_alias')->label('Alias'),
|
||||
Tables\Columns\IconColumn::make('primary')
|
||||
->icon(fn ($state) => match ($state) {
|
||||
false => 'tabler-star',
|
||||
true => 'tabler-star-filled',
|
||||
})
|
||||
->color(fn ($state) => match ($state) {
|
||||
false => 'gray',
|
||||
true => 'warning',
|
||||
})
|
||||
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]))
|
||||
->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id)
|
||||
->label('Primary'),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\Action::make('make-primary')
|
||||
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]))
|
||||
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'),
|
||||
])
|
||||
->headerActions([
|
||||
//TODO Tables\Actions\CreateAction::make()->label('Create Allocation'),
|
||||
Tables\Actions\AssociateAction::make()
|
||||
->multiple()
|
||||
->preloadRecordSelect()
|
||||
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node))
|
||||
->label('Add Allocation'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DissociateBulkAction::make(),
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Filament/Resources/UserResource.php
Normal file
37
app/Filament/Resources/UserResource.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\UserResource\Pages;
|
||||
use App\Filament\Resources\UserResource\RelationManagers;
|
||||
use App\Models\User;
|
||||
use Filament\Resources\Resource;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'tabler-users';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'username';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count() ?: null;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\ServersRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListUsers::route('/'),
|
||||
'edit' => Pages\EditUser::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
306
app/Filament/Resources/UserResource/Pages/EditProfile.php
Normal file
306
app/Filament/Resources/UserResource/Pages/EditProfile.php
Normal file
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid;
|
||||
use App\Facades\Activity;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\User;
|
||||
use App\Services\Users\ToggleTwoFactorService;
|
||||
use App\Services\Users\TwoFactorSetupService;
|
||||
use chillerlan\QRCode\Common\EccLevel;
|
||||
use chillerlan\QRCode\Common\Version;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use Filament\Forms\Components\Actions\Action;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Tabs;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\Tabs\Tab;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Get;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class EditProfile extends \Filament\Pages\Auth\EditProfile
|
||||
{
|
||||
protected function getForms(): array
|
||||
{
|
||||
return [
|
||||
'form' => $this->form(
|
||||
$this->makeForm()
|
||||
->schema([
|
||||
Tabs::make()->persistTabInQueryString()
|
||||
->schema([
|
||||
Tab::make('Account')
|
||||
->label(trans('strings.account'))
|
||||
->icon('tabler-user')
|
||||
->schema([
|
||||
TextInput::make('username')
|
||||
->label(trans('strings.username'))
|
||||
->disabled()
|
||||
->readOnly()
|
||||
->maxLength(191)
|
||||
->unique(ignoreRecord: true)
|
||||
->autofocus(),
|
||||
|
||||
TextInput::make('email')
|
||||
->prefixIcon('tabler-mail')
|
||||
->label(trans('strings.email'))
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(191)
|
||||
->unique(ignoreRecord: true),
|
||||
|
||||
TextInput::make('password')
|
||||
->label(trans('strings.password'))
|
||||
->password()
|
||||
->prefixIcon('tabler-password')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->rule(Password::default())
|
||||
->autocomplete('new-password')
|
||||
->dehydrated(fn ($state): bool => filled($state))
|
||||
->dehydrateStateUsing(fn ($state): string => Hash::make($state))
|
||||
->live(debounce: 500)
|
||||
->same('passwordConfirmation'),
|
||||
|
||||
TextInput::make('passwordConfirmation')
|
||||
->label(trans('strings.password_confirmation'))
|
||||
->password()
|
||||
->prefixIcon('tabler-password-fingerprint')
|
||||
->revealable(filament()->arePasswordsRevealable())
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => filled($get('password')))
|
||||
->dehydrated(false),
|
||||
|
||||
Select::make('language')
|
||||
->label(trans('strings.language'))
|
||||
->required()
|
||||
->prefixIcon('tabler-flag')
|
||||
->live()
|
||||
->default('en')
|
||||
->helperText(fn (User $user, $state) => new HtmlString($user->isLanguageTranslated($state) ? '' : "
|
||||
Your language ($state) has not been translated yet!
|
||||
But never fear, you can help fix that by
|
||||
<a style='color: rgb(56, 189, 248)' href='https://crowdin.com/project/pelican-dev'>contributing directly here</a>.
|
||||
")
|
||||
)
|
||||
->options(fn (User $user) => $user->getAvailableLanguages()),
|
||||
]),
|
||||
|
||||
Tab::make('2FA')
|
||||
->icon('tabler-shield-lock')
|
||||
->schema(function () {
|
||||
|
||||
if ($this->getUser()->use_totp) {
|
||||
return [
|
||||
Placeholder::make('2fa-already-enabled')
|
||||
->label('Two Factor Authentication is currently enabled!'),
|
||||
Textarea::make('backup-tokens')
|
||||
->hidden(fn () => !cache()->get("users.{$this->getUser()->id}.2fa.tokens"))
|
||||
->rows(10)
|
||||
->readOnly()
|
||||
->formatStateUsing(fn () => cache()->get("users.{$this->getUser()->id}.2fa.tokens"))
|
||||
->helperText('These will not be shown again!')
|
||||
->label('Backup Tokens:'),
|
||||
TextInput::make('2fa-disable-code')
|
||||
->label('Disable 2FA')
|
||||
->helperText('Enter your current 2FA code to disable Two Factor Authentication'),
|
||||
];
|
||||
}
|
||||
$setupService = app(TwoFactorSetupService::class);
|
||||
|
||||
['image_url_data' => $url, 'secret' => $secret] = cache()->remember(
|
||||
"users.{$this->getUser()->id}.2fa.state",
|
||||
now()->addMinutes(5), fn () => $setupService->handle($this->getUser())
|
||||
);
|
||||
|
||||
$options = new QROptions([
|
||||
'svgLogo' => public_path('pelican.svg'),
|
||||
'addLogoSpace' => true,
|
||||
'logoSpaceWidth' => 13,
|
||||
'logoSpaceHeight' => 13,
|
||||
]);
|
||||
|
||||
// https://github.com/chillerlan/php-qrcode/blob/main/examples/svgWithLogo.php
|
||||
|
||||
// SVG logo options (see extended class)
|
||||
$options->svgLogo = public_path('pelican.svg'); // logo from: https://github.com/simple-icons/simple-icons
|
||||
$options->svgLogoScale = 0.05;
|
||||
// $options->svgLogoCssClass = 'dark';
|
||||
|
||||
// QROptions
|
||||
$options->version = Version::AUTO;
|
||||
// $options->outputInterface = QRSvgWithLogo::class;
|
||||
$options->outputBase64 = false;
|
||||
$options->eccLevel = EccLevel::H; // ECC level H is necessary when using logos
|
||||
$options->addQuietzone = true;
|
||||
// $options->drawLightModules = true;
|
||||
$options->connectPaths = true;
|
||||
$options->drawCircularModules = true;
|
||||
// $options->circleRadius = 0.45;
|
||||
|
||||
$options->svgDefs = '<linearGradient id="gradient" x1="100%" y2="100%">
|
||||
<stop stop-color="#7dd4fc" offset="0"/>
|
||||
<stop stop-color="#38bdf8" offset="0.5"/>
|
||||
<stop stop-color="#0369a1" offset="1"/>
|
||||
</linearGradient>
|
||||
<style><![CDATA[
|
||||
.dark{fill: url(#gradient);}
|
||||
.light{fill: #000;}
|
||||
]]></style>';
|
||||
|
||||
$image = (new QRCode($options))->render($url);
|
||||
|
||||
return [
|
||||
Placeholder::make('qr')
|
||||
->label('Scan QR Code')
|
||||
->content(fn () => new HtmlString("
|
||||
<div style='width: 300px; background-color: rgb(24, 24, 27);'>$image</div>
|
||||
"))
|
||||
->helperText('Setup Key: '. $secret),
|
||||
TextInput::make('2facode')
|
||||
->label('Code')
|
||||
->requiredWith('2fapassword')
|
||||
->helperText('Scan the QR code above using your two-step authentication app, then enter the code generated.'),
|
||||
TextInput::make('2fapassword')
|
||||
->label('Current Password')
|
||||
->requiredWith('2facode')
|
||||
->currentPassword()
|
||||
->password()
|
||||
->helperText('Enter your current password to verify.'),
|
||||
];
|
||||
}),
|
||||
|
||||
Tab::make('API Keys')
|
||||
->icon('tabler-key')
|
||||
->schema([
|
||||
Grid::make('asdf')->columns(5)->schema([
|
||||
Section::make('Create API Key')->columnSpan(3)->schema([
|
||||
TextInput::make('description')->required(),
|
||||
TagsInput::make('allowed_ips')
|
||||
->splitKeys([',', ' ', 'Tab'])
|
||||
->placeholder('Example: 127.0.0.1 or 192.168.1.1')
|
||||
->label('Whitelisted IP\'s')
|
||||
->helperText('Press enter to add a new IP address or leave blank to allow any IP address')
|
||||
->columnSpanFull(),
|
||||
])->headerActions([
|
||||
Action::make('Create')
|
||||
->successRedirectUrl(route('filament.admin.auth.profile', ['tab' => '-api-keys-tab']))
|
||||
->action(function (Get $get, Action $action) {
|
||||
$token = auth()->user()->createToken(
|
||||
$get('description'),
|
||||
$get('allowed_ips'),
|
||||
);
|
||||
|
||||
Activity::event('user:api-key.create')
|
||||
->subject($token->accessToken)
|
||||
->property('identifier', $token->accessToken->identifier)
|
||||
->log();
|
||||
|
||||
$action->success();
|
||||
}),
|
||||
]),
|
||||
Section::make('Keys')->columnSpan(2)->schema([
|
||||
Repeater::make('keys')
|
||||
->label('')
|
||||
->relationship('apiKeys')
|
||||
->addable(false)
|
||||
->itemLabel(fn ($state) => $state['identifier'])
|
||||
->deleteAction(function (Action $action) {
|
||||
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component) {
|
||||
$items = $component->getState();
|
||||
$key = $items[$arguments['item']];
|
||||
ApiKey::find($key['id'] ?? null)?->delete();
|
||||
|
||||
unset($items[$arguments['item']]);
|
||||
|
||||
$component->state($items);
|
||||
|
||||
$component->callAfterStateUpdated();
|
||||
});
|
||||
})
|
||||
->schema(fn () => [
|
||||
Placeholder::make('adf')->label(fn (ApiKey $key) => $key->memo),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('SSH Keys')
|
||||
->icon('tabler-lock-code')
|
||||
->schema([
|
||||
Placeholder::make('Coming soon!'),
|
||||
]),
|
||||
|
||||
Tab::make('Activity')
|
||||
->icon('tabler-history')
|
||||
->schema([
|
||||
Repeater::make('activity')
|
||||
->deletable(false)
|
||||
->addable(false)
|
||||
->relationship(null, function (Builder $query) {
|
||||
$query->orderBy('timestamp', 'desc');
|
||||
})
|
||||
->schema([
|
||||
Placeholder::make('activity!')->label('')->content(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->operation('edit')
|
||||
->model($this->getUser())
|
||||
->statePath('data')
|
||||
->inlineLabel(!static::isSimple()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
protected function handleRecordUpdate($record, $data): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
if ($token = $data['2facode'] ?? null) {
|
||||
/** @var ToggleTwoFactorService $service */
|
||||
$service = resolve(ToggleTwoFactorService::class);
|
||||
|
||||
$tokens = $service->handle($record, $token, true);
|
||||
cache()->set("users.$record->id.2fa.tokens", implode("\n", $tokens), now()->addSeconds(15));
|
||||
|
||||
$this->redirectRoute('filament.admin.auth.profile', ['tab' => '-2fa-tab']);
|
||||
}
|
||||
|
||||
if ($token = $data['2fa-disable-code'] ?? null) {
|
||||
/** @var ToggleTwoFactorService $service */
|
||||
$service = resolve(ToggleTwoFactorService::class);
|
||||
|
||||
$service->handle($record, $token, false);
|
||||
|
||||
cache()->forget("users.$record->id.2fa.state");
|
||||
}
|
||||
|
||||
return parent::handleRecordUpdate($record, $data);
|
||||
}
|
||||
|
||||
public function exception($e, $stopPropagation): void
|
||||
{
|
||||
if ($e instanceof TwoFactorAuthenticationTokenInvalid) {
|
||||
Notification::make()
|
||||
->title('Invalid 2FA Code')
|
||||
->body($e->getMessage())
|
||||
->color('danger')
|
||||
->icon('tabler-2fa')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
$stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
86
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
86
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Services\Exceptions\FilamentExceptionHandler;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use App\Models\User;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Form;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class EditUser extends EditRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Section::make()->schema([
|
||||
Forms\Components\TextInput::make('username')->required()->maxLength(191),
|
||||
Forms\Components\TextInput::make('email')->email()->required()->maxLength(191),
|
||||
|
||||
Forms\Components\TextInput::make('password')
|
||||
->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
|
||||
->dehydrated(fn (?string $state): bool => filled($state))
|
||||
->required(fn (string $operation): bool => $operation === 'create')
|
||||
->password(),
|
||||
|
||||
Forms\Components\ToggleButtons::make('root_admin')
|
||||
->label('Administrator (Root)')
|
||||
->options([
|
||||
false => 'No',
|
||||
true => 'Admin',
|
||||
])
|
||||
->colors([
|
||||
false => 'primary',
|
||||
true => 'danger',
|
||||
])
|
||||
->disableOptionWhen(function (string $operation, $value, User $user) {
|
||||
if ($operation !== 'edit' || $value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isLastRootAdmin();
|
||||
})
|
||||
->hint(fn (User $user) => $user->isLastRootAdmin() ? 'This is the last root administrator!' : '')
|
||||
->helperText(fn (User $user) => $user->isLastRootAdmin() ? 'You must have at least one root administrator in your system.' : '')
|
||||
->hintColor('warning')
|
||||
->inline()
|
||||
->required()
|
||||
->default(false),
|
||||
|
||||
Forms\Components\Hidden::make('skipValidation')->default(true),
|
||||
|
||||
Forms\Components\Select::make('language')
|
||||
->required()
|
||||
->hidden()
|
||||
->default('en')
|
||||
->options(fn (User $user) => $user->getAvailableLanguages()),
|
||||
|
||||
])->columns(),
|
||||
]);
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->label(fn (User $user) => auth()->user()->id === $user->id ? 'Can\'t Delete Yourself' : ($user->servers()->count() > 0 ? 'User Has Servers' : 'Delete'))
|
||||
->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0),
|
||||
$this->getSaveFormAction()->formId('form'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function exception($exception, $stopPropagation): void
|
||||
{
|
||||
(new FilamentExceptionHandler())->handle($exception, $stopPropagation);
|
||||
}
|
||||
}
|
||||
124
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
124
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Services\Users\UserCreationService;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables;
|
||||
use Filament\Forms;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->searchable(false)
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('picture')
|
||||
->visibleFrom('lg')
|
||||
->label('')
|
||||
->extraImgAttributes(['class' => 'rounded-full'])
|
||||
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
|
||||
Tables\Columns\TextColumn::make('external_id')
|
||||
->searchable()
|
||||
->hidden(),
|
||||
Tables\Columns\TextColumn::make('uuid')
|
||||
->label('UUID')
|
||||
->hidden()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('username')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('email')
|
||||
->searchable()
|
||||
->icon('tabler-mail'),
|
||||
Tables\Columns\IconColumn::make('root_admin')
|
||||
->visibleFrom('md')
|
||||
->label('Admin')
|
||||
->boolean()
|
||||
->trueIcon('tabler-star-filled')
|
||||
->falseIcon('tabler-star-off')
|
||||
->sortable(),
|
||||
Tables\Columns\IconColumn::make('use_totp')->label('2FA')
|
||||
->visibleFrom('lg')
|
||||
->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off')
|
||||
->boolean()->sortable(),
|
||||
Tables\Columns\TextColumn::make('servers_count')
|
||||
->counts('servers')
|
||||
->icon('tabler-server')
|
||||
->label('Servers'),
|
||||
Tables\Columns\TextColumn::make('subusers_count')
|
||||
->visibleFrom('sm')
|
||||
->label('Subusers')
|
||||
->counts('subusers')
|
||||
->icon('tabler-users'),
|
||||
// ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count))
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->checkIfRecordIsSelectableUsing(fn (User $user) => !$user->servers_count)
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make('create')
|
||||
->label('Create User')
|
||||
->createAnother(false)
|
||||
->form([
|
||||
Forms\Components\Grid::make()
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('username')
|
||||
->alphaNum()
|
||||
->required()
|
||||
->maxLength(191),
|
||||
Forms\Components\TextInput::make('email')
|
||||
->email()
|
||||
->required()
|
||||
->unique()
|
||||
->maxLength(191),
|
||||
|
||||
Forms\Components\TextInput::make('password')
|
||||
->hintIcon('tabler-question-mark')
|
||||
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
|
||||
->password(),
|
||||
|
||||
Forms\Components\ToggleButtons::make('root_admin')
|
||||
->label('Administrator (Root)')
|
||||
->options([
|
||||
false => 'No',
|
||||
true => 'Admin',
|
||||
])
|
||||
->colors([
|
||||
false => 'primary',
|
||||
true => 'danger',
|
||||
])
|
||||
->inline()
|
||||
->required()
|
||||
->default(false),
|
||||
]),
|
||||
])
|
||||
->successRedirectUrl(route('filament.admin.resources.users.index'))
|
||||
->action(function (array $data) {
|
||||
resolve(UserCreationService::class)->handle($data);
|
||||
Notification::make()->title('User Created!')->success()->send();
|
||||
|
||||
return redirect()->route('filament.admin.resources.users.index');
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use App\Enums\ServerState;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\Servers\SuspensionService;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
|
||||
class ServersRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'servers';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getOwnerRecord();
|
||||
|
||||
return $table
|
||||
->searchable(false)
|
||||
->headerActions([
|
||||
Actions\Action::make('toggleSuspend')
|
||||
->hidden(fn () => $user->servers()
|
||||
->whereNot('status', ServerState::Suspended)
|
||||
->orWhereNull('status')
|
||||
->count() === 0
|
||||
)
|
||||
->label('Suspend All Servers')
|
||||
->color('warning')
|
||||
->action(function () use ($user) {
|
||||
foreach ($user->servers()->whereNot('status', ServerState::Suspended)->get() as $server) {
|
||||
resolve(SuspensionService::class)->toggle($server);
|
||||
}
|
||||
}),
|
||||
|
||||
Actions\Action::make('toggleUnsuspend')
|
||||
->hidden(fn () => $user->servers()->where('status', ServerState::Suspended)->count() === 0)
|
||||
->label('Unsuspend All Servers')
|
||||
->color('primary')
|
||||
->action(function () use ($user) {
|
||||
foreach ($user->servers()->where('status', ServerState::Suspended)->get() as $server) {
|
||||
resolve(SuspensionService::class)->toggle($server, SuspensionService::ACTION_UNSUSPEND);
|
||||
}
|
||||
}),
|
||||
])
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('uuid')
|
||||
->hidden()
|
||||
->label('UUID')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->icon('tabler-brand-docker')
|
||||
->label(trans('strings.name'))
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('node.name')
|
||||
->icon('tabler-server-2')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('egg.name')
|
||||
->icon('tabler-egg')
|
||||
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
|
||||
->sortable(),
|
||||
Tables\Columns\SelectColumn::make('allocation.id')
|
||||
->label('Primary Allocation')
|
||||
->options(fn ($state, Server $server) => [$server->allocation->id => $server->allocation->address])
|
||||
->selectablePlaceholder(false)
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('image')->hidden(),
|
||||
Tables\Columns\TextColumn::make('databases_count')
|
||||
->counts('databases')
|
||||
->label('Databases')
|
||||
->icon('tabler-database')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('backups_count')
|
||||
->counts('backups')
|
||||
->label('Backups')
|
||||
->icon('tabler-file-download')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ class EggController extends Controller
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['docker_images'] = $this->normalizeDockerImages($data['docker_images'] ?? null);
|
||||
$data['author'] = $request->user()->email;
|
||||
|
||||
$egg = $this->creationService->handle($data);
|
||||
$this->alert->success(trans('admin/eggs.notices.egg_created'))->flash();
|
||||
|
||||
@@ -46,7 +46,7 @@ class EggShareController extends Controller
|
||||
*/
|
||||
public function import(EggImportFormRequest $request): RedirectResponse
|
||||
{
|
||||
$egg = $this->importerService->handle($request->file('import_file'));
|
||||
$egg = $this->importerService->fromFile($request->file('import_file'));
|
||||
$this->alert->success(trans('admin/eggs.notices.imported'))->flash();
|
||||
|
||||
return redirect()->route('admin.eggs.view', ['egg' => $egg->id]);
|
||||
@@ -61,7 +61,7 @@ class EggShareController extends Controller
|
||||
*/
|
||||
public function update(EggImportFormRequest $request, Egg $egg): RedirectResponse
|
||||
{
|
||||
$this->updateImporterService->handle($egg, $request->file('import_file'));
|
||||
$this->updateImporterService->fromFile($egg, $request->file('import_file'));
|
||||
$this->alert->success(trans('admin/eggs.notices.updated_via_import'))->flash();
|
||||
|
||||
return redirect()->route('admin.eggs.view', ['egg' => $egg]);
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\Egg;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use App\Models\Mount;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Prologue\Alerts\AlertsMessageBag;
|
||||
use Illuminate\View\Factory as ViewFactory;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\MountFormRequest;
|
||||
|
||||
class MountController extends Controller
|
||||
{
|
||||
/**
|
||||
* MountController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
protected AlertsMessageBag $alert,
|
||||
protected ViewFactory $view
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the mount overview page.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return view('admin.mounts.index', [
|
||||
'mounts' => Mount::query()->withCount(['eggs', 'nodes'])->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the mount view page.
|
||||
*/
|
||||
public function view(string $id): View
|
||||
{
|
||||
return view('admin.mounts.view', [
|
||||
'mount' => Mount::with(['eggs', 'nodes'])->findOrFail($id),
|
||||
'eggs' => Egg::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle request to create new mount.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function create(MountFormRequest $request): RedirectResponse
|
||||
{
|
||||
$model = (new Mount())->fill($request->validated());
|
||||
$model->forceFill(['uuid' => Uuid::uuid4()->toString()]);
|
||||
|
||||
$model->saveOrFail();
|
||||
$mount = $model->fresh();
|
||||
|
||||
$this->alert->success('Mount was created successfully.')->flash();
|
||||
|
||||
return redirect()->route('admin.mounts.view', $mount->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle request to update or delete location.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function update(MountFormRequest $request, Mount $mount): RedirectResponse
|
||||
{
|
||||
if ($request->input('action') === 'delete') {
|
||||
return $this->delete($mount);
|
||||
}
|
||||
|
||||
$mount->forceFill($request->validated())->save();
|
||||
|
||||
$this->alert->success('Mount was updated successfully.')->flash();
|
||||
|
||||
return redirect()->route('admin.mounts.view', $mount->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a location from the system.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(Mount $mount): RedirectResponse
|
||||
{
|
||||
$mount->delete();
|
||||
|
||||
return redirect()->route('admin.mounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds eggs to the mount's many-to-many relation.
|
||||
*/
|
||||
public function addEggs(Request $request, Mount $mount): RedirectResponse
|
||||
{
|
||||
$validatedData = $request->validate([
|
||||
'eggs' => 'required|exists:eggs,id',
|
||||
]);
|
||||
|
||||
$eggs = $validatedData['eggs'] ?? [];
|
||||
if (count($eggs) > 0) {
|
||||
$mount->eggs()->attach($eggs);
|
||||
}
|
||||
|
||||
$this->alert->success('Mount was updated successfully.')->flash();
|
||||
|
||||
return redirect()->route('admin.mounts.view', $mount->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds nodes to the mount's many-to-many relation.
|
||||
*/
|
||||
public function addNodes(Request $request, Mount $mount): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['nodes' => 'required|exists:nodes,id']);
|
||||
|
||||
$nodes = $data['nodes'] ?? [];
|
||||
if (count($nodes) > 0) {
|
||||
$mount->nodes()->attach($nodes);
|
||||
}
|
||||
|
||||
$this->alert->success('Mount was updated successfully.')->flash();
|
||||
|
||||
return redirect()->route('admin.mounts.view', $mount->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an egg from the mount's many-to-many relation.
|
||||
*/
|
||||
public function deleteEgg(Mount $mount, int $egg_id): Response
|
||||
{
|
||||
$mount->eggs()->detach($egg_id);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a node from the mount's many-to-many relation.
|
||||
*/
|
||||
public function deleteNode(Mount $mount, int $node_id): Response
|
||||
{
|
||||
$mount->nodes()->detach($node_id);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class NodeAutoDeployController extends Controller
|
||||
|
||||
return new JsonResponse([
|
||||
'node' => $node->id,
|
||||
'token' => $key->identifier . decrypt($key->token),
|
||||
'token' => $key->identifier . $key->token,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Admin\Nodes;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Node;
|
||||
use Spatie\QueryBuilder\QueryBuilder;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -13,7 +12,7 @@ class NodeController extends Controller
|
||||
/**
|
||||
* Returns a listing of nodes on the system.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
public function index(): View
|
||||
{
|
||||
$nodes = QueryBuilder::for(
|
||||
Node::query()->withCount('servers')
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Admin\Nodes;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Node;
|
||||
use Illuminate\Support\Collection;
|
||||
use App\Models\Allocation;
|
||||
@@ -29,39 +28,12 @@ class NodeViewController extends Controller
|
||||
/**
|
||||
* Returns index view for a specific node on the system.
|
||||
*/
|
||||
public function index(Request $request, Node $node): View
|
||||
public function index(Node $node): View
|
||||
{
|
||||
$node->loadCount('servers');
|
||||
|
||||
$stats = Node::query()
|
||||
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
|
||||
->join('servers', 'servers.node_id', '=', 'nodes.id')
|
||||
->where('node_id', '=', $node->id)
|
||||
->first();
|
||||
|
||||
$usageStats = Collection::make(['disk' => $stats->sum_disk, 'memory' => $stats->sum_memory])
|
||||
->mapWithKeys(function ($value, $key) use ($node) {
|
||||
$maxUsage = $node->{$key};
|
||||
if ($node->{$key . '_overallocate'} > 0) {
|
||||
$maxUsage = $node->{$key} * (1 + ($node->{$key . '_overallocate'} / 100));
|
||||
}
|
||||
|
||||
$percent = ($value / $maxUsage) * 100;
|
||||
|
||||
return [
|
||||
$key => [
|
||||
'value' => number_format($value),
|
||||
'max' => number_format($maxUsage),
|
||||
'percent' => $percent,
|
||||
'css' => ($percent <= self::THRESHOLD_PERCENTAGE_LOW) ? 'green' : (($percent > self::THRESHOLD_PERCENTAGE_MEDIUM) ? 'red' : 'yellow'),
|
||||
],
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
return view('admin.nodes.view.index', [
|
||||
'node' => $node,
|
||||
'stats' => $usageStats,
|
||||
'version' => $this->versionService,
|
||||
]);
|
||||
}
|
||||
@@ -69,7 +41,7 @@ class NodeViewController extends Controller
|
||||
/**
|
||||
* Returns the settings page for a specific node.
|
||||
*/
|
||||
public function settings(Request $request, Node $node): View
|
||||
public function settings(Node $node): View
|
||||
{
|
||||
return view('admin.nodes.view.settings', [
|
||||
'node' => $node,
|
||||
@@ -79,7 +51,7 @@ class NodeViewController extends Controller
|
||||
/**
|
||||
* Return the node configuration page for a specific node.
|
||||
*/
|
||||
public function configuration(Request $request, Node $node): View
|
||||
public function configuration(Node $node): View
|
||||
{
|
||||
return view('admin.nodes.view.configuration', compact('node'));
|
||||
}
|
||||
@@ -87,7 +59,7 @@ class NodeViewController extends Controller
|
||||
/**
|
||||
* Return the node allocation management page.
|
||||
*/
|
||||
public function allocations(Request $request, Node $node): View
|
||||
public function allocations(Node $node): View
|
||||
{
|
||||
$node->setRelation(
|
||||
'allocations',
|
||||
@@ -113,11 +85,11 @@ class NodeViewController extends Controller
|
||||
/**
|
||||
* Return a listing of servers that exist for this specific node.
|
||||
*/
|
||||
public function servers(Request $request, Node $node): View
|
||||
public function servers(Node $node): View
|
||||
{
|
||||
$this->plainInject([
|
||||
'node' => Collection::wrap($node->makeVisible(['daemon_token_id', 'daemon_token']))
|
||||
->only(['scheme', 'fqdn', 'daemonListen', 'daemon_token_id', 'daemon_token']),
|
||||
->only(['scheme', 'fqdn', 'daemon_listen', 'daemon_token_id', 'daemon_token']),
|
||||
]);
|
||||
|
||||
return view('admin.nodes.view.servers', [
|
||||
|
||||
@@ -53,7 +53,6 @@ class CreateServerController extends Controller
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws \App\Exceptions\DisplayException
|
||||
* @throws \App\Exceptions\Service\Deployment\NoViableAllocationException
|
||||
* @throws \App\Exceptions\Service\Deployment\NoViableNodeException
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function store(ServerFormRequest $request): RedirectResponse
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Admin\Servers;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Server;
|
||||
use Spatie\QueryBuilder\QueryBuilder;
|
||||
use Spatie\QueryBuilder\AllowedFilter;
|
||||
@@ -16,7 +15,7 @@ class ServerController extends Controller
|
||||
* Returns all the servers that exist on the system using a paginated result set. If
|
||||
* a query is passed along in the request it is also passed to the repository function.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
public function index(): View
|
||||
{
|
||||
$servers = QueryBuilder::for(Server::query()->with('node', 'user', 'allocation'))
|
||||
->allowedFilters([
|
||||
|
||||
@@ -2,21 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin\Servers;
|
||||
|
||||
use App\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Node;
|
||||
use Carbon\CarbonImmutable;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Lcobucci\JWT\Token\Plain;
|
||||
use Prologue\Alerts\AlertsMessageBag;
|
||||
use App\Models\ServerTransfer;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Nodes\NodeJWTService;
|
||||
use App\Models\Server;
|
||||
use App\Services\Servers\TransferServerService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Prologue\Alerts\AlertsMessageBag;
|
||||
|
||||
class ServerTransferController extends Controller
|
||||
{
|
||||
@@ -25,30 +16,10 @@ class ServerTransferController extends Controller
|
||||
*/
|
||||
public function __construct(
|
||||
private AlertsMessageBag $alert,
|
||||
private ConnectionInterface $connection,
|
||||
private NodeJWTService $nodeJWTService,
|
||||
private TransferServerService $transferServerService,
|
||||
) {
|
||||
}
|
||||
|
||||
private function notify(Server $server, Plain $token): void
|
||||
{
|
||||
try {
|
||||
Http::daemon($server->node)->post('/api/transfer', [
|
||||
'json' => [
|
||||
'server_id' => $server->uuid,
|
||||
'url' => $server->node->getConnectionAddress() . "/api/servers/$server->uuid/archive",
|
||||
'token' => 'Bearer ' . $token->toString(),
|
||||
'server' => [
|
||||
'uuid' => $server->uuid,
|
||||
'start_on_completion' => false,
|
||||
],
|
||||
],
|
||||
])->toPsrResponse();
|
||||
} catch (TransferException $exception) {
|
||||
throw new DaemonConnectionException($exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a transfer of a server to a new node.
|
||||
*
|
||||
@@ -62,85 +33,12 @@ class ServerTransferController extends Controller
|
||||
'allocation_additional' => 'nullable',
|
||||
]);
|
||||
|
||||
$node_id = $validatedData['node_id'];
|
||||
$allocation_id = intval($validatedData['allocation_id']);
|
||||
$additional_allocations = array_map('intval', $validatedData['allocation_additional'] ?? []);
|
||||
|
||||
// Check if the node is viable for the transfer.
|
||||
$node = Node::query()
|
||||
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemonListen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
|
||||
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
|
||||
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
|
||||
->where('nodes.id', $node_id)
|
||||
->first();
|
||||
|
||||
if (!$node->isViable($server->memory, $server->disk)) {
|
||||
if ($this->transferServerService->handle($server, $validatedData)) {
|
||||
$this->alert->success(trans('admin/server.alerts.transfer_started'))->flash();
|
||||
} else {
|
||||
$this->alert->danger(trans('admin/server.alerts.transfer_not_viable'))->flash();
|
||||
|
||||
return redirect()->route('admin.servers.view.manage', $server->id);
|
||||
}
|
||||
|
||||
$server->validateTransferState();
|
||||
|
||||
$this->connection->transaction(function () use ($server, $node_id, $allocation_id, $additional_allocations) {
|
||||
// Create a new ServerTransfer entry.
|
||||
$transfer = new ServerTransfer();
|
||||
|
||||
$transfer->server_id = $server->id;
|
||||
$transfer->old_node = $server->node_id;
|
||||
$transfer->new_node = $node_id;
|
||||
$transfer->old_allocation = $server->allocation_id;
|
||||
$transfer->new_allocation = $allocation_id;
|
||||
$transfer->old_additional_allocations = $server->allocations->where('id', '!=', $server->allocation_id)->pluck('id')->all();
|
||||
$transfer->new_additional_allocations = $additional_allocations;
|
||||
|
||||
$transfer->save();
|
||||
|
||||
// Add the allocations to the server, so they cannot be automatically assigned while the transfer is in progress.
|
||||
$this->assignAllocationsToServer($server, $node_id, $allocation_id, $additional_allocations);
|
||||
|
||||
// Generate a token for the destination node that the source node can use to authenticate with.
|
||||
$token = $this->nodeJWTService
|
||||
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
|
||||
->setSubject($server->uuid)
|
||||
->handle($transfer->newNode, $server->uuid, 'sha256');
|
||||
|
||||
// Notify the source node of the pending outgoing transfer.
|
||||
$this->notify($server, $token);
|
||||
|
||||
return $transfer;
|
||||
});
|
||||
|
||||
$this->alert->success(trans('admin/server.alerts.transfer_started'))->flash();
|
||||
|
||||
return redirect()->route('admin.servers.view.manage', $server->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the specified allocations to the specified server.
|
||||
*/
|
||||
private function assignAllocationsToServer(Server $server, int $node_id, int $allocation_id, array $additional_allocations)
|
||||
{
|
||||
$allocations = $additional_allocations;
|
||||
$allocations[] = $allocation_id;
|
||||
|
||||
$node = Node::query()->findOrFail($node_id);
|
||||
$unassigned = $node->allocations()
|
||||
->whereNull('server_id')
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
$updateIds = [];
|
||||
foreach ($allocations as $allocation) {
|
||||
if (!in_array($allocation, $unassigned)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$updateIds[] = $allocation;
|
||||
}
|
||||
|
||||
if (!empty($updateIds)) {
|
||||
Allocation::query()->whereIn('id', $updateIds)->update(['server_id' => $server->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin\Servers;
|
||||
|
||||
use App\Enums\ServerState;
|
||||
use App\Models\DatabaseHost;
|
||||
use App\Models\Egg;
|
||||
use App\Models\Mount;
|
||||
use App\Models\Node;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Server;
|
||||
use App\Exceptions\DisplayException;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -22,14 +22,14 @@ class ServerViewController extends Controller
|
||||
* ServerViewController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private EnvironmentService $environmentService,
|
||||
private readonly EnvironmentService $environmentService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index view for a server.
|
||||
*/
|
||||
public function index(Request $request, Server $server): View
|
||||
public function index(Server $server): View
|
||||
{
|
||||
return view('admin.servers.view.index', compact('server'));
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class ServerViewController extends Controller
|
||||
/**
|
||||
* Returns the server details page.
|
||||
*/
|
||||
public function details(Request $request, Server $server): View
|
||||
public function details(Server $server): View
|
||||
{
|
||||
return view('admin.servers.view.details', compact('server'));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class ServerViewController extends Controller
|
||||
/**
|
||||
* Returns a view of server build settings.
|
||||
*/
|
||||
public function build(Request $request, Server $server): View
|
||||
public function build(Server $server): View
|
||||
{
|
||||
$allocations = $server->node->allocations->toBase();
|
||||
|
||||
@@ -59,7 +59,7 @@ class ServerViewController extends Controller
|
||||
/**
|
||||
* Returns the server startup management page.
|
||||
*/
|
||||
public function startup(Request $request, Server $server): View
|
||||
public function startup(Server $server): View
|
||||
{
|
||||
$variables = $this->environmentService->handle($server);
|
||||
$eggs = Egg::all()->keyBy('id');
|
||||
@@ -76,7 +76,7 @@ class ServerViewController extends Controller
|
||||
/**
|
||||
* Returns all the databases that exist for the server.
|
||||
*/
|
||||
public function database(Request $request, Server $server): View
|
||||
public function database(Server $server): View
|
||||
{
|
||||
return view('admin.servers.view.database', [
|
||||
'hosts' => DatabaseHost::all(),
|
||||
@@ -87,7 +87,7 @@ class ServerViewController extends Controller
|
||||
/**
|
||||
* Returns all the mounts that exist for the server.
|
||||
*/
|
||||
public function mounts(Request $request, Server $server): View
|
||||
public function mounts(Server $server): View
|
||||
{
|
||||
$server->load('mounts');
|
||||
|
||||
@@ -108,9 +108,9 @@ class ServerViewController extends Controller
|
||||
*
|
||||
* @throws \App\Exceptions\DisplayException
|
||||
*/
|
||||
public function manage(Request $request, Server $server): View
|
||||
public function manage(Server $server): View
|
||||
{
|
||||
if ($server->status === Server::STATUS_INSTALL_FAILED) {
|
||||
if ($server->status === ServerState::InstallFailed) {
|
||||
throw new DisplayException('This server is in a failed install state and cannot be recovered. Please delete and re-create the server.');
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ class ServerViewController extends Controller
|
||||
/**
|
||||
* Returns the server deletion page.
|
||||
*/
|
||||
public function delete(Request $request, Server $server): View
|
||||
public function delete(Server $server): View
|
||||
{
|
||||
return view('admin.servers.view.delete', compact('server'));
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\ServerState;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Response;
|
||||
use App\Models\Mount;
|
||||
use App\Models\Server;
|
||||
use App\Models\Database;
|
||||
use App\Models\MountServer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Prologue\Alerts\AlertsMessageBag;
|
||||
use App\Exceptions\DisplayException;
|
||||
@@ -69,18 +70,22 @@ class ServersController extends Controller
|
||||
* @throws \App\Exceptions\DisplayException
|
||||
* @throws \App\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function toggleInstall(Server $server): RedirectResponse
|
||||
public function toggleInstall(Server $server)
|
||||
{
|
||||
if ($server->status === Server::STATUS_INSTALL_FAILED) {
|
||||
if ($server->status === ServerState::InstallFailed) {
|
||||
throw new DisplayException(trans('admin/server.exceptions.marked_as_failed'));
|
||||
}
|
||||
|
||||
$server->status = $server->isInstalled() ? Server::STATUS_INSTALLING : null;
|
||||
$server->status = $server->isInstalled() ? ServerState::Installing : null;
|
||||
$server->save();
|
||||
|
||||
$this->alert->success(trans('admin/server.alerts.install_toggled'))->flash();
|
||||
Notification::make()
|
||||
->title('Success!')
|
||||
->body(trans('admin/server.alerts.install_toggled'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return redirect()->route('admin.servers.view.manage', $server->id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,12 +94,15 @@ class ServersController extends Controller
|
||||
* @throws \App\Exceptions\DisplayException
|
||||
* @throws \App\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function reinstallServer(Server $server): RedirectResponse
|
||||
public function reinstallServer(Server $server)
|
||||
{
|
||||
$this->reinstallService->handle($server);
|
||||
$this->alert->success(trans('admin/server.alerts.server_reinstalled'))->flash();
|
||||
|
||||
return redirect()->route('admin.servers.view.manage', $server->id);
|
||||
Notification::make()
|
||||
->title('Success!')
|
||||
->body(trans('admin/server.alerts.server_reinstalled'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +133,7 @@ class ServersController extends Controller
|
||||
$this->buildModificationService->handle($server, $request->only([
|
||||
'allocation_id', 'add_allocations', 'remove_allocations',
|
||||
'memory', 'swap', 'io', 'cpu', 'threads', 'disk',
|
||||
'database_limit', 'allocation_limit', 'backup_limit', 'oom_disabled',
|
||||
'database_limit', 'allocation_limit', 'backup_limit', 'oom_killer',
|
||||
]));
|
||||
} catch (DataValidationException $exception) {
|
||||
throw new ValidationException($exception->getValidator());
|
||||
@@ -227,12 +235,7 @@ class ServersController extends Controller
|
||||
*/
|
||||
public function addMount(Request $request, Server $server): RedirectResponse
|
||||
{
|
||||
$mountServer = (new MountServer())->forceFill([
|
||||
'mount_id' => $request->input('mount_id'),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
|
||||
$mountServer->saveOrFail();
|
||||
$server->mounts()->attach($request->input('mount_id'));
|
||||
|
||||
$this->alert->success('Mount was added successfully.')->flash();
|
||||
|
||||
@@ -244,7 +247,7 @@ class ServersController extends Controller
|
||||
*/
|
||||
public function deleteMount(Server $server, Mount $mount): RedirectResponse
|
||||
{
|
||||
MountServer::where('mount_id', $mount->id)->where('server_id', $server->id)->delete();
|
||||
$server->mounts()->detach($mount);
|
||||
|
||||
$this->alert->success('Mount was removed successfully.')->flash();
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class UserController extends Controller
|
||||
/**
|
||||
* Display user index page.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
public function index(): View
|
||||
{
|
||||
$users = QueryBuilder::for(
|
||||
User::query()->select('users.*')
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Application\DatabaseHosts;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Models\DatabaseHost;
|
||||
use Spatie\QueryBuilder\QueryBuilder;
|
||||
use App\Services\Databases\Hosts\HostUpdateService;
|
||||
use App\Services\Databases\Hosts\HostCreationService;
|
||||
use App\Transformers\Api\Application\DatabaseHostTransformer;
|
||||
use App\Http\Controllers\Api\Application\ApplicationApiController;
|
||||
use App\Http\Requests\Api\Application\DatabaseHosts\GetDatabaseHostRequest;
|
||||
use App\Http\Requests\Api\Application\DatabaseHosts\StoreDatabaseHostRequest;
|
||||
use App\Http\Requests\Api\Application\DatabaseHosts\DeleteDatabaseHostRequest;
|
||||
use App\Http\Requests\Api\Application\DatabaseHosts\UpdateDatabaseHostRequest;
|
||||
|
||||
class DatabaseHostController extends ApplicationApiController
|
||||
{
|
||||
/**
|
||||
* DatabaseHostController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private HostCreationService $creationService,
|
||||
private HostUpdateService $updateService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all the database hosts currently registered on the Panel.
|
||||
*/
|
||||
public function index(GetDatabaseHostRequest $request): array
|
||||
{
|
||||
$databases = QueryBuilder::for(DatabaseHost::query())
|
||||
->allowedFilters(['name', 'host'])
|
||||
->allowedSorts(['id', 'name', 'host'])
|
||||
->paginate($request->query('per_page') ?? 10);
|
||||
|
||||
return $this->fractal->collection($databases)
|
||||
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single database host.
|
||||
*/
|
||||
public function view(GetDatabaseHostRequest $request, DatabaseHost $databaseHost): array
|
||||
{
|
||||
return $this->fractal->item($databaseHost)
|
||||
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new database host on the Panel and return an HTTP/201 response code with the
|
||||
* new database host attached.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function store(StoreDatabaseHostRequest $request): JsonResponse
|
||||
{
|
||||
$databaseHost = $this->creationService->handle($request->validated());
|
||||
|
||||
return $this->fractal->item($databaseHost)
|
||||
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
|
||||
->addMeta([
|
||||
'resource' => route('api.application.databases.view', [
|
||||
'database_host' => $databaseHost->id,
|
||||
]),
|
||||
])
|
||||
->respond(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a database host on the Panel and return the updated record to the user.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function update(UpdateDatabaseHostRequest $request, DatabaseHost $databaseHost): array
|
||||
{
|
||||
$databaseHost = $this->updateService->handle($databaseHost->id, $request->validated());
|
||||
|
||||
return $this->fractal->item($databaseHost)
|
||||
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a database host from the Panel.
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(DeleteDatabaseHostRequest $request, DatabaseHost $databaseHost): Response
|
||||
{
|
||||
$databaseHost->delete();
|
||||
|
||||
return $this->returnNoContent();
|
||||
}
|
||||
}
|
||||
165
app/Http/Controllers/Api/Application/Mounts/MountController.php
Normal file
165
app/Http/Controllers/Api/Application/Mounts/MountController.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Application\Mounts;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Contracts\Translation\Translator;
|
||||
use Spatie\QueryBuilder\QueryBuilder;
|
||||
use App\Models\Mount;
|
||||
use App\Http\Controllers\Api\Application\ApplicationApiController;
|
||||
use App\Transformers\Api\Application\MountTransformer;
|
||||
use App\Http\Requests\Api\Application\Mounts\GetMountRequest;
|
||||
use App\Http\Requests\Api\Application\Mounts\StoreMountRequest;
|
||||
use App\Http\Requests\Api\Application\Mounts\DeleteMountRequest;
|
||||
use App\Http\Requests\Api\Application\Mounts\UpdateMountRequest;
|
||||
use App\Exceptions\Service\HasActiveServersException;
|
||||
|
||||
class MountController extends ApplicationApiController
|
||||
{
|
||||
/**
|
||||
* MountController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
protected Translator $translator
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all the mounts currently available on the Panel.
|
||||
*/
|
||||
public function index(GetMountRequest $request): array
|
||||
{
|
||||
$mounts = QueryBuilder::for(Mount::query())
|
||||
->allowedFilters(['uuid', 'name'])
|
||||
->allowedSorts(['id', 'uuid'])
|
||||
->paginate($request->query('per_page') ?? 50);
|
||||
|
||||
return $this->fractal->collection($mounts)
|
||||
->transformWith($this->getTransformer(MountTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return data for a single instance of a mount.
|
||||
*/
|
||||
public function view(GetMountRequest $request, Mount $mount): array
|
||||
{
|
||||
return $this->fractal->item($mount)
|
||||
->transformWith($this->getTransformer(MountTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new mount on the Panel. Returns the created mount and an HTTP/201
|
||||
* status response on success.
|
||||
*
|
||||
* @throws \App\Exceptions\Model\DataValidationException
|
||||
*/
|
||||
public function store(StoreMountRequest $request): JsonResponse
|
||||
{
|
||||
$model = (new Mount())->fill($request->validated());
|
||||
$model->forceFill(['uuid' => Uuid::uuid4()->toString()]);
|
||||
|
||||
$model->saveOrFail();
|
||||
$mount = $model->fresh();
|
||||
|
||||
return $this->fractal->item($mount)
|
||||
->transformWith($this->getTransformer(MountTransformer::class))
|
||||
->addMeta([
|
||||
'resource' => route('api.application.mounts.view', [
|
||||
'mount' => $mount->id,
|
||||
]),
|
||||
])
|
||||
->respond(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing mount on the Panel.
|
||||
*
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function update(UpdateMountRequest $request, Mount $mount): array
|
||||
{
|
||||
$mount->forceFill($request->validated())->save();
|
||||
|
||||
return $this->fractal->item($mount)
|
||||
->transformWith($this->getTransformer(MountTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a given mount from the Panel as long as there are no servers
|
||||
* currently attached to it.
|
||||
*
|
||||
* @throws \App\Exceptions\Service\HasActiveServersException
|
||||
*/
|
||||
public function delete(DeleteMountRequest $request, Mount $mount): JsonResponse
|
||||
{
|
||||
if ($mount->servers()->count() > 0) {
|
||||
throw new HasActiveServersException($this->translator->get('exceptions.mount.servers_attached'));
|
||||
}
|
||||
|
||||
$mount->delete();
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds eggs to the mount's many-to-many relation.
|
||||
*/
|
||||
public function addEggs(Request $request, Mount $mount): array
|
||||
{
|
||||
$validatedData = $request->validate([
|
||||
'eggs' => 'required|exists:eggs,id',
|
||||
]);
|
||||
|
||||
$eggs = $validatedData['eggs'] ?? [];
|
||||
if (count($eggs) > 0) {
|
||||
$mount->eggs()->attach($eggs);
|
||||
}
|
||||
|
||||
return $this->fractal->item($mount)
|
||||
->transformWith($this->getTransformer(MountTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds nodes to the mount's many-to-many relation.
|
||||
*/
|
||||
public function addNodes(Request $request, Mount $mount): array
|
||||
{
|
||||
$data = $request->validate(['nodes' => 'required|exists:nodes,id']);
|
||||
|
||||
$nodes = $data['nodes'] ?? [];
|
||||
if (count($nodes) > 0) {
|
||||
$mount->nodes()->attach($nodes);
|
||||
}
|
||||
|
||||
return $this->fractal->item($mount)
|
||||
->transformWith($this->getTransformer(MountTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an egg from the mount's many-to-many relation.
|
||||
*/
|
||||
public function deleteEgg(Mount $mount, int $egg_id): JsonResponse
|
||||
{
|
||||
$mount->eggs()->detach($egg_id);
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a node from the mount's many-to-many relation.
|
||||
*/
|
||||
public function deleteNode(Mount $mount, int $node_id): JsonResponse
|
||||
{
|
||||
$mount->nodes()->detach($node_id);
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class NodeController extends ApplicationApiController
|
||||
{
|
||||
$nodes = QueryBuilder::for(Node::query())
|
||||
->allowedFilters(['uuid', 'name', 'fqdn', 'daemon_token_id'])
|
||||
->allowedSorts(['id', 'uuid', 'memory', 'disk'])
|
||||
->allowedSorts(['id', 'uuid', 'memory', 'disk', 'cpu'])
|
||||
->paginate($request->query('per_page') ?? 50);
|
||||
|
||||
return $this->fractal->collection($nodes)
|
||||
|
||||
@@ -9,9 +9,6 @@ use App\Http\Requests\Api\Application\Nodes\GetDeployableNodesRequest;
|
||||
|
||||
class NodeDeploymentController extends ApplicationApiController
|
||||
{
|
||||
/**
|
||||
* NodeDeploymentController constructor.
|
||||
*/
|
||||
public function __construct(private FindViableNodesService $viableNodesService)
|
||||
{
|
||||
parent::__construct();
|
||||
@@ -21,16 +18,17 @@ class NodeDeploymentController extends ApplicationApiController
|
||||
* Finds any nodes that are available using the given deployment criteria. This works
|
||||
* similarly to the server creation process, but allows you to pass the deployment object
|
||||
* to this endpoint and get back a list of all Nodes satisfying the requirements.
|
||||
*
|
||||
* @throws \App\Exceptions\Service\Deployment\NoViableNodeException
|
||||
*/
|
||||
public function __invoke(GetDeployableNodesRequest $request): array
|
||||
{
|
||||
$data = $request->validated();
|
||||
$nodes = $this->viableNodesService
|
||||
->setMemory($data['memory'])
|
||||
->setDisk($data['disk'])
|
||||
->handle((int) $request->query('per_page'), (int) $request->query('page'));
|
||||
|
||||
$nodes = $this->viableNodesService->handle(
|
||||
$data['memory'] ?? 0,
|
||||
$data['disk'] ?? 0,
|
||||
$data['cpu'] ?? 0,
|
||||
$data['tags'] ?? $data['location_ids'] ?? [],
|
||||
);
|
||||
|
||||
return $this->fractal->collection($nodes)
|
||||
->transformWith($this->getTransformer(NodeTransformer::class))
|
||||
|
||||
@@ -33,7 +33,7 @@ class ServerController extends ApplicationApiController
|
||||
public function index(GetServersRequest $request): array
|
||||
{
|
||||
$servers = QueryBuilder::for(Server::query())
|
||||
->allowedFilters(['uuid', 'uuidShort', 'name', 'description', 'image', 'external_id'])
|
||||
->allowedFilters(['uuid', 'uuid_short', 'name', 'description', 'image', 'external_id'])
|
||||
->allowedSorts(['id', 'uuid'])
|
||||
->paginate($request->query('per_page') ?? 50);
|
||||
|
||||
@@ -50,7 +50,6 @@ class ServerController extends ApplicationApiController
|
||||
* @throws \App\Exceptions\DisplayException
|
||||
* @throws \App\Exceptions\Model\DataValidationException
|
||||
* @throws \App\Exceptions\Service\Deployment\NoViableAllocationException
|
||||
* @throws \App\Exceptions\Service\Deployment\NoViableNodeException
|
||||
*/
|
||||
public function store(StoreServerRequest $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Application\Servers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use App\Models\Server;
|
||||
use App\Services\Servers\SuspensionService;
|
||||
use App\Services\Servers\ReinstallServerService;
|
||||
use App\Http\Requests\Api\Application\Servers\ServerWriteRequest;
|
||||
use App\Http\Controllers\Api\Application\ApplicationApiController;
|
||||
use App\Http\Requests\Api\Application\Servers\ServerWriteRequest;
|
||||
use App\Models\Server;
|
||||
use App\Repositories\Daemon\DaemonServerRepository;
|
||||
use App\Services\Servers\ReinstallServerService;
|
||||
use App\Services\Servers\SuspensionService;
|
||||
use App\Services\Servers\TransferServerService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class ServerManagementController extends ApplicationApiController
|
||||
{
|
||||
@@ -16,7 +18,9 @@ class ServerManagementController extends ApplicationApiController
|
||||
*/
|
||||
public function __construct(
|
||||
private ReinstallServerService $reinstallServerService,
|
||||
private SuspensionService $suspensionService
|
||||
private SuspensionService $suspensionService,
|
||||
private TransferServerService $transferServerService,
|
||||
private DaemonServerRepository $daemonServerRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -57,4 +61,44 @@ class ServerManagementController extends ApplicationApiController
|
||||
|
||||
return $this->returnNoContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a transfer of a server to a new node.
|
||||
*/
|
||||
public function startTransfer(ServerWriteRequest $request, Server $server): Response
|
||||
{
|
||||
$validatedData = $request->validate([
|
||||
'node_id' => 'required|exists:nodes,id',
|
||||
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
|
||||
'allocation_additional' => 'nullable',
|
||||
]);
|
||||
|
||||
if ($this->transferServerService->handle($server, $validatedData)) {
|
||||
// Transfer started
|
||||
$this->returnNoContent();
|
||||
} else {
|
||||
// Node was not viable
|
||||
return new Response('', Response::HTTP_NOT_ACCEPTABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a transfer of a server to a new node.
|
||||
*
|
||||
* @throws \App\Exceptions\Http\Connection\DaemonConnectionException
|
||||
*/
|
||||
public function cancelTransfer(ServerWriteRequest $request, Server $server): Response
|
||||
{
|
||||
if (!$transfer = $server->transfer) {
|
||||
// Server is not transferring
|
||||
return new Response('', Response::HTTP_NOT_ACCEPTABLE);
|
||||
}
|
||||
|
||||
$transfer->successful = true;
|
||||
$transfer->save();
|
||||
|
||||
$this->daemonServerRepository->setServer($server)->cancelTransfer();
|
||||
|
||||
return $this->returnNoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Client\Servers;
|
||||
|
||||
use App\Enums\ServerState;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Backup;
|
||||
use App\Models\Server;
|
||||
@@ -212,7 +213,7 @@ class BackupController extends ClientApiController
|
||||
|
||||
// Update the status right away for the server so that we know not to allow certain
|
||||
// actions against it via the Panel API.
|
||||
$server->update(['status' => Server::STATUS_RESTORING_BACKUP]);
|
||||
$server->update(['status' => ServerState::RestoringBackup]);
|
||||
|
||||
$this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate'));
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ class StartupController extends ClientApiController
|
||||
$startup = $this->startupCommandService->handle($server);
|
||||
|
||||
return $this->fractal->collection(
|
||||
$server->variables()->where('user_viewable', true)->get()
|
||||
$server->variables()->orderBy('sort')->where('user_viewable', true)->get()
|
||||
)
|
||||
->transformWith($this->getTransformer(EggVariableTransformer::class))
|
||||
->addMeta([
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use App\Exceptions\Http\HttpForbiddenException;
|
||||
|
||||
class BackupRemoteUploadController extends Controller
|
||||
{
|
||||
@@ -32,18 +33,32 @@ class BackupRemoteUploadController extends Controller
|
||||
*/
|
||||
public function __invoke(Request $request, string $backup): JsonResponse
|
||||
{
|
||||
// Get the node associated with the request.
|
||||
/** @var \App\Models\Node $node */
|
||||
$node = $request->attributes->get('node');
|
||||
|
||||
// Get the size query parameter.
|
||||
$size = (int) $request->query('size');
|
||||
if (empty($size)) {
|
||||
throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.');
|
||||
}
|
||||
|
||||
/** @var \App\Models\Backup $backup */
|
||||
$backup = Backup::query()->where('uuid', $backup)->firstOrFail();
|
||||
/** @var \App\Models\Backup $model */
|
||||
$model = Backup::query()
|
||||
->where('uuid', $backup)
|
||||
->firstOrFail();
|
||||
|
||||
// Check that the backup is "owned" by the node making the request. This avoids other nodes
|
||||
// from messing with backups that they don't own.
|
||||
/** @var \App\Models\Server $server */
|
||||
$server = $model->server;
|
||||
if ($server->node_id !== $node->id) {
|
||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
||||
}
|
||||
|
||||
// Prevent backups that have already been completed from trying to
|
||||
// be uploaded again.
|
||||
if (!is_null($backup->completed_at)) {
|
||||
if (!is_null($model->completed_at)) {
|
||||
throw new ConflictHttpException('This backup is already in a completed state.');
|
||||
}
|
||||
|
||||
@@ -54,7 +69,7 @@ class BackupRemoteUploadController extends Controller
|
||||
}
|
||||
|
||||
// The path where backup will be uploaded to
|
||||
$path = sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid);
|
||||
$path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid);
|
||||
|
||||
// Get the S3 client
|
||||
$client = $adapter->getClient();
|
||||
@@ -92,7 +107,7 @@ class BackupRemoteUploadController extends Controller
|
||||
}
|
||||
|
||||
// Set the upload_id on the backup in the database.
|
||||
$backup->update(['upload_id' => $params['UploadId']]);
|
||||
$model->update(['upload_id' => $params['UploadId']]);
|
||||
|
||||
return new JsonResponse([
|
||||
'parts' => $parts,
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use App\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
|
||||
use App\Exceptions\Http\HttpForbiddenException;
|
||||
|
||||
class BackupStatusController extends Controller
|
||||
{
|
||||
@@ -30,8 +31,22 @@ class BackupStatusController extends Controller
|
||||
*/
|
||||
public function index(ReportBackupCompleteRequest $request, string $backup): JsonResponse
|
||||
{
|
||||
// Get the node associated with the request.
|
||||
/** @var \App\Models\Node $node */
|
||||
$node = $request->attributes->get('node');
|
||||
|
||||
/** @var \App\Models\Backup $model */
|
||||
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
|
||||
$model = Backup::query()
|
||||
->where('uuid', $backup)
|
||||
->firstOrFail();
|
||||
|
||||
// Check that the backup is "owned" by the node making the request. This avoids other nodes
|
||||
// from messing with backups that they don't own.
|
||||
/** @var \App\Models\Server $server */
|
||||
$server = $model->server;
|
||||
if ($server->node_id !== $node->id) {
|
||||
throw new HttpForbiddenException('You do not have permission to access that backup.');
|
||||
}
|
||||
|
||||
if ($model->is_successful) {
|
||||
throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.');
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Remote\Servers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class ServerContainersController extends Controller
|
||||
{
|
||||
/**
|
||||
* Updates the server container's status on the Panel
|
||||
*/
|
||||
public function status(Server $server, Request $request): JsonResponse
|
||||
{
|
||||
$status = fluent($request->json()->all())->get('data.new_state');
|
||||
|
||||
cache()->set("servers.$server->uuid.container.status", $status, now()->addHour());
|
||||
|
||||
return new JsonResponse([]);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Remote\Servers;
|
||||
|
||||
use App\Enums\ServerState;
|
||||
use App\Models\Backup;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Server;
|
||||
@@ -81,7 +82,7 @@ class ServerDetailsController extends Controller
|
||||
->latest('timestamp'),
|
||||
])
|
||||
->where('node_id', $node->id)
|
||||
->where('status', Server::STATUS_RESTORING_BACKUP)
|
||||
->where('status', ServerState::RestoringBackup)
|
||||
->get();
|
||||
|
||||
$this->connection->transaction(function () use ($node, $servers) {
|
||||
@@ -108,7 +109,7 @@ class ServerDetailsController extends Controller
|
||||
// Update any server marked as installing or restoring as being in a normal state
|
||||
// at this point in the process.
|
||||
Server::query()->where('node_id', $node->id)
|
||||
->whereIn('status', [Server::STATUS_INSTALLING, Server::STATUS_RESTORING_BACKUP])
|
||||
->whereIn('status', [ServerState::Installing, ServerState::RestoringBackup])
|
||||
->update(['status' => null]);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Remote\Servers;
|
||||
|
||||
use App\Enums\ServerState;
|
||||
use Illuminate\Http\Response;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -36,16 +37,16 @@ class ServerInstallController extends Controller
|
||||
|
||||
// Make sure the type of failure is accurate
|
||||
if (!$request->boolean('successful')) {
|
||||
$status = Server::STATUS_INSTALL_FAILED;
|
||||
$status = ServerState::InstallFailed;
|
||||
|
||||
if ($request->boolean('reinstall')) {
|
||||
$status = Server::STATUS_REINSTALL_FAILED;
|
||||
$status = ServerState::ReinstallFailed;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the server suspended if it's already suspended
|
||||
if ($server->status === Server::STATUS_SUSPENDED) {
|
||||
$status = Server::STATUS_SUSPENDED;
|
||||
if ($server->status === ServerState::Suspended) {
|
||||
$status = ServerState::Suspended;
|
||||
}
|
||||
|
||||
$previouslyInstalledAt = $server->installed_at;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user