Compare commits

..

416 Commits

Author SHA1 Message Date
Lance Pioch
c5b441e54a Merge branch 'main' of github.com:pelican-dev/panel 2024-05-11 22:08:30 -04:00
Lance Pioch
30452890f5 Fix mounts 2024-05-11 22:08:23 -04:00
notCharles
af18a3704f Fix Creating Eggs 2024-05-11 22:02:57 -04:00
Lance Pioch
2c98693bd2 Replace hard coded url 2024-05-11 21:56:12 -04:00
Lance Pioch
259599b441 Merge branch 'main' of github.com:pelican-dev/panel 2024-05-11 21:50:04 -04:00
Lance Pioch
2da058be49 Force reset allocations 2024-05-11 21:50:00 -04:00
Lance Pioch
8d33e20f6c Create these one by one 2024-05-11 21:49:12 -04:00
Lance Pioch
b4e8f0586a Limit to valid ports 2024-05-11 21:48:46 -04:00
Lance Pioch
6f8f5e2746 Don’t handle livewire 2024-05-11 21:48:36 -04:00
notCharles
37cc3ae20d Oopsie Woopsie 2024-05-11 21:39:00 -04:00
Lance Pioch
9fc46b9ae5 Add default in 2024-05-11 21:01:15 -04:00
Lance Pioch
d409ff037c Fix route 2024-05-11 21:01:07 -04:00
Lance Pioch
000363cd17 Pint 2024-05-11 21:01:01 -04:00
Lance Pioch
2beb12c04f Swap over routes 2024-05-11 20:50:10 -04:00
Lance Pioch
373ede8548 Make sure transaction is finished 2024-05-11 20:19:56 -04:00
notCharles
e286100197 Fix migrations to work with SQLite without needing SQLite CLI 2024-05-11 20:09:22 -04:00
notCharles
082163389a update gitignore 2024-05-11 18:56:55 -04:00
notCharles
f9247c9318 Update Nav Bar, '/new-admin' > '/panel'
We cannot replace `/admin` as some functions still call the old admin area, Exporting Eggs
2024-05-11 18:54:47 -04:00
notCharles
5a00b8690d update name 2024-05-11 18:43:48 -04:00
notCharles
c0ca189536 Update CreateEgg to match EditEgg
Creating Eggs still broken.
2024-05-11 18:42:24 -04:00
kubi
84a3ceeae3 Update release.yaml 2024-05-11 14:44:58 -07:00
Lance Pioch
1c499d84cf Merge branch 'main' of github.com:pelican-dev/panel 2024-05-11 17:39:01 -04:00
Lance Pioch
871e93a38c Add scramble api docs 2024-05-11 17:38:44 -04:00
notCharles
1481338eb9 Fix Egg delete button logic 2024-05-11 17:21:06 -04:00
kubi
1e4fadda24 Update release.yaml (#196) 2024-05-11 12:31:15 -07:00
Lance Pioch
585fe8d1a1 Add basic widgets for now 2024-05-11 15:25:37 -04:00
Lance Pioch
f58c697d28 Add this for the future 2024-05-11 14:10:06 -04:00
Lance Pioch
b32f8966e1 Revert "Remove yarn"
This reverts commit 602c1ed9a6.
2024-05-11 00:55:35 -04:00
Lance Pioch
b18ebeefdc Merge branch 'main' of github.com:pelican-dev/panel 2024-05-10 22:20:20 -04:00
Lance Pioch
5fd7e419d9 Run pint 2024-05-10 22:20:14 -04:00
Lance Pioch
602c1ed9a6 Remove yarn 2024-05-10 22:19:51 -04:00
Lance Pioch
39bc87c2e2 Swap suspend over 2024-05-10 22:19:17 -04:00
notCharles
44cc5a8132 Sort server vars for front end 2024-05-10 21:31:32 -04:00
Lance Pioch
0c7ae26313 Fix faker 2024-05-10 20:24:01 -04:00
Lance Pioch
102955bf6a Merge branch 'main' of github.com:pelican-dev/panel 2024-05-10 19:59:57 -04:00
Lance Pioch
72eb3ce467 Generate a new key if it doesn’t exist 2024-05-10 19:59:33 -04:00
notCharles
d656f21cd9 Add view link on server listing 2024-05-10 19:33:53 -04:00
notCharles
8da5afb35e disable delete if node has servers 2024-05-10 17:57:42 -04:00
notCharles
44e9da93b6 Add variable permission to edit/create egg + export 2024-05-10 17:53:16 -04:00
notCharles
29e1bd4757 Color Export link 2024-05-10 17:01:39 -04:00
notCharles
f1493c5139 Fix creating api keys 2024-05-10 16:42:05 -04:00
notCharles
30a668c84a Fix making databases when using sqlite for panel 2024-05-10 16:26:29 -04:00
notCharles
9f4bf8777e encrypt the database hosts password 2024-05-10 16:15:10 -04:00
notCharles
dfe2e9d629 Pint & Add application features to create page 2024-05-09 19:43:46 -04:00
notCharles
607e186082 Remove author variable from cli help 2024-05-08 20:20:02 -04:00
notCharles
e60c86a87e un-break ui building wf 2024-05-08 20:17:13 -04:00
Lance Pioch
577479edaf Merge pull request #188 from QuintenQVD0/main
Some simple updates
2024-05-08 10:28:23 -04:00
Quinten
4423baa1e7 7z was already their. 2024-05-08 12:14:44 +02:00
Quinten
d967681227 Swap all docker images and add java 21 to all minecraft eggs 2024-05-08 12:05:14 +02:00
Quinten
f391edda27 Fix minecraft Eula link 2024-05-08 11:44:59 +02:00
Quinten
255030136f allow decompressing .7z files 2024-05-08 11:43:47 +02:00
Lance Pioch
f79d304586 Remove facade usage 2024-05-08 00:04:57 -04:00
Lance Pioch
60772b1775 Add new cluster 2024-05-07 23:54:53 -04:00
Lance Pioch
bf3ef435ae Update config 2024-05-07 22:17:28 -04:00
Lance Pioch
30411ccd13 Don’t need this anymore 2024-05-07 22:14:04 -04:00
Lance Pioch
bd8ca0abcf Don’t run this for the merfolk 2024-05-07 22:13:54 -04:00
Lance Pioch
6621ece3a1 Don’t ask for the author 2024-05-07 22:13:46 -04:00
Lance Pioch
8f4b68617a Use the current user as the new author 2024-05-07 22:00:40 -04:00
Lance Pioch
1b3017222e Time zone should always be UTC
Will implement display time zones on a per user basis
2024-05-07 22:00:24 -04:00
Lance Pioch
a050fbd2d3 Copy example env over for user 2024-05-07 21:52:51 -04:00
notCharles
fe3bf88ea4 Fix editing server resources 2024-05-07 21:44:44 -04:00
notCharles
94f583fef0 Pint 2024-05-07 20:55:02 -04:00
Lance Pioch
8da0017eaf Make things work
Co-authored-by: notCharles <charles@pelican.dev>
2024-05-07 20:47:58 -04:00
Lance Pioch
4e838201c6 Merge branch 'main' of github.com:pelican-dev/panel 2024-05-07 20:00:08 -04:00
Lance Pioch
111b8b3cda Add specific logic for CF 2024-05-07 20:00:02 -04:00
Lance Pioch
d0d388534b Add http status code enum 2024-05-07 19:59:52 -04:00
Lance Pioch
939b7354e4 Return basic response 2024-05-07 19:59:41 -04:00
Lance Pioch
7077693da2 Comment this for now 2024-05-07 19:59:27 -04:00
notCharles
9c047d0a45 revert new buttons on server create 2024-05-07 18:19:51 -04:00
notCharles
f13dbfa766 Remove live on toggles 2024-05-07 17:01:55 -04:00
notCharles
d4dd1349da Add defaults to create server. 2024-05-07 16:52:34 -04:00
Boy132
1db8c209fb fix schedule process command (#187) 2024-05-06 23:55:05 -07:00
Lance Pioch
852d2b7431 Merge branch 'main' of github.com:pelican-dev/panel 2024-05-07 00:11:02 -04:00
Lance Pioch
6468f85cb0 Fix #164 and bypass the casting issue
https://github.com/filamentphp/filament/issues/12116#issuecomment-2097408424
2024-05-07 00:10:46 -04:00
kubi
5c61865dfb Update cla.yaml (#185)
Try using a new personal access token
2024-05-06 21:00:43 -07:00
kubi
3feb9d2304 Update cla.yaml (#184)
Update to the latest release and remove unnecessary permission when using a remote repository.
2024-05-06 20:50:15 -07:00
notCharles
f17ac6ffac Add new resource view to create server 2024-05-06 22:05:07 -04:00
notCharles
04675a73fd Make pretty 2024-05-06 22:05:03 -04:00
notCharles
e2353af0d8 Fix editing egg-variables 2024-05-06 22:05:00 -04:00
Lance Pioch
8eaf64b5fd Merge pull request #100 from Poseidon281/Command-Translations
Translation file for commands & tiny cleanup
2024-05-06 22:04:55 -04:00
Lance Pioch
e5c5bc40d9 Remove these casts for now 2024-05-06 22:03:59 -04:00
Lance Pioch
5286f446dc Merge pull request #139 from Boy132/database/sqlite
Support SQLite
2024-05-05 15:17:08 -04:00
Lance Pioch
abbf2038a7 Fix pint issue 2024-05-05 15:14:04 -04:00
Lance Pioch
52026ca9e4 Update egg creation process 2024-05-04 18:45:26 -04:00
Lance Pioch
0b0c4bb434 Switch to select 2024-05-04 15:26:08 -04:00
Lance Pioch
22aa56d306 Rearrange these 2024-05-04 15:25:52 -04:00
Lance Pioch
3b935a1eea No need to mess with this for now 2024-05-04 15:20:51 -04:00
Lance Pioch
146421ee52 Add suggestions for egg features 2024-05-04 15:20:10 -04:00
Lance Pioch
9875942191 Rearrange these 2024-05-04 15:19:57 -04:00
Lance Pioch
3d2b18140a Hide this 2024-05-04 15:19:40 -04:00
Lance Pioch
ec0882cd14 Redo how docker images work 2024-05-04 15:05:15 -04:00
Lance Pioch
48c97ee1cc Generate name automatically for server if egg is selected 2024-05-04 13:39:57 -04:00
Lance Pioch
17787fee18 Forbid built in webserver due to unresolvable issues 2024-05-04 13:12:33 -04:00
Boy132
f2a59002bc simplify AllocationSelectionService 2024-05-02 09:58:28 +02:00
Boy132
98419bc625 update sql schemas 2024-05-02 09:58:28 +02:00
Boy132
fb596fa4f9 update IntegrationTestCase 2024-05-02 09:58:28 +02:00
Boy132
7a4289cee1 add default value for "ignored_files" in backup factory 2024-05-02 09:58:27 +02:00
Boy132
488acce564 update database settings command 2024-05-02 09:58:27 +02:00
Boy132
3f3b500a14 update ci tests 2024-05-02 09:58:27 +02:00
Boy132
29803bbaf2 change default db driver in example .env to "sqlite" 2024-05-02 09:58:27 +02:00
Boy132
e07eabc579 set default value for "tags" 2024-05-02 09:58:27 +02:00
Boy132
e82a3b838c update AllocationSelectionService 2024-05-02 09:58:27 +02:00
Boy132
9761c3762d add config for sqlite 2024-05-02 09:58:27 +02:00
Lance Pioch
1ecfcc611f Pint fixes 2024-04-30 19:49:18 -04:00
Lance Pioch
ad60b437ce Merge pull request #165 from parkervcp/issue/fix_discord_button
make discord button blurple
2024-04-30 19:48:37 -04:00
Scai
cc5208cc6b fix prettier linting issue 2024-04-30 22:59:30 +03:00
Michael (Parker) Parker
a9212d9e7d remove extra comma 2024-04-30 08:16:06 -04:00
Michael (Parker) Parker
efcf22d837 make discord button blurple
changes the discord button in the admin page to discords official 'blurple' color.
2024-04-29 22:55:53 -04:00
Lance Pioch
d4a02336aa Merge branch 'main' of github.com:pelican-dev/panel 2024-04-29 22:42:21 -04:00
Lance Pioch
386eba28e6 Allow searching by tags 2024-04-29 22:42:15 -04:00
Lance Pioch
651b887a0e Allow this to be changed 2024-04-29 22:42:04 -04:00
Lance Pioch
2982757649 Don’t need to show this 2024-04-29 22:41:58 -04:00
Lance Pioch
04a8999e0b Default this to true 2024-04-29 22:41:46 -04:00
Lance Pioch
41ee9e563c Fix saving tags 2024-04-29 22:41:39 -04:00
notCharles
16a16dc390 Add console button 2024-04-29 22:13:54 -04:00
notCharles
80155a17e5 remove add allocation btn 2024-04-29 21:42:02 -04:00
notCharles
26da0c5e74 Add server url to allocations relationship manager 2024-04-29 21:35:16 -04:00
notCharles
27059e7b99 Fix edit server docker image selection 2024-04-29 21:34:49 -04:00
notCharles
fdc51e03ac Change colors 2024-04-29 21:34:27 -04:00
Lance Pioch
549ab12048 Pint 2024-04-29 20:17:14 -04:00
Lance Pioch
fb95a3d923 Replace with relation manager 2024-04-29 20:15:59 -04:00
Lance Pioch
70da7c0f51 Add allocations to node pages 2024-04-29 20:06:34 -04:00
Lance Pioch
d7051bb7ed Add icon to tab 2024-04-29 20:06:18 -04:00
Lance Pioch
dd8f28b864 Increase cache 2024-04-29 15:02:12 -04:00
Lance Pioch
8b060e8834 Style fixes 2024-04-29 14:29:33 -04:00
Lance Pioch
7b66e1ce33 Merge branch 'main' of github.com:pelican-dev/panel 2024-04-29 14:29:10 -04:00
Lance Pioch
5d2248ab1f Implement container statuses from wings 2024-04-29 14:29:04 -04:00
Lance Pioch
7df5f12c75 Show select box for specific egg variables 2024-04-28 17:43:26 -04:00
Lance Pioch
b0dadc60f2 Make this form look nicer 2024-04-28 17:43:15 -04:00
Lance Pioch
36b7998714 Remove dd 2024-04-28 17:42:40 -04:00
notCharles
d42fc88535 Update Server Resources, remove defaults 2024-04-28 14:25:40 -04:00
Lance Pioch
4ebc67aab0 Fix updating server variables and some small cleanup 2024-04-28 12:57:56 -04:00
Lance Pioch
5bef99611b Remove unused imports 2024-04-27 23:08:49 -04:00
Lance Pioch
191f1456ee Update passed in hidden data to include actual changed server variable values 2024-04-27 23:07:21 -04:00
Lance Pioch
a839078c7d Merge branch 'main' of github.com:pelican-dev/panel 2024-04-27 22:49:36 -04:00
Lance Pioch
2d7804311d Populate these fields for server variables 2024-04-27 22:49:27 -04:00
Lance Pioch
6b0c1d136b Prevent duplicated server variables 2024-04-27 22:49:18 -04:00
notCharles
383845ca62 fix saving/editing server egg vars 2024-04-27 22:35:10 -04:00
Lance Pioch
a9b755ae2d Merge branch 'main' of github.com:pelican-dev/panel 2024-04-27 22:18:34 -04:00
Lance Pioch
6ed85cfdc6 Better icon 2024-04-27 22:18:25 -04:00
notCharles
137b6040ab Fix Backups 2024-04-27 22:08:51 -04:00
notCharles
da08b60f20 hide checkbox if servers > 0 2024-04-27 21:39:37 -04:00
iamkubi
267952d750 Merge pull request #162 from pelican-dev/backup_fix
Fix backups
2024-04-27 02:02:17 -07:00
kubi
38aea0edbe Fix backups 2024-04-27 08:52:27 +00:00
Charles
51bb60c3b1 Add emptyState 2024-04-26 06:49:11 -04:00
Lance Pioch
8f2413dc7e Add front end translations
Co-authored-by: Miniontoby <tobias.gaarenstroom@gmail.com>
2024-04-25 23:33:08 -04:00
Lance Pioch
1bdef318f0 Start adding translations to backend
Co-authored-by: Miniontoby <tobias.gaarenstroom@gmail.com>
2024-04-25 23:29:19 -04:00
Lance Pioch
2efb807f0b Merge branch 'main' of github.com:pelican-dev/panel 2024-04-25 22:44:45 -04:00
Lance Pioch
be35692125 Add basic relationship manager for allocations 2024-04-25 22:36:59 -04:00
Lance Pioch
076125485d Don’t need to specify this param 2024-04-25 22:36:36 -04:00
Lance Pioch
1800f105d7 Simplify logic greatly 2024-04-25 20:31:46 -04:00
Lance Pioch
933e693897 Put something here 2024-04-25 20:31:38 -04:00
Lance Pioch
97ff693e5c Don’t update status/state 2024-04-25 19:48:46 -04:00
Lance Pioch
7853cdc9ed Return default 2024-04-25 19:15:45 -04:00
notCharles
cba00d822c minor fixes for random stuff 2024-04-25 18:57:21 -04:00
Lance Pioch
00502f6d4d Allow suspension of servers 2024-04-25 17:45:49 -04:00
Lance Pioch
7ffd7019a2 Always default this 2024-04-25 17:35:35 -04:00
Lance Pioch
ea146f4715 Resource changes 2024-04-25 17:35:27 -04:00
notCharles
85b250d016 Fix deleting servers 2024-04-23 20:33:54 -04:00
notCharles
7bbbba37f5 Add force delete for servers 2024-04-23 20:03:57 -04:00
notCharles
07244c38eb refactor resources 2024-04-23 19:45:11 -04:00
notCharles
50f9dde280 Render egg variables by sort 2024-04-23 17:57:20 -04:00
notCharles
76a3197022 Remove un-needed code 2024-04-23 17:55:43 -04:00
Charles
f26628a546 remove unused imports / options 2024-04-23 06:40:12 -04:00
Charles
ceb365b95c Split create/edit server pages 2024-04-23 06:28:23 -04:00
notCharles
b0a2bae0b5 Don't need to display the mount id 2024-04-22 19:04:39 -04:00
notCharles
426b82754d remove search bar 2024-04-22 18:48:09 -04:00
notCharles
2f82229048 Fix Container/Server Status + Mobile Styling 2024-04-22 18:03:34 -04:00
notCharles
65bfda1034 Add Server list to node page 2024-04-22 17:47:16 -04:00
notCharles
2328f07473 fix egg variables on server edit page 2024-04-22 17:02:47 -04:00
Scai
ad2e48cfc1 Merge pull request #138 from Boy132/update/webpack
Update Webpack & change to node LTS version for Dockerfile
2024-04-22 19:37:43 +03:00
Boy132
cca5e4a4c0 add php 8.3 to composer.json 2024-04-22 09:10:01 +02:00
Boy132
17ec5c7acf use node lts version for dockerfile 2024-04-22 08:58:05 +02:00
Boy132
4708105104 set min node version to 18 2024-04-22 08:57:08 +02:00
Boy132
19d2066a1a update webpack to 4.47.0 2024-04-22 08:55:49 +02:00
Lance Pioch
ae3a355a99 Create security.md 2024-04-21 21:32:42 -04:00
Lance Pioch
1996ffe724 Update license 2024-04-21 21:25:13 -04:00
Lance Pioch
edf9bc6f4d Merge pull request #137 from pelican-dev/feature/72
Allow Egg Variables to be orderable/sortable
2024-04-21 21:21:35 -04:00
notCharles
c31eafaf4f Pint 2024-04-21 21:16:05 -04:00
notCharles
1a884c0cdf Save sort order 2024-04-21 21:11:18 -04:00
notCharles
e343de00c0 Merge branch 'feature/72' of https://github.com/pelican-dev/panel into feature/72 2024-04-21 21:09:36 -04:00
Lance Pioch
0f360fcdd1 Merge branch 'main' into feature/72
# Conflicts:
#	app/Filament/Resources/EggResource.php
2024-04-21 21:08:57 -04:00
Lance Pioch
a9a18464dd Wip 2024-04-21 21:06:21 -04:00
Lance Pioch
e47beb59d2 Merge branch 'main' of github.com:pelican-dev/panel 2024-04-21 20:53:22 -04:00
Lance Pioch
d4ff502e08 Make error message more helpful 2024-04-21 20:52:24 -04:00
Lance Pioch
8c9c2c080a Fix creating egg variables 2024-04-21 20:52:18 -04:00
Lance Pioch
aaf7429298 New Crowdin updates (#125) 2024-04-21 17:33:35 -04:00
notCharles
329268697b Merge branch 'main' into feature/72 2024-04-21 16:22:48 -04:00
Lance Pioch
0b950832c2 Merge branch 'main' of github.com:pelican-dev/panel 2024-04-21 16:18:37 -04:00
Lance Pioch
788056d55d Small updates 2024-04-21 16:18:13 -04:00
Lance Pioch
dfaff50ca1 Fix egg variable saving 2024-04-21 15:57:15 -04:00
notCharles
145568237c Rename columns 2024-04-21 15:50:46 -04:00
notCharles
9baaff53cd Update node allocation page 2024-04-21 15:27:05 -04:00
Lance Pioch
3ad622dd69 Add new completed language 2024-04-21 15:16:03 -04:00
Lance Pioch
906a1d7f3e Merge pull request #66 from pelican-dev/issue/fix-3
Change columns
2024-04-21 14:21:16 -04:00
Lance Pioch
ba7a5d5126 Be explicit about this being a string 2024-04-21 14:17:32 -04:00
notCharles
693c65995d Merge branch 'issue/fix-3' of https://github.com/pelican-dev/panel into issue/fix-3 2024-04-21 14:09:35 -04:00
notCharles
85f7bf30b9 fix daemon_base 2024-04-21 14:08:40 -04:00
notCharles
53ad87a349 update egg configuration tab 2024-04-21 13:30:55 -04:00
Scai
7a034c1abf Merge pull request #103 from Boy132/update/dockerfile 2024-04-21 17:33:17 +03:00
notCharles
eeee5779ba Merge branch 'main' into feature/72 2024-04-21 10:14:21 -04:00
Charles
6ff9568760 Merge pull request #115 from pelican-dev/feature/mult-egg-upload
Allow importing of multiple eggs at once
2024-04-21 10:06:21 -04:00
notCharles
7de4cf1417 Allow desc to be null
Desc's are allowed to be null as they're not a required field.
2024-04-21 10:04:16 -04:00
notCharles
db67c64da0 We already know we're importing eggs... 2024-04-21 09:52:08 -04:00
Charles
8476f89f19 Merge branch 'main' into feature/mult-egg-upload 2024-04-21 09:47:46 -04:00
Boy132
01f89d7855 add nodejs 21 to build workflow 2024-04-21 15:14:57 +02:00
Boy132
fa46f78fd5 update dockerfile to php 8.3
add php intl extension
change to official nodejs image and to nodejs 21
2024-04-21 15:14:57 +02:00
Lance Pioch
3e239f9caa Fix tabs 2024-04-21 01:00:48 -04:00
Lance Pioch
204734914d Rename to normal 2024-04-20 22:55:21 -04:00
Lance Pioch
67edf4f472 Adding and deleting api keys 2024-04-20 21:01:41 -04:00
Lance Pioch
7693106a44 Order in reverse chronological order 2024-04-20 20:22:40 -04:00
Lance Pioch
d8f5e1506c Persist this for now 2024-04-20 20:22:17 -04:00
Lance Pioch
330b3bb496 Update languages 2024-04-20 20:21:45 -04:00
notCharles
ac3a36e489 Updates 2024-04-20 18:08:05 -04:00
notCharles
22c03c8075 add sort column 2024-04-20 17:57:14 -04:00
Lance Pioch
bc972da982 Merge branch 'main' of github.com:pelican-dev/panel 2024-04-19 22:33:01 -04:00
Lance Pioch
d9738949c1 Some progress 2024-04-19 22:32:57 -04:00
Lance Pioch
1c0e91a301 Show missing if we can’t connect 2024-04-19 22:32:52 -04:00
Lance Pioch
3db6593b0e Cast the enum to a string 2024-04-19 22:32:30 -04:00
Lance Pioch
2bb1caf308 Better color 2024-04-19 22:32:20 -04:00
Lance Pioch
807a6f02fd New Crowdin updates (#110) 2024-04-19 22:31:46 -04:00
Lance Pioch
367b9bd154 Fix this up 2024-04-19 21:59:11 -04:00
Lance Pioch
d30accbc71 Switch this back to datalist 2024-04-19 21:58:45 -04:00
Lance Pioch
716d298b75 Styling fixes 2024-04-19 18:08:17 -04:00
Charles
05c4610654 Allow uploading multiple eggs
I'm sure there is a cleaner way to do it, but this works :)

Also ran pint...
2024-04-19 12:59:29 -04:00
Charles
82c294ab63 Make allocations mobile friendly 2024-04-19 10:18:51 -04:00
Lance Pioch
ee142a26b0 Small refactor 2024-04-19 01:33:14 -04:00
Lance Pioch
dea310e9ab Do some fancy ports 2024-04-19 01:33:06 -04:00
Lance Pioch
daf2cb0ebc Specify split keys 2024-04-19 00:36:22 -04:00
Lance Pioch
2812129d00 Pull ip addresses into selector 2024-04-19 00:36:02 -04:00
Lance Pioch
89b6f70cde Merge pull request #108 from NeonSpectrum/fix/daemon-http-params
Fix missed adjustments on http parameters
2024-04-19 00:34:11 -04:00
Lance Pioch
ad372a754e Update app/Repositories/Daemon/DaemonFileRepository.php 2024-04-19 00:33:42 -04:00
NeonSpectrum
c0b1345e90 Remove withQueryParameters on get request 2024-04-19 11:37:02 +08:00
NeonSpectrum
11e6430d42 Fix missed adjustments on http parameters 2024-04-19 11:29:35 +08:00
Lance Pioch
1e10f250b4 Merge branch 'main' of github.com:pelican-dev/panel 2024-04-18 23:15:18 -04:00
Lance Pioch
4b23703f99 Cache the statuses for a bit 2024-04-18 23:07:15 -04:00
Lance Pioch
bab4315bb7 Don’t allow eggs to be selected for deletion 2024-04-18 23:06:57 -04:00
Lance Pioch
c4839708ce New Crowdin updates (#106) 2024-04-18 22:13:30 -04:00
Lance Pioch
fa379be99b Single quotes 2024-04-18 21:56:19 -04:00
iamkubi
12eee6f6a2 Merge pull request #107 from Boy132/fix/tests
Fix tests
2024-04-18 15:37:29 -07:00
Boy132
50240933a0 fix "uuid must be a string" 2024-04-18 23:58:55 +02:00
Lance Pioch
69b70bf649 Merge branch 'master' into issue/fix-3 2024-04-18 17:20:23 -04:00
notCharles
3e01e483fb Allow variables to be sorted
todo: save order in db in a new column 'sort'
2024-04-18 17:13:17 -04:00
Lance Pioch
ae189748f1 Fix styling 2024-04-18 17:08:10 -04:00
Lance Pioch
679c72d70e Merge branch '3.x'
# Conflicts:
#	composer.lock
#	resources/scripts/components/auth/LoginFormContainer.tsx
2024-04-18 16:50:39 -04:00
Lance Pioch
49e02a2574 Merge branch 'feature/filament' of github.com:pelican-dev/panel into feature/filament 2024-04-18 16:46:18 -04:00
Lance Pioch
ee735c9b77 Add skeleton for adding new allocations 2024-04-18 16:46:08 -04:00
Lance Pioch
e25ca5dfc1 Add separated repositories for eggs 2024-04-18 16:21:40 -04:00
Lance Pioch
422fc1a6b2 Add github for funding 2024-04-18 16:21:22 -04:00
Lance Pioch
0949362da5 Merge pull request #89 from pelican-dev/issue/fix-mobile
Improve Mobile Experience
2024-04-18 16:14:27 -04:00
Lance Pioch
65f59c446e Merge branch 'feature/filament' of github.com:pelican-dev/panel into feature/filament
# Conflicts:
#	composer.lock
2024-04-18 04:02:42 -04:00
Lance Pioch
556ab76fc5 Basic server status implementation 2024-04-18 03:53:28 -04:00
Lance Pioch
4c5072b5c0 When editing set the custom image selector correctly 2024-04-18 03:52:55 -04:00
Lance Pioch
25177d1685 Wip 2fa 2024-04-18 03:51:25 -04:00
Lance Pioch
5469dce6ca Remove unused variables 2024-04-18 03:50:45 -04:00
Lance Pioch
d659bf4349 Allow primary allocation to be changed 2024-04-18 03:50:33 -04:00
Lance Pioch
c5008a43e7 Use new enum 2024-04-18 03:50:20 -04:00
Lance Pioch
256a961e1b Add enums 2024-04-18 03:48:30 -04:00
Lance Pioch
d642987df4 Better phrasing 2024-04-17 01:32:47 -04:00
Lance Pioch
7d0fc80a80 Switch to components 2024-04-17 01:30:05 -04:00
Lance Pioch
fa0bc96611 Merge pull request #88 from pelican-dev/feature/code-editor
Add Monaco
2024-04-17 01:28:56 -04:00
Lance Pioch
c492fa285f Update languages 2024-04-17 00:23:35 -04:00
notCharles
11494bbad6 Should enable this, so it ya know.. works <3 2024-04-16 20:56:47 -04:00
notCharles
c83cc073f0 Major Mobile Improvements <3 2024-04-16 18:47:12 -04:00
notCharles
b7c0829af9 Add Monaco
Known issues...
Changing themes does not reload editor, F5 required.
Editing a variable/docker images clears code box render, not sure how to fix this? reload on view?
2024-04-15 19:00:49 -04:00
Lance Pioch
e899acbdbe Add docker container status enum 2024-04-15 02:15:06 -04:00
Lance Pioch
1f95430507 Update Laravel to latest version 2024-04-15 02:14:54 -04:00
Lance Pioch
b70ab0e6cc Adjust these 2024-04-15 01:39:59 -04:00
Lance Pioch
8ec4dc1b6e Cool status bars 2024-04-15 01:34:27 -04:00
Lance Pioch
9b9875a31b Whoops 2024-04-15 01:34:17 -04:00
Lance Pioch
56e2cac85f Always just start automatically for now 2024-04-15 01:34:07 -04:00
Lance Pioch
65359f87d0 Nobody needs to see my mistakes 2024-04-15 01:15:09 -04:00
Lance Pioch
058371ba7d Merge branch 'feature/filament' of github.com:pelican-dev/panel into feature/filament
# Conflicts:
#	app/Filament/Resources/ServerResource.php
2024-04-14 22:24:30 -04:00
notCharles
dc8d7aa3da Fix mariadb? 2024-04-14 20:53:50 -04:00
Scai
e1c6545507 styled database hosts layout 2024-04-15 01:55:44 +03:00
Scai
5c8097d9b7 cleanup server boilerplate 2024-04-15 01:34:55 +03:00
notCharles
6118ed91fa oops, readd this 2024-04-14 15:23:15 -04:00
notCharles
0814b82b7e pint 2024-04-14 15:23:03 -04:00
Lance Pioch
288d3a2cff Nobody look 2024-04-14 02:48:21 -04:00
Lance Pioch
e2399eb4e2 Hide this again 2024-04-14 02:41:56 -04:00
Lance Pioch
bfe8fc66ce Merge branch 'feature/filament' of github.com:pelican-dev/panel into feature/filament
# Conflicts:
#	app/Filament/Resources/ServerResource.php
2024-04-14 02:38:50 -04:00
Lance Pioch
dd1b25604a Do the additional allocations 2024-04-14 02:36:32 -04:00
Lance Pioch
b488253c76 Unused 2024-04-14 02:31:30 -04:00
Lance Pioch
56f96348f4 Allow disabling block io balancing 2024-04-14 02:31:19 -04:00
Lance Pioch
18cbaf7458 Add deletion button 2024-04-14 02:31:06 -04:00
Lance Pioch
9add408b6b Filter out null entries 2024-04-14 02:30:57 -04:00
notCharles
54eaf8ab0f Change colums
Closes https://github.com/pelican-dev/panel/issues/3
2024-04-13 21:51:22 -04:00
Scai
32a3b8dd9b add servers relationship to users 2024-04-14 01:50:43 +03:00
Lance Pioch
06c773c3b1 Prevent deleting nodes if there are active servers 2024-04-13 16:49:53 -04:00
Lance Pioch
18e5c17ebe Add languages to profile edit 2024-04-13 16:21:44 -04:00
Lance Pioch
287c657e60 Merge branch 'feature/filament' of github.com:pelican-dev/panel into feature/filament
# Conflicts:
#	app/Filament/Resources/ServerResource.php
#	app/Filament/Resources/ServerResource/Pages/CreateServer.php
2024-04-13 15:55:29 -04:00
Lance Pioch
f69d0823f4 Small updates 2024-04-13 15:52:54 -04:00
Scai
f26373dfd5 linting 2024-04-13 16:30:20 +03:00
Scai
c54217f236 Merge pull request #56 from pelican-dev/feature/mounts
Mounts: Relationships for Eggs & Nodes
2024-04-13 16:25:31 +03:00
Scai
25c1b251a3 added eggs, nodes relationship, structured the layout 2024-04-13 16:23:05 +03:00
Scai
9fafe2f42c remove timestamp columns 2024-04-13 14:52:22 +03:00
Lance Pioch
f25bd33f06 Add data lists 2024-04-12 14:59:54 -04:00
Scai
de02e8853d add mounts icons columns 2024-04-12 20:36:30 +03:00
Scai
4c09905503 fix error on no node created 2024-04-12 20:15:57 +03:00
Lance Pioch
a9a39ae502 Set defaults 2024-04-12 13:05:12 -04:00
Lance Pioch
99693367d3 Simplify and restrict api keys 2024-04-12 13:05:04 -04:00
Lance Pioch
05e9f12dc4 Switch default path 2024-04-12 12:52:00 -04:00
Lance Pioch
0b1712f653 Merge branch 'feature/filament' of github.com:pelican-dev/panel into feature/filament 2024-04-12 12:19:25 -04:00
Lance Pioch
8575f1b036 More icons and a couple tweaks 2024-04-12 12:18:41 -04:00
Scai
4c2278a7f2 fix route missing on database hosts 2024-04-12 19:09:01 +03:00
Scai
af4d1d1fee lower the size of logo on login 2024-04-12 18:19:39 +03:00
Scai
d52cb4c7d7 round the avatar 2024-04-12 18:01:24 +03:00
Lance Pioch
65acb3fd94 Add more icons 2024-04-12 02:31:31 -04:00
Lance Pioch
ecf54a3025 Double down on this to fix validation 2024-04-12 02:31:25 -04:00
Lance Pioch
180bfc30a8 Add server name generator 2024-04-12 02:29:39 -04:00
Lance Pioch
cbc255ddf8 Add server variables 2024-04-12 02:04:22 -04:00
Lance Pioch
15971aaa94 Auto expand the startup command 2024-04-12 02:04:03 -04:00
Lance Pioch
695ebd35a4 No another create 2024-04-12 02:03:36 -04:00
Lance Pioch
21247e91c7 Do the icons 2024-04-12 02:03:20 -04:00
Lance Pioch
d30471ae6a Fix this around 2024-04-11 21:03:06 -04:00
Lance Pioch
03292bb02d logo and favicon changes 2024-04-11 20:59:35 -04:00
Lance Pioch
297e292e06 Switch favicon 2024-04-11 20:59:00 -04:00
Lance Pioch
f32aa8609d Add toggle for custom image 2024-04-11 11:41:29 -04:00
Lance Pioch
f8550334dd Helper text for creating new allocations 2024-04-11 11:41:03 -04:00
Lance Pioch
da1bf320dd Auto select latest node 2024-04-11 11:31:51 -04:00
Lance Pioch
27f05b5f95 Expand text area to make it look nicer 2024-04-11 11:31:38 -04:00
Lance Pioch
203289fd38 This isn’t required 2024-04-11 11:31:25 -04:00
Lance Pioch
f9b93f284c Servers 2024-04-11 03:11:51 -04:00
Lance Pioch
24f9a8aeb1 Use default 2024-04-11 03:11:27 -04:00
Lance Pioch
c6eb6dc054 Return all allocation ids back 2024-04-11 03:11:19 -04:00
Lance Pioch
a16ef9743b Add missing relationship 2024-04-11 03:09:19 -04:00
Lance Pioch
6c8816c289 Liven this up 2024-04-11 03:08:56 -04:00
Lance Pioch
d4f325e6c5 Force usage of global search 2024-04-11 00:55:30 -04:00
Lance Pioch
93ec3bdfc4 Rename 2024-04-11 00:51:36 -04:00
Lance Pioch
a4fb9eea40 Get docker images working 2024-04-11 00:45:58 -04:00
Lance Pioch
f201a5eaf6 Easier to understand 2024-04-11 00:45:33 -04:00
Lance Pioch
6689e796a7 Better description 2024-04-11 00:45:26 -04:00
Lance Pioch
b2e5b4862d Add nice new intro page 2024-04-11 00:45:20 -04:00
Lance Pioch
546dc5c449 Fix from demo 2024-04-11 00:45:12 -04:00
Lance Pioch
f660611ed3 Add skeleton new page 2024-04-10 17:10:25 -04:00
Lance Pioch
89a507de69 Better email 2024-04-10 17:10:13 -04:00
Lance Pioch
3cc29ba7b6 Small adjustments 2024-04-10 16:11:27 -04:00
Lance Pioch
1660af94a5 New revolution 2024-04-09 21:07:14 -04:00
Lance Pioch
c51687246e Better icon 2024-04-09 21:02:02 -04:00
Lance Pioch
bc72d6103c We are stars 2024-04-09 21:01:57 -04:00
Lance Pioch
cf44e46490 Rename to owner 2024-04-09 21:01:51 -04:00
Lance Pioch
8311669e6c Show valid ip address 2024-04-09 21:01:46 -04:00
Lance Pioch
195557373c Clarify the resource name 2024-04-09 21:01:30 -04:00
Lance Pioch
e7055242e1 More small adjustments 2024-04-09 18:45:51 -04:00
Lance Pioch
6020b8d6a8 Small adjustments 2024-04-09 18:45:43 -04:00
Lance Pioch
7c14a2edff Remove the top bar 2024-04-09 18:45:31 -04:00
Lance Pioch
6307919546 Add database hosts 2024-04-09 18:45:01 -04:00
Lance Pioch
2dd53eee27 Clean up 2024-04-08 01:20:07 -04:00
Lance Pioch
4e0aaedc86 Add api keys 2024-04-08 00:59:55 -04:00
Lance Pioch
970d2b0f0f Add better profile page 2024-04-08 00:33:00 -04:00
Lance Pioch
b1cc4ef45c Squish them 2024-04-08 00:10:19 -04:00
Lance Pioch
17c20d6b91 Shorten nav sidebar 2024-04-07 20:13:39 -04:00
Lance Pioch
d05332662b Add some of these 2024-04-07 01:35:30 -04:00
Lance Pioch
42728fd9b9 Small tweaks 2024-04-07 01:35:20 -04:00
Lance Pioch
732db9a5b4 User resource adjustments 2024-04-06 23:58:10 -04:00
Lance Pioch
a19c8e72b3 Fix password saving 2024-04-06 22:56:57 -04:00
Lance Pioch
b0067c4e4b Top right user menu 2024-04-06 10:11:10 -04:00
Lance Pioch
a133503256 Simplify 2024-04-04 21:45:57 -04:00
Lance Pioch
3465d2fc64 Revert "Not used"
This reverts commit 1728cbf28b.
2024-04-04 21:45:21 -04:00
Lance Pioch
05ae2b2ecf Add home url 2024-04-04 21:43:31 -04:00
Lance Pioch
e00d9ed273 Add some allocations 2024-04-04 21:43:18 -04:00
Lance Pioch
bf8f90f479 More specific 2024-04-04 21:26:18 -04:00
Lance Pioch
978a4ac0a2 Adjust the mounts 2024-04-04 21:26:10 -04:00
Lance Pioch
aac522232f Update database form 2024-04-04 21:25:59 -04:00
Lance Pioch
95570724e6 Use tabs instead 2024-04-04 20:16:14 -04:00
Lance Pioch
a96c53f407 Make this hidden by default 2024-04-04 20:15:56 -04:00
Lance Pioch
f79ec37f5e Move this over 2024-04-04 20:15:41 -04:00
Lance Pioch
5d38f2ece6 Adjustments 2024-04-04 18:59:42 -04:00
Lance Pioch
cf13bfb1e4 Add dns checking field 2024-04-04 18:59:34 -04:00
Lance Pioch
051b5d6bea Move this up 2024-04-04 18:59:12 -04:00
Lance Pioch
a8532d1cd2 Fix colspan 2024-04-04 18:58:40 -04:00
Lance Pioch
a7d8f3b79f Better account for ip addresses 2024-04-04 18:58:19 -04:00
Lance Pioch
e634dd81b1 Take these out 2024-04-04 18:58:01 -04:00
Lance Pioch
c6f4ee3d57 Increase this slightly 2024-04-01 12:33:22 -04:00
Lance Pioch
f47b420785 Better display of tablular data 2024-04-01 12:33:16 -04:00
Lance Pioch
1add3ca605 Allow this to be swappable from here in the future 2024-03-31 02:20:23 -04:00
Lance Pioch
2b172e6d8b User adjustments 2024-03-31 02:09:23 -04:00
Lance Pioch
1728cbf28b Not used 2024-03-31 01:57:59 -04:00
Lance Pioch
766c6c08f4 Easy to copy and paste 2024-03-31 01:39:24 -04:00
Lance Pioch
400e4d783b Redirect them directly to the config 2024-03-31 01:07:03 -04:00
Lance Pioch
a8b3e2bfa4 Just prevent the user altogether 2024-03-31 01:06:49 -04:00
Lance Pioch
57649d1c08 Show warning to user 2024-03-31 00:14:48 -04:00
Lance Pioch
dc794c64ce This is supposed to be sent, but isn’t because it’s disabled, even though it’s dehydrated 2024-03-31 00:14:15 -04:00
Lance Pioch
eda3959748 Too slow for now 2024-03-31 00:13:17 -04:00
Lance Pioch
f5a0a0f8ba Better helper text 2024-03-31 00:12:25 -04:00
Lance Pioch
6e4f3f7191 Set better default 2024-03-31 00:11:56 -04:00
Lance Pioch
e276a07f1b Hide this for now 2024-03-30 20:32:48 -04:00
Lance Pioch
db9b3e9b67 Set the name to the first part of the domain 2024-03-30 20:32:20 -04:00
Lance Pioch
d32d0692f8 Icons 2024-03-30 20:31:36 -04:00
Lance Pioch
ebdca47fbc Go nuts with icons 2024-03-30 02:30:29 -04:00
Lance Pioch
957a335817 Prevent root from getting rid of last root 2024-03-30 02:29:57 -04:00
Lance Pioch
71a27862bd This isn’t used 2024-03-30 02:16:02 -04:00
Lance Pioch
a2b03895d7 Switch icon set 2024-03-30 02:15:56 -04:00
Lance Pioch
a93ebfd7bf Add licensing 2024-03-29 00:58:13 -04:00
Lance Pioch
3c43f3aa18 Don’t need this anymore 2024-03-28 17:01:09 -04:00
Lance Pioch
eb5c304f69 Remove these mount usages 2024-03-28 13:29:24 -04:00
Lance Pioch
0925e141b4 These don’t work 2024-03-28 13:27:14 -04:00
Lance Pioch
ad8d087fd9 Lazy load statuses 2024-03-27 00:07:21 -04:00
Lance Pioch
e50e3509bd More adjustments 2024-03-26 22:36:32 -04:00
Lance Pioch
090b2e6f1b Move this over for the import 2024-03-26 22:34:42 -04:00
Lance Pioch
0af0bea90d Update views 2024-03-26 22:00:39 -04:00
Lance Pioch
f452280cdb Favicon adjustments 2024-03-26 21:59:45 -04:00
Lance Pioch
8c892ac05d Adjustments 2024-03-26 20:52:56 -04:00
Lance Pioch
65384250d6 Add attribute 2024-03-26 20:52:32 -04:00
Lance Pioch
3e7bff2446 Consolidate these 2024-03-25 10:26:57 -04:00
Lance Pioch
b6d39c66d1 Allow adjusting timeout 2024-03-24 14:42:54 -04:00
Lance Pioch
03e1733b7d Update users 2024-03-24 14:42:45 -04:00
Lance Pioch
039ac40cf7 Update nodes 2024-03-24 14:42:36 -04:00
Lance Pioch
2664ba0774 Baseline 2024-03-24 01:48:03 -04:00
Lance Pioch
90efb4e827 Simplify the parsing 2024-03-24 01:47:30 -04:00
Lance Pioch
946d597a13 Switch route key over 2024-03-24 01:37:24 -04:00
Lance Pioch
0961d6314c Eggs should not be manually created 2024-03-24 01:36:32 -04:00
Lance Pioch
0ef015bb0e Allow admins to access filament 2024-03-23 20:17:48 -04:00
Lance Pioch
36ca708850 Add filament provider 2024-03-23 20:17:33 -04:00
Lance Pioch
1c539ff50c Add filament css and js 2024-03-23 20:17:21 -04:00
Lance Pioch
49d0865010 Add filament dependencies 2024-03-23 20:17:14 -04:00
826 changed files with 41763 additions and 12669 deletions

View File

@@ -11,12 +11,7 @@ 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=
DB_CONNECTION=sqlite
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null

View File

@@ -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.
//

1
.github/FUNDING.yml vendored
View File

@@ -1 +1,2 @@
github: pelican-dev
custom: [https://buy.stripe.com/14kdU99SI4UT7ni9AB, https://buy.stripe.com/14kaHXc0Q9b9372eUU]

View File

@@ -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 _;

View File

@@ -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/

View File

@@ -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

View File

@@ -6,8 +6,8 @@ on:
- '**'
jobs:
tests:
name: Tests
mysql:
name: MySQL
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -23,6 +23,22 @@ 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
HASHIDS_SALT: alittlebitofsalt1234
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: root
steps:
- name: Code Checkout
uses: actions/checkout@v4
@@ -48,18 +64,11 @@ jobs:
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 +78,64 @@ 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
HASHIDS_SALT: alittlebitofsalt1234
DB_CONNECTION: sqlite
DB_DATABASE: ${{ github.workspace }}/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, cli, 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

View File

@@ -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

View File

@@ -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

5
.gitignore vendored
View File

@@ -17,6 +17,7 @@ _ide_helper_models.php
.phpstorm.meta.php
.yarn
public/assets/manifest.json
*.sqlite
# For local development with docker
# Remove if we ever put the Dockerfile in the repo
@@ -34,3 +35,7 @@ resources/lang/locales.js
/public/hot
result
docker-compose.yaml
public/css/filament-monaco-editor/
public/js/filament-monaco-editor/

View File

@@ -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 \

View File

@@ -5,11 +5,11 @@ 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',
@@ -34,9 +34,7 @@ class AppSettingsCommand extends Command
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 +60,18 @@ class AppSettingsCommand extends Command
*/
public function handle(): int
{
$this->variables['APP_TIMEZONE'] = 'UTC';
if (empty(config('hashids.salt')) || $this->option('new-salt')) {
$this->variables['HASHIDS_SALT'] = str_random(20);
}
$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',
@@ -115,7 +96,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
@@ -124,8 +105,18 @@ class AppSettingsCommand extends Command
}
$this->checkForRedis();
$path = base_path('.env');
if (!file_exists($path)) {
copy($path . '.example', $path);
}
$this->writeToEnvironment($this->variables);
if (!config('app.key')) {
Artisan::call('key:generate');
}
$this->info($this->console->output());
return 0;
@@ -145,7 +136,7 @@ class AppSettingsCommand extends Command
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 +149,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'
);

View File

@@ -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',
config('database.connections.sqlite.database', database_path('database.sqlite'))
);
}
$this->writeToEnvironment($this->variables);

View File

@@ -34,13 +34,14 @@ class EmailSettingsCommand extends Command
$this->variables['MAIL_DRIVER'] = $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')
'smtp',
);
$method = 'setup' . studly_case($this->variables['MAIL_DRIVER']) . 'DriverVariables';

View File

@@ -29,7 +29,6 @@ class InfoCommand extends Command
['Panel Version', config('app.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');

View File

@@ -42,27 +42,28 @@ 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['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_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 . '.');
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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()->toDateString())
->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;
}
@@ -66,7 +67,7 @@ class ProcessRunnableCommand extends Command
} 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());
}
}
}

View File

@@ -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)

View File

@@ -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.
@@ -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'],
]);

View 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',
};
}
}

View 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
View 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',
};
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Exceptions;
use Exception;
use Filament\Notifications\Notification;
use Illuminate\Http\Request;
use Psr\Log\LoggerInterface;
use Illuminate\Http\Response;
@@ -47,8 +48,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 (str($request->url())->contains('livewire')) {
Notification::make()
->title(static::class)
->body($this->getMessage())
->danger()
->send();
return;
}
if ($request->expectsJson()) {
return response()->json(Handler::toArray($this), $this->getStatusCode(), $this->getHeaders());
}

View File

@@ -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.';

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Filament\Clusters;
use Filament\Clusters\Cluster;
class Settings extends Cluster
{
protected static ?string $navigationIcon = 'tabler-settings';
}

View 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 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
{
return [
'inDevelopment' => config('app.version') === 'canary',
'eggsCount' => Egg::query()->count(),
'nodesList' => ListNodes::getUrl(),
'nodesCount' => Node::query()->count(),
'serversCount' => Server::query()->count(),
'usersCount' => User::query()->count(),
'devActions' => [
CreateAction::make()
->label(trans('dashboard/index.sections.intro-developers.button_issues'))
->icon('tabler-brand-github')
->url('https://github.com/pelican-dev/panel/issues/new/choose', true)
->color('warning'),
CreateAction::make()
->label(trans('dashboard/index.sections.intro-developers.button_features'))
->icon('tabler-brand-github')
->url('https://github.com/pelican-dev/panel/discussions', true),
],
'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_translate'))
->icon('tabler-language')
->url('https://crowdin.com/project/pelican-dev', true),
CreateAction::make()
->label(trans('dashboard/index.sections.intro-support.button_donate'))
->icon('tabler-cash')
->url('https://pelican.dev/donate', true)
->color('success'),
],
'helpActions' => [
CreateAction::make()
->label(trans('dashboard/index.sections.intro-help.button_docs'))
->icon('tabler-speedboat')
->url('https://pelican.dev/docs', true),
CreateAction::make()
->label(trans('dashboard/index.sections.intro-help.button_discord'))
->icon('tabler-brand-discord')
->url('https://discord.gg/pelican-panel', true)
->color('blurple'),
],
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ApiKeyResource\Pages;
use App\Models\ApiKey;
use Filament\Resources\Components\Tab;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
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 canEdit($record): bool
{
return false;
}
public function getTabs(): array
{
return [
'all' => Tab::make('All Keys'),
'application' => Tab::make('Application Keys')
->modifyQueryUsing(fn (Builder $query) => $query->where('key_type', ApiKey::TYPE_APPLICATION)),
];
}
public function getDefaultActiveTab(): string|int|null
{
return 'application';
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListApiKeys::route('/'),
'create' => Pages\CreateApiKey::route('/create'),
];
}
}

View File

@@ -0,0 +1,102 @@
<?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(encrypt(str_random(ApiKey::KEY_LENGTH))),
Forms\Components\Hidden::make('user_id')
->default(auth()->user()->id)
->required(),
Forms\Components\Select::make('key_type')
->inlineLabel()
->options(function (ApiKey $apiKey) {
$originalOptions = [
//ApiKey::TYPE_NONE => 'None',
ApiKey::TYPE_ACCOUNT => 'Account',
ApiKey::TYPE_APPLICATION => 'Application',
//ApiKey::TYPE_DAEMON_USER => 'Daemon User',
//ApiKey::TYPE_DAEMON_APPLICATION => 'Daemon Application',
];
return collect($originalOptions)
->filter(fn ($value, $key) => $key <= ApiKey::TYPE_APPLICATION || $apiKey->key_type === $key)
->all();
})
->selectablePlaceholder(false)
->required()
->default(ApiKey::TYPE_APPLICATION),
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(),
]);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Filament\Resources\ApiKeyResource\Pages;
use App\Filament\Resources\ApiKeyResource;
use App\Models\ApiKey;
use Filament\Actions;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Filament\Tables;
class ListApiKeys extends ListRecords
{
protected static string $resource = ApiKeyResource::class;
public function table(Table $table): Table
{
return $table
->searchable(false)
->columns([
Tables\Columns\TextColumn::make('user.username')
->hidden()
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('key')
->copyable()
->icon('tabler-clipboard-text')
->state(fn (ApiKey $key) => $key->identifier . decrypt($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')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->label('Created')
->dateTime()
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\DeleteAction::make(),
//Tables\Actions\EditAction::make()
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
public function getTabs(): array
{
return [
'all' => Tab::make('All Keys'),
'application' => Tab::make('Application Keys')
->modifyQueryUsing(fn (Builder $query) => $query->where('key_type', ApiKey::TYPE_APPLICATION)
),
'account' => Tab::make('Account Keys')
->modifyQueryUsing(fn (Builder $query) => $query->where('key_type', ApiKey::TYPE_ACCOUNT)
),
];
}
public function getDefaultActiveTab(): string|int|null
{
return 'application';
}
}

View File

@@ -0,0 +1,32 @@
<?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 getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDatabaseHosts::route('/'),
'create' => Pages\CreateDatabaseHost::route('/create'),
'edit' => Pages\EditDatabaseHost::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,76 @@
<?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()->schema([
Forms\Components\TextInput::make('host')
->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()
->debounce(500)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(191),
Forms\Components\TextInput::make('port')
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
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\TextInput::make('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\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'),
])->columns([
'default' => 1,
'lg' => 2,
]),
]);
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['password'])) {
$data['password'] = encrypt($data['password']);
}
return $data;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Filament\Resources\DatabaseHostResource\Pages;
use App\Filament\Resources\DatabaseHostResource;
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()->schema([
Forms\Components\TextInput::make('host')
->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()
->debounce(500)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(191),
Forms\Components\TextInput::make('port')
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
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\TextInput::make('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\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'),
])->columns([
'default' => 1,
'lg' => 2,
]),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['password'])) {
$data['password'] = encrypt($data['password']);
}
return $data;
}
}

View File

@@ -0,0 +1,54 @@
<?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;
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(),
];
}
}

View File

@@ -0,0 +1,32 @@
<?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 getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListDatabases::route('/'),
'create' => Pages\CreateDatabase::route('/create'),
'edit' => Pages\EditDatabase::route('/{record}/edit'),
];
}
}

View File

@@ -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),
]);
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View File

@@ -0,0 +1,39 @@
<?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 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'),
];
}
}

View File

@@ -0,0 +1,212 @@
<?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()
->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);
}
}

View File

@@ -0,0 +1,207 @@
<?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' => 2])
->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
Forms\Components\TextInput::make('uuid')
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('This is the globally unique identifier for this Egg which Wings uses as an identifier.'),
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)
->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']])),
];
}
}

View File

@@ -0,0 +1,117 @@
<?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\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 => $record->description)
->wrap()
->searchable(),
Tables\Columns\TextColumn::make('author')
->hidden()
->searchable(),
Tables\Columns\TextColumn::make('servers_count')
->counts('servers')
->icon('tabler-server')
->label('Servers'),
Tables\Columns\TextColumn::make('script_container')
->searchable()
->hidden(),
Tables\Columns\TextColumn::make('copyFrom.name')
->hidden()
->sortable(),
Tables\Columns\TextColumn::make('script_entry')
->hidden()
->searchable(),
])
->filters([
//
])
->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])),
])
->headerActions([
//
])
->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([
Forms\Components\FileUpload::make('egg')
->acceptedFileTypes(['application/json'])
->storeFiles(false)
->multiple(),
])
->action(function (array $data): void {
/** @var TemporaryUploadedFile $eggFile */
$eggFile = $data['egg'];
/** @var EggImporterService $eggImportService */
$eggImportService = resolve(EggImporterService::class);
foreach ($eggFile as $file) {
try {
$eggImportService->handle($file);
} catch (Exception $exception) {
Notification::make()
->title('Import Failed')
->danger()
->send();
report($exception);
return;
}
}
Notification::make()
->title('Import Success')
->success()
->send();
}),
];
}
}

View File

@@ -0,0 +1,30 @@
<?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 getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListMounts::route('/'),
'create' => Pages\CreateMount::route('/create'),
'edit' => Pages\EditMount::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Filament\Resources\MountResource\Pages;
use App\Filament\Resources\MountResource;
use App\Services\Servers\ServerCreationService;
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);
}
}

View File

@@ -0,0 +1,103 @@
<?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(),
];
}
}

View 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),
];
}
}

View File

@@ -0,0 +1,34 @@
<?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 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'),
];
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource;
use Filament\Forms;
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
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->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'),
Forms\Components\Textarea::make('description')
->label('strings.description')
->hidden()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 4,
])
->rows(5),
Forms\Components\Hidden::make('skipValidation')->default(true),
]);
}
protected function getRedirectUrlParameters(): array
{
return [
'tab' => '-configuration-tab',
];
}
}

View File

@@ -0,0 +1,91 @@
<?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 Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Tabs;
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((new CreateNode())->form($form)->getComponents()),
// Tabs\Tab::make('Advanced Settings')
// ->icon('tabler-server-cog')
// ->schema([
// Forms\Components\Placeholder::make('Coming soon!'),
// ]),
Tabs\Tab::make('Configuration')
->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(),
]),
]),
]);
}
protected function mutateFormDataBeforeFill(array $data): array
{
$node = Node::findOrFail($data['id']);
$data['config'] = $node->getYamlConfiguration();
return $data;
}
protected function getSteps(): 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'),
];
}
protected function getFooterWidgets(): array
{
return [
NodeStorageChart::class,
NodeMemoryChart::class,
];
}
}

View File

@@ -0,0 +1,101 @@
<?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(' GB')
->formatStateUsing(fn ($state) => number_format($state / 1000, 2))
->sortable(),
Tables\Columns\TextColumn::make('disk')
->visibleFrom('sm')
->icon('tabler-file')
->numeric()
->suffix(' GB')
->formatStateUsing(fn ($state) => number_format($state / 1000, 2))
->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),
];
}
}

View File

@@ -0,0 +1,145 @@
<?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('server.name')
->label('Server')
->icon('tabler-brand-docker')
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
Tables\Columns\TextColumn::make('ip_alias')
->label('Alias'),
Tables\Columns\TextColumn::make('ip')
->label('IP'),
Tables\Columns\TextColumn::make('port')
->searchable()
->label('Port'),
])
->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);
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;
}
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(),
]),
]);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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;
$used = $node->statistics()['memory_used'] ?? 0;
$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';
}
}

View File

@@ -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;
$used = $node->statistics()['disk_used'] ?? 0;
$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';
}
}

View File

@@ -0,0 +1,32 @@
<?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 getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListServers::route('/'),
'create' => Pages\CreateServer::route('/create'),
'edit' => Pages\EditServer::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,706 @@
<?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 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 () => Node::query()->latest()->first()?->id)
->columnSpan(2)
->live()
->relationship('node', 'name')
->searchable()
->preload()
->afterStateUpdated(fn (Forms\Set $set) => $set('allocation_id', null))
->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\Select::make('select_image')
->label('Docker Image Name')
->prefixIcon('tabler-brand-docker')
->live()
->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([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 3,
]),
Forms\Components\TextInput::make('image')
->label('Docker Image')
->prefixIcon('tabler-brand-docker')
->live()
->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([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 3,
]),
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) => !empty($get('server_variables'))),
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)
->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'));
$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('Resource Management')
->collapsed()
->icon('tabler-server-cog')
->iconColor('primary')
->columns([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 4,
])
->columnSpanFull()
->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('MB')
->default(0)
->required()
->columnSpan(2)
->numeric(),
]),
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('MB')
->default(0)
->required()
->columnSpan(2)
->numeric(),
]),
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(),
]),
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('MB')
->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_disabled')
->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('Application 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),
]),
]),
]);
}
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();
}
}

View File

@@ -0,0 +1,523 @@
<?php
namespace App\Filament\Resources\ServerResource\Pages;
use App\Filament\Resources\ServerResource;
use App\Services\Servers\RandomWordService;
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\Form;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Validator;
use Closure;
class EditServer extends EditRecord
{
protected static string $resource = ServerResource::class;
public function form(Form $form): Form
{
return $form
->columns([
'default' => 2,
'sm' => 2,
'md' => 4,
'lg' => 6,
])
->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' => 3,
]),
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' => 3,
]),
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')
->label('Owner')
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 2,
'lg' => 3,
])
->relationship('user', 'username')
->searchable()
->preload()
->required(),
Forms\Components\Textarea::make('description')
->hidden()
->required()
->columnSpanFull(),
Forms\Components\Select::make('egg_id')
->disabledOn('edit')
->prefixIcon('tabler-egg')
->columnSpan([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 6,
])
->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\ToggleButtons::make('custom_image')
->live()
->label('Custom Image?')->inline()
->formatStateUsing(function ($state, Forms\Get $get) {
if ($state !== null) {
return $state;
}
$images = Egg::find($get('egg_id'))->docker_images;
return !in_array($get('image'), $images);
})
->options([
false => 'No',
true => 'Yes',
])
->colors([
false => 'primary',
true => 'danger',
])
->icons([
false => 'tabler-settings-cancel',
true => 'tabler-settings-check',
]),
Forms\Components\TextInput::make('image')
->hidden(fn (Forms\Get $get) => !$get('custom_image'))
->disabled(fn (Forms\Get $get) => !$get('custom_image'))
->label('Docker Image')
->placeholder('Enter a custom Image')
->columnSpan([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->required(),
Forms\Components\Select::make('image')
->hidden(fn (Forms\Get $get) => $get('custom_image'))
->disabled(fn (Forms\Get $get) => $get('custom_image'))
->label('Docker Image')
->prefixIcon('tabler-brand-docker')
->options(fn (Forms\Get $get) => Egg::find($get('egg_id'))->docker_images)
->disabled(fn (Forms\Components\Select $component) => empty($component->getOptions()))
->selectablePlaceholder(false)
->columnSpan([
'default' => 2,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->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('start_on_completion'),
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\Repeater::make('server_variables')
->label('')
->relationship('serverVariables')
->grid()
->deletable(false)
->addable(false)
->schema([
Forms\Components\TextInput::make('variable_value')
->rules([
fn (ServerVariable $variable): Closure => function (string $attribute, $value, Closure $fail) use ($variable) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $variable->variable->rules,
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $variable->variable->name);
$fail($message);
}
},
])
->label(fn (ServerVariable $variable) => $variable->variable->name)
->hintIcon('tabler-code')
->hintIconTooltip(fn (ServerVariable $variable) => $variable->variable->rules)
->prefix(fn (ServerVariable $variable) => '{{' . $variable->variable->env_variable . '}}')
->helperText(fn (ServerVariable $variable) => $variable->variable->description ?: '—')
->maxLength(191),
Forms\Components\Hidden::make('variable_id'),
])
->columnSpan(2),
]),
Forms\Components\Section::make('Resource Management')
->collapsed()
->icon('tabler-server-cog')
->iconColor('primary')
->columns([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 4,
])
->columnSpanFull()
->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('MB')
->required()
->columnSpan(2)
->numeric(),
]),
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('MB')
->required()
->columnSpan(2)
->numeric(),
]),
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(),
]),
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('MB')
->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_disabled')
->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('Application 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(),
]),
]),
]);
}
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\DeleteAction::make('Force Delete')
->label('Force Delete')
->hidden()
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->withForce()->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
{
unset($data['docker'], $data['status']);
return $data;
}
public function getRelationManagers(): array
{
return [
ServerResource\RelationManagers\AllocationsRelationManager::class,
];
}
}

View 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),
];
}
}

View File

@@ -0,0 +1,69 @@
<?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')
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
// ->actions
// ->groups
->columns([
Tables\Columns\TextColumn::make('ip_alias')->label('Alias'),
Tables\Columns\TextColumn::make('ip')->label('IP'),
Tables\Columns\TextColumn::make('port')->label('Port'),
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([
Tables\Actions\CreateAction::make()->label('Create Allocation'),
//Tables\Actions\AssociateAction::make()->label('Add Allocation'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DissociateBulkAction::make(),
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?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 getRelations(): array
{
return [
RelationManagers\ServersRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Components\Section;
use Filament\Forms\Form;
use Illuminate\Support\Facades\Hash;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected static bool $canCreateAnother = false;
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(2),
]);
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Facades\Activity;
use App\Models\ActivityLog;
use App\Models\ApiKey;
use App\Models\User;
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\TextInput;
use Filament\Forms\Get;
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!'),
];
}
$setupService = app(TwoFactorSetupService::class);
['image_url_data' => $url] = $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'>$image</div>
"))
->default('asdfasdf'),
];
}),
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'),
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('API Keys')->columnSpan(2)->schema([
Repeater::make('keys')
->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()),
),
];
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
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(),
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Models\User;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Table;
use Filament\Tables;
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()
->label('Create User'),
];
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -39,29 +39,8 @@ class NodeViewController extends Controller
->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,
]);
}
@@ -117,7 +96,7 @@ class NodeViewController extends Controller
{
$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', [

View File

@@ -68,7 +68,7 @@ class ServerTransferController extends Controller
// 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'])
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemon_listen', '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)

View File

@@ -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'));
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin;
use App\Enums\ServerState;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Http\Response;
@@ -71,11 +72,11 @@ class ServersController extends Controller
*/
public function toggleInstall(Server $server): RedirectResponse
{
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();

View File

@@ -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);

View File

@@ -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'));
});

View File

@@ -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([

View File

@@ -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([]);
}
}

View File

@@ -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]);
});

View File

@@ -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;

View File

@@ -89,7 +89,7 @@ class SftpAuthenticationController extends Controller
protected function getServer(Request $request, string $uuid): Server
{
return Server::query()
->where(fn ($builder) => $builder->where('uuid', $uuid)->orWhere('uuidShort', $uuid))
->where(fn ($builder) => $builder->where('uuid', $uuid)->orWhere('uuid_short', $uuid))
->where('node_id', $request->attributes->get('node')->id)
->firstOr(function () use ($request) {
$this->reject($request);

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Http\Requests\Admin;
use App\Models\Mount;
class MountFormRequest extends AdminFormRequest
{
/**
* Set up the validation rules to use for these requests.
*/
public function rules(): array
{
if ($this->method() === 'PATCH') {
/** @var Mount $mount */
$mount = $this->route()->parameter('mount');
return Mount::getRulesForUpdate($mount->id);
}
return Mount::getRules();
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Http\Requests\Admin\Node;
use App\Rules\Fqdn;
use App\Models\Node;
use App\Http\Requests\Admin\AdminFormRequest;
@@ -17,9 +16,6 @@ class NodeFormRequest extends AdminFormRequest
return Node::getRulesForUpdate($this->route()->parameter('node'));
}
$data = Node::getRules();
$data['fqdn'][] = Fqdn::make('scheme');
return $data;
return Node::getRules();
}
}

View File

@@ -29,11 +29,11 @@ class StoreNodeRequest extends ApplicationApiRequest
'disk',
'disk_overallocate',
'upload_size',
'daemonListen',
'daemonSFTP',
'daemonBase',
'daemon_listen',
'daemon_sftp',
'daemon_base',
])->mapWithKeys(function ($value, $key) {
$key = ($key === 'daemonSFTP') ? 'daemonSftp' : $key;
$key = ($key === 'daemon_sftp') ? 'daemon_sftp' : $key;
return [snake_case($key) => $value];
})->toArray();
@@ -58,9 +58,9 @@ class StoreNodeRequest extends ApplicationApiRequest
public function validated($key = null, $default = null): array
{
$response = parent::validated();
$response['daemonListen'] = $response['daemon_listen'];
$response['daemonSFTP'] = $response['daemon_sftp'];
$response['daemonBase'] = $response['daemon_base'] ?? (new Node())->getAttribute('daemonBase');
$response['daemon_listen'] = $response['daemon_listen'];
$response['daemon_sftp'] = $response['daemon_sftp'];
$response['daemon_base'] = $response['daemon_base'] ?? (new Node())->getAttribute('daemon_base');
unset($response['daemon_base'], $response['daemon_listen'], $response['daemon_sftp']);

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Livewire;
use App\Models\Node;
use Livewire\Component;
class NodeSystemInformation extends Component
{
public Node $node;
public string $sizeClasses;
public function render()
{
return view('livewire.node-system-information');
}
public function placeholder()
{
return <<<'HTML'
<div>
<x-filament::icon
:icon="'tabler-heart-question'"
@class(['fi-ta-icon-item', $sizeClasses, 'fi-color-custom text-custom-500 dark:text-custom-400', 'fi-color-warning'])
@style([\Filament\Support\get_color_css_variables('warning', shades: [400, 500], alias: 'tables::columns.icon-column.item')])
/>
</div>
HTML;
}
}

View File

@@ -144,4 +144,29 @@ class ActivityLog extends Model
Event::dispatch(new ActivityLogged($model));
});
}
public function htmlable()
{
$user = $this->actor;
if (!$user instanceof User) {
$user = new User([
'email' => 'system@pelican.dev',
'username' => 'system',
]);
}
$event = __('activity.'.str($this->event)->replace(':', '.'));
return "
<div style='display: flex; align-items: center;'>
<img width='50px' height='50px' src='{$user->getFilamentAvatarUrl()}' style='margin-right: 15px' />
<div>
<p>$user->username$this->event</p>
<p>$event</p>
<p>$this->ip — <span title='{$this->timestamp->format('M j, Y g:ia')}'>{$this->timestamp->diffForHumans()}</span></p>
</div>
</div>
";
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Exceptions\Service\Allocation\ServerUsingAllocationException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -111,9 +112,16 @@ class Allocation extends Model
return !is_null($this->ip_alias);
}
public function address(): Attribute
{
return Attribute::make(
get: fn () => "$this->ip:$this->port",
);
}
public function toString(): string
{
return sprintf('%s:%s', $this->ip, $this->port);
return $this->address;
}
/**

View File

@@ -83,6 +83,8 @@ class ApiKey extends Model
*/
public const KEY_LENGTH = 32;
public const RESOURCES = ['servers', 'nodes', 'allocations', 'users', 'eggs', 'database_hosts', 'server_databases'];
/**
* The table associated with the model.
*/
@@ -92,12 +94,21 @@ class ApiKey extends Model
* Fields that are mass assignable.
*/
protected $fillable = [
'user_id',
'key_type',
'identifier',
'token',
'allowed_ips',
'memo',
'last_used_at',
'expires_at',
'r_' . AdminAcl::RESOURCE_USERS,
'r_' . AdminAcl::RESOURCE_ALLOCATIONS,
'r_' . AdminAcl::RESOURCE_DATABASE_HOSTS,
'r_' . AdminAcl::RESOURCE_SERVER_DATABASES,
'r_' . AdminAcl::RESOURCE_EGGS,
'r_' . AdminAcl::RESOURCE_NODES,
'r_' . AdminAcl::RESOURCE_SERVERS,
];
/**
@@ -187,7 +198,7 @@ class ApiKey extends Model
{
Assert::oneOf($type, [self::TYPE_ACCOUNT, self::TYPE_APPLICATION]);
return $type === self::TYPE_ACCOUNT ? 'ptlc_' : 'ptla_';
return $type === self::TYPE_ACCOUNT ? 'plcn_' : 'peli_';
}
/**

View File

@@ -31,7 +31,7 @@ class Backup extends Model
public const RESOURCE_NAME = 'backup';
public const ADAPTER_DAEMON = 'daemon';
public const ADAPTER_DAEMON = 'wings';
public const ADAPTER_AWS_S3 = 's3';
protected $table = 'backups';

View File

@@ -113,7 +113,7 @@ class Database extends Model
*/
private function run(string $statement): bool
{
return DB::connection($this->connection)->statement($statement);
return DB::connection(self::DEFAULT_CONNECTION_NAME)->statement($statement);
}
/**

View File

@@ -65,6 +65,11 @@ class DatabaseHost extends Model
];
}
public function getRouteKeyName(): string
{
return 'id';
}
/**
* Gets the node associated with a database host.
*/

View File

@@ -6,6 +6,7 @@ use App\Exceptions\Service\Egg\HasChildrenException;
use App\Exceptions\Service\HasActiveServersException;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* @property int $id
@@ -80,7 +81,9 @@ class Egg extends Model
* Fields that are not mass assignable.
*/
protected $fillable = [
'uuid',
'name',
'author',
'description',
'features',
'docker_images',
@@ -97,6 +100,7 @@ class Egg extends Model
'script_entry',
'script_container',
'copy_script_from',
'tags',
];
public static array $validationRules = [
@@ -127,6 +131,7 @@ class Egg extends Model
'config_logs' => null,
'config_files' => null,
'update_url' => null,
'tags' => '[]',
];
protected function casts(): array
@@ -145,6 +150,12 @@ class Egg extends Model
protected static function booted(): void
{
static::creating(function (self $egg) {
$egg->uuid ??= Str::uuid()->toString();
return true;
});
static::deleting(function (self $egg) {
throw_if($egg->servers()->count(), new HasActiveServersException(trans('exceptions.egg.delete_has_servers')));
@@ -152,6 +163,11 @@ class Egg extends Model
});
}
public function getRouteKeyName(): string
{
return 'id';
}
/**
* Returns the install script for the egg; if egg is copying from another
* it will return the copied script.
@@ -301,4 +317,9 @@ class Egg extends Model
{
return $this->belongsTo(self::class, 'config_from');
}
public function getKebabName(): string
{
return str($this->name)->kebab()->lower()->trim()->split('/[^\w\-]/')->join('');
}
}

View File

@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $egg_id
* @property null $sort
* @property string $name
* @property string $description
* @property string $env_variable
@@ -50,13 +51,14 @@ class EggVariable extends Model
public static array $validationRules = [
'egg_id' => 'exists:eggs,id',
'sort' => 'nullable',
'name' => 'required|string|between:1,191',
'description' => 'string',
'env_variable' => 'required|regex:/^[\w]{1,191}$/|notIn:' . self::RESERVED_ENV_NAMES,
'env_variable' => 'required|alphaDash|between:1,191|notIn:' . self::RESERVED_ENV_NAMES,
'default_value' => 'string',
'user_viewable' => 'boolean',
'user_editable' => 'boolean',
'rules' => 'required|string',
'rules' => 'string',
];
protected $attributes = [

View File

@@ -24,7 +24,7 @@ class AdminServerFilter implements Filter
->where(function (Builder $builder) use ($value) {
$builder->where('servers.uuid', $value)
->orWhere('servers.uuid', 'LIKE', "$value%")
->orWhere('servers.uuidShort', $value)
->orWhere('servers.uuid_short', $value)
->orWhere('servers.external_id', $value)
->orWhereRaw('LOWER(users.username) LIKE ?', ["%$value%"])
->orWhereRaw('LOWER(users.email) LIKE ?', ["$value%"])

View File

@@ -61,7 +61,7 @@ class MultiFieldServerFilter implements Filter
->where(function (Builder $builder) use ($value) {
$builder->where('servers.uuid', $value)
->orWhere('servers.uuid', 'LIKE', "$value%")
->orWhere('servers.uuidShort', $value)
->orWhere('servers.uuid_short', $value)
->orWhere('servers.external_id', $value)
->orWhereRaw('LOWER(servers.name) LIKE ?', ["%$value%"]);
});

View File

@@ -34,7 +34,7 @@ class Mount extends Model
/**
* Fields that are not mass assignable.
*/
protected $guarded = ['id', 'uuid'];
protected $guarded = ['id'];
/**
* Rules verifying that the data being stored matches the expectations of the database.
@@ -71,8 +71,8 @@ class Mount extends Model
* Blacklisted source paths.
*/
public static $invalidSourcePaths = [
'/etc/panel',
'/var/lib/panel/volumes',
'/etc/pelican',
'/var/lib/pelican/volumes',
'/srv/daemon-data',
];
@@ -115,4 +115,9 @@ class Mount extends Model
{
return $this->belongsToMany(Server::class);
}
public function getRouteKeyName(): string
{
return 'id';
}
}

View File

@@ -2,6 +2,10 @@
namespace App\Models;
use App\Exceptions\Service\HasActiveServersException;
use App\Repositories\Daemon\DaemonConfigurationRepository;
use Exception;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
use Illuminate\Notifications\Notifiable;
@@ -25,9 +29,9 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
* @property int $upload_size
* @property string $daemon_token_id
* @property string $daemon_token
* @property int $daemonListen
* @property int $daemonSFTP
* @property string $daemonBase
* @property int $daemon_listen
* @property int $daemon_sftp
* @property string $daemon_base
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property \App\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts
@@ -67,8 +71,8 @@ class Node extends Model
'public', 'name',
'fqdn', 'scheme', 'behind_proxy',
'memory', 'memory_overallocate', 'disk',
'disk_overallocate', 'upload_size', 'daemonBase',
'daemonSFTP', 'daemonListen',
'disk_overallocate', 'upload_size', 'daemon_base',
'daemon_sftp', 'daemon_listen',
'description', 'maintenance_mode',
];
@@ -79,13 +83,13 @@ class Node extends Model
'fqdn' => 'required|string',
'scheme' => 'required',
'behind_proxy' => 'boolean',
'memory' => 'required|numeric|min:1',
'memory' => 'required|numeric|min:0',
'memory_overallocate' => 'required|numeric|min:-1',
'disk' => 'required|numeric|min:1',
'disk' => 'required|numeric|min:0',
'disk_overallocate' => 'required|numeric|min:-1',
'daemonBase' => 'sometimes|required|regex:/^([\/][\d\w.\-\/]+)$/',
'daemonSFTP' => 'required|numeric|between:1,65535',
'daemonListen' => 'required|numeric|between:1,65535',
'daemon_base' => 'sometimes|required|regex:/^([\/][\d\w.\-\/]+)$/',
'daemon_sftp' => 'required|numeric|between:1,65535',
'daemon_listen' => 'required|numeric|between:1,65535',
'maintenance_mode' => 'boolean',
'upload_size' => 'int|between:1,1024',
];
@@ -96,12 +100,15 @@ class Node extends Model
protected $attributes = [
'public' => true,
'behind_proxy' => false,
'memory' => 0,
'memory_overallocate' => 0,
'disk' => 0,
'disk_overallocate' => 0,
'daemonBase' => '/var/lib/panel/volumes',
'daemonSFTP' => 2022,
'daemonListen' => 8080,
'daemon_base' => '/var/lib/pelican/volumes',
'daemon_sftp' => 2022,
'daemon_listen' => 8080,
'maintenance_mode' => false,
'tags' => '[]',
];
protected function casts(): array
@@ -109,20 +116,41 @@ class Node extends Model
return [
'memory' => 'integer',
'disk' => 'integer',
'daemonListen' => 'integer',
'daemonSFTP' => 'integer',
'daemon_listen' => 'integer',
'daemon_sftp' => 'integer',
'behind_proxy' => 'boolean',
'public' => 'boolean',
'maintenance_mode' => 'boolean',
'tags' => 'array',
];
}
public function getRouteKeyName(): string
{
return 'id';
}
protected static function booted(): void
{
static::creating(function (self $node) {
$node->uuid = Str::uuid();
$node->daemon_token = encrypt(Str::random(self::DAEMON_TOKEN_LENGTH));
$node->daemon_token_id = Str::random(self::DAEMON_TOKEN_ID_LENGTH);
return true;
});
static::deleting(function (self $node) {
throw_if($node->servers()->count(), new HasActiveServersException(trans('exceptions.egg.delete_has_servers')));
});
}
/**
* Get the connection address to use when making calls to this node.
*/
public function getConnectionAddress(): string
{
return "$this->scheme://$this->fqdn:$this->daemonListen";
return "$this->scheme://$this->fqdn:$this->daemon_listen";
}
/**
@@ -137,7 +165,7 @@ class Node extends Model
'token' => decrypt($this->daemon_token),
'api' => [
'host' => '0.0.0.0',
'port' => $this->daemonListen,
'port' => $this->daemon_listen,
'ssl' => [
'enabled' => (!$this->behind_proxy && $this->scheme === 'https'),
'cert' => '/etc/letsencrypt/live/' . Str::lower($this->fqdn) . '/fullchain.pem',
@@ -146,9 +174,9 @@ class Node extends Model
'upload_limit' => $this->upload_size,
],
'system' => [
'data' => $this->daemonBase,
'data' => $this->daemon_base,
'sftp' => [
'bind_port' => $this->daemonSFTP,
'bind_port' => $this->daemon_sftp,
],
],
'allowed_mounts' => $this->mounts->pluck('source')->toArray(),
@@ -240,4 +268,91 @@ class Node extends Model
];
})->values();
}
public function systemInformation(): array
{
return once(function () {
try {
return resolve(DaemonConfigurationRepository::class)
->setNode($this)
->getSystemInformation(connectTimeout: 3);
} catch (Exception $exception) {
$message = str($exception->getMessage());
if ($message->startsWith('cURL error 6: Could not resolve host')) {
$message = str('Could not resolve host');
}
if ($message->startsWith('cURL error 28: Failed to connect to ')) {
$message = $message->after('cURL error 28: ')->before(' after ');
}
return ['exception' => $message->toString()];
}
});
}
public function serverStatuses(): array
{
$statuses = [];
try {
$statuses = Http::daemon($this)->connectTimeout(1)->timeout(1)->get('/api/servers')->json() ?? [];
} catch (Exception $exception) {
report($exception);
}
foreach ($statuses as $status) {
$uuid = fluent($status)->get('configuration.uuid');
cache()->remember("servers.$uuid.container.status", now()->addMinute(), fn () => fluent($status)->get('state'));
}
return $statuses;
}
public function statistics()
{
$default = [
'memory_total' => 0,
'memory_used' => 0,
'swap_total' => 0,
'swap_used' => 0,
'load_average1' => 0.00,
'load_average5' => 0.00,
'load_average15' => 0.00,
'cpu_percent' => 0.00,
'disk_total' => 0,
'disk_used' => 0,
];
try {
return Http::daemon($this)
->connectTimeout(1)
->timeout(1)
->get('/api/system/utilization')
->json() ?? $default;
} catch (Exception) {
return $default;
}
}
public function ipAddresses(): array
{
return cache()->remember("nodes.$this->id.ips", now()->addHour(), function () {
$ips = collect();
if (is_ip($this->fqdn)) {
$ips = $ips->push($this->fqdn);
} elseif ($dnsRecords = gethostbynamel($this->fqdn)) {
$ips = $ips->concat($dnsRecords);
}
try {
$addresses = Http::daemon($this)->connectTimeout(1)->timeout(1)->get('/api/system/ips')->json();
$ips = $ips->concat(fluent($addresses)->get('ip_addresses'));
} catch (Exception) {
// pass
}
return $ips->all();
});
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\ServerState;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Notifications\Notifiable;
@@ -21,7 +22,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
* @property int $id
* @property string|null $external_id
* @property string $uuid
* @property string $uuidShort
* @property string $uuid_short
* @property int $node_id
* @property string $name
* @property string $description
@@ -98,7 +99,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
* @method static \Illuminate\Database\Eloquent\Builder|Server whereThreads($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereUuid($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereUuidShort($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereuuid_short($value)
*
* @mixin \Eloquent
*/
@@ -112,12 +113,6 @@ class Server extends Model
*/
public const RESOURCE_NAME = 'server';
public const STATUS_INSTALLING = 'installing';
public const STATUS_INSTALL_FAILED = 'install_failed';
public const STATUS_REINSTALL_FAILED = 'reinstall_failed';
public const STATUS_SUSPENDED = 'suspended';
public const STATUS_RESTORING_BACKUP = 'restoring_backup';
/**
* The table associated with the model.
*/
@@ -128,7 +123,7 @@ class Server extends Model
* on server instances unless the user specifies otherwise in the request.
*/
protected $attributes = [
'status' => self::STATUS_INSTALLING,
'status' => ServerState::Installing,
'oom_disabled' => true,
'installed_at' => null,
];
@@ -152,7 +147,7 @@ class Server extends Model
'status' => 'nullable|string',
'memory' => 'required|numeric|min:0',
'swap' => 'required|numeric|min:-1',
'io' => 'required|numeric|between:10,1000',
'io' => 'required|numeric|between:0,1000',
'cpu' => 'required|numeric|min:0',
'threads' => 'nullable|regex:/^[0-9-,]+$/',
'oom_disabled' => 'sometimes|boolean',
@@ -171,6 +166,7 @@ class Server extends Model
{
return [
'node_id' => 'integer',
'status' => ServerState::class,
'skip_scripts' => 'boolean',
'owner_id' => 'integer',
'memory' => 'integer',
@@ -203,12 +199,12 @@ class Server extends Model
public function isInstalled(): bool
{
return $this->status !== self::STATUS_INSTALLING && $this->status !== self::STATUS_INSTALL_FAILED;
return $this->status !== ServerState::Installing && $this->status !== ServerState::InstallFailed;
}
public function isSuspended(): bool
{
return $this->status === self::STATUS_SUSPENDED;
return $this->status === ServerState::Suspended;
}
/**
@@ -240,7 +236,7 @@ class Server extends Model
*/
public function allocations(): HasMany
{
return $this->hasMany(Allocation::class, 'server_id');
return $this->hasMany(Allocation::class);
}
/**
@@ -251,6 +247,11 @@ class Server extends Model
return $this->hasOne(Egg::class, 'id', 'egg_id');
}
public function eggVariables(): HasMany
{
return $this->hasMany(EggVariable::class, 'egg_id', 'egg_id');
}
/**
* Gets information for the egg variables associated with this server.
*/
@@ -267,6 +268,11 @@ class Server extends Model
});
}
public function serverVariables(): HasMany
{
return $this->hasMany(ServerVariable::class);
}
/**
* Gets information for the node associated with this server.
*/
@@ -323,7 +329,7 @@ class Server extends Model
public function resolveRouteBinding($value, $field = null): ?self
{
return match ($field) {
'uuid' => $this->where('uuidShort', $value)->orWhere('uuid', $value)->firstOrFail(),
'uuid' => $this->where('uuid_short', $value)->orWhere('uuid', $value)->firstOrFail(),
default => $this->where('id', $value)->firstOrFail(),
};
}
@@ -349,7 +355,7 @@ class Server extends Model
$this->isSuspended() ||
$this->node->isUnderMaintenance() ||
!$this->isInstalled() ||
$this->status === self::STATUS_RESTORING_BACKUP ||
$this->status === ServerState::RestoringBackup ||
!is_null($this->transfer)
) {
throw new ServerStateConflictException($this);
@@ -366,7 +372,7 @@ class Server extends Model
{
if (
!$this->isInstalled() ||
$this->status === self::STATUS_RESTORING_BACKUP ||
$this->status === ServerState::RestoringBackup ||
!is_null($this->transfer)
) {
throw new ServerStateConflictException($this);
@@ -388,4 +394,17 @@ class Server extends Model
throw new DaemonConnectionException($exception);
}
}
public function retrieveStatus(): string
{
$status = cache()->get("servers.$this->uuid.container.status");
if ($status) {
return $status;
}
$this->node->serverStatuses();
return cache()->get("servers.$this->uuid.container.status") ?? 'missing';
}
}

View File

@@ -5,7 +5,11 @@ namespace App\Models;
use App\Exceptions\DisplayException;
use App\Rules\Username;
use App\Facades\Activity;
use Illuminate\Support\Collection;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasAvatar;
use Filament\Models\Contracts\HasName;
use Filament\Panel;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\In;
use Illuminate\Auth\Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -79,7 +83,7 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
*
* @mixin \Eloquent
*/
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAvatar, HasName
{
use Authenticatable;
use Authorizable {can as protected canned; }
@@ -139,18 +143,20 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'language' => 'en',
'use_totp' => false,
'totp_secret' => null,
'name_first' => '',
'name_last' => '',
];
/**
* Rules verifying that the data being stored matches the expectations of the database.
*/
public static array $validationRules = [
'uuid' => 'required|string|size:36|unique:users,uuid',
'uuid' => 'nullable|string|size:36|unique:users,uuid',
'email' => 'required|email|between:1,191|unique:users,email',
'external_id' => 'sometimes|nullable|string|max:191|unique:users,external_id',
'username' => 'required|between:1,191|unique:users,username',
'name_first' => 'required|string|between:1,191',
'name_last' => 'required|string|between:1,191',
'name_first' => 'nullable|string|between:0,191',
'name_last' => 'nullable|string|between:0,191',
'password' => 'sometimes|nullable|string',
'root_admin' => 'boolean',
'language' => 'string',
@@ -170,6 +176,12 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected static function booted(): void
{
static::creating(function (self $user) {
$user->uuid = Str::uuid()->toString();
return true;
});
static::deleting(function (self $user) {
throw_if($user->servers()->count() > 0, new DisplayException(__('admin/user.exceptions.user_has_servers')));
@@ -177,6 +189,11 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
});
}
public function getRouteKeyName(): string
{
return 'id';
}
/**
* Implement language verification by overriding Eloquence's gather
* rules function.
@@ -196,7 +213,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
public function toVueObject(): array
{
return Collection::make($this->toArray())->except(['id', 'external_id'])->toArray();
return collect($this->toArray())->except(['id', 'external_id'])->toArray();
}
/**
@@ -278,6 +295,11 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
->groupBy('servers.id');
}
public function subusers(): HasMany
{
return $this->hasMany(Subuser::class);
}
protected function checkPermission(Server $server, string $permission = ''): bool
{
if ($this->root_admin || $server->owner_id === $this->id) {
@@ -313,4 +335,26 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $this->canned($abilities, $arguments);
}
public function isLastRootAdmin(): bool
{
$rootAdmins = User::query()->where('root_admin', true)->limit(2)->get();
return once(fn () => $rootAdmins->count() === 1 && $rootAdmins->first()->is($this));
}
public function canAccessPanel(Panel $panel): bool
{
return $this->root_admin;
}
public function getFilamentName(): string
{
return $this->name_first ?: $this->username;
}
public function getFilamentAvatarUrl(): ?string
{
return 'https://gravatar.com/avatar/' . md5(strtolower($this->email));
}
}

View File

@@ -38,6 +38,6 @@ class AddedToServer extends Notification implements ShouldQueue
->greeting('Hello ' . $this->server->user . '!')
->line('You have been added as a subuser for the following server, allowing you certain control over the server.')
->line('Server Name: ' . $this->server->name)
->action('Visit Server', url('/server/' . $this->server->uuidShort));
->action('Visit Server', url('/server/' . $this->server->uuid_short));
}
}

Some files were not shown because too many files have changed in this diff Show More