Compare commits

...

155 Commits

Author SHA1 Message Date
github-actions[bot]
7edbc72074 ci(release): bump version 2025-05-09 20:26:24 +00:00
Boy132
f400e2db76 Fix TRUSTED_PROXIES with * (#1358) 2025-05-09 16:22:33 -04:00
Boy132
1f7562563a Use github error format for phpstan tests (#1357) 2025-05-09 21:03:50 +02:00
Boy132
2296e41a8b Add button to view install logs (#1356)
Co-authored-by: notCharles <charles@pelican.dev>
2025-05-09 21:03:32 +02:00
MartinOscar
7971dc13fc chore: Refactor Mounts (#1236) 2025-05-09 13:18:20 -04:00
Boy132
8406f4686c Enable ipv6 on frontend (#1350) 2025-05-09 08:44:18 +02:00
Charles
67705b14b4 remove ComicMono as default set to monospace (#1352) 2025-05-08 18:00:51 -04:00
Boy132
bc115af5fd Replace File with Storage on EditProfile (#1353) 2025-05-08 22:14:53 +02:00
MartinOscar
da35703f75 Hide ChartWidgets when Server isInConflictState or Offline (#1348) 2025-05-08 20:42:14 +02:00
MartinOscar
c54bfd714b Make Tags work in StoreNodeRequest (#1349) 2025-05-08 19:08:13 +02:00
Lance Pioch
b83e3657d6 Laravel 12.13.0 Shift (#1347)
Co-authored-by: Shift <shift@laravelshift.com>
2025-05-07 15:50:41 -05:00
Boy132
e2c87a8206 Add back network chart (#1283)
* add back network chart

* don't show timestamp

* convert "total" to "real time"

* fix typo

* set min to 0

* sort data to make sure we actually get the previous value

* Fix `ServerNetworkChart`

* Many changes...

* small cleanup

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
Co-authored-by: notCharles <charles@pelican.dev>
2025-05-06 23:32:01 +02:00
Boy132
e38a736b61 Small cleanup for new egg features (#1343) 2025-05-06 13:01:34 +02:00
Boy132
26e20453bf Prevent primary allocation overwrite on save (#1344) 2025-05-06 13:01:09 +02:00
Boy132
292523d153 Cleanup files mount and fix path for global search (#1341) 2025-05-06 08:36:51 +02:00
PalmarHealer
85d625d118 Rework subuser permission loading (#1311)
* Remove open in new tab since both are on filament now.

Removing the open in new tab since both are on filament now. And the tenant: null was function default so not needed aswell

* Rework permission tab loading

Reworked permission tab loading to make it easier to expand on it in the future. This is way more friendly if extensions are planned in the future.

* Rework permission tab loading

Reworked permission tab loading to make it easier to expand on it in the future. This is way more friendly if extensions are planned in the future.

* Rework permission tab loading

Reworked permission tab loading to make it easier to expand on it in the future. This is way more friendly if extensions are planned in the future.

* Update UserResource.php

Used wrong name. It's not the name, the label has to be checked there.

* Fix: wrong name used

Used wrong name. It's not the name, the label has to be checked there.

* Update permission loading
Moved permission list to app/Models/Permission.php and made UserResource.php and ListUsers.php use it.

* Fix Pint and PHPStan error
Added comments

* Update array key
Updated array key using the lowercase name. Suggested by https://github.com/Boy132

* Correct array key
Updated array key using the lowercase. Suggested by https://github.com/Boy132

* Revert/correct array key
Updated array key using the lowercase and the correct label.

* Add 'user' key
In the old $permission array was user an entry witch is missing in permissionTabs()

* Style and return
Added @return and removed empty lines

* pin
fix pint

* fix pint
remove @return

* fix pint
add () since pint is still not happy

* remove mb_strtolower
mb_strtolower is not necessary

* remove schema for control
remove ->schema for control tab.

* Remove import

Remove unused import

* correct translation key

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

* make columns optional,
checkboxList => columns is now optional and default to 2

* move user and control registration
removed control registration since it was duplicate and move user registration to permissionTabs

* update @return on permissionTabs()

* Fix array key warning

* simplify permissions data

* revert this

* fix edit modal

* update icons

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
Co-authored-by: Boy132 <mail@boy132.de>
2025-05-05 17:35:17 -04:00
Boy132
c8230771ec Fix 500 when searching for empty term (#1340) 2025-05-05 23:31:36 +02:00
Charles
79691ba663 move redis only command to if statement (#1337) 2025-05-05 16:43:27 -04:00
Boy132
a6326f64fb Add back behind_proxy to ui (#1263)
* add back `behind_proxy` to ui

* combine `scheme` and `behind_proxy` into one component

* remove debug stuff

* update translations

* make bulky
2025-05-05 13:00:34 +02:00
Boy132
03745eb4be Allow to assign nodes to roles (node ownership) (#1231)
* allow to assign nodes to roles

* fix typo

* fix node policy

* small ui improvements

* add missing translation

* make phpstan happy

* fix migration on mysql

* also restrict mounts & database hosts to allowed nodes

* fix migration on mysql v2

* changes from review

* fix hasManyThrough

* change `accessibleNodes` to builder

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-05-05 12:58:55 +02:00
Charles
c0fda71e20 Font Saga Continues... (#1339)
Add back removed ??
2025-05-04 17:22:18 -04:00
Charles
f2f1026a97 Font Saga Continues (#1338)
Nuke comic, just use monospace..... make life easy
2025-05-04 17:03:45 -04:00
Charles
e1eaf805ea composer update (#1335) 2025-05-04 09:15:25 -04:00
Charles
03ec20e3a0 fix settings on mobile (#1336) 2025-05-04 09:15:12 -04:00
Charles
a5ffff8c8c Add Comic Mono to the list (#1330)
* Add Comic Mono to list and make default

* Update preview

* Create folder if missing.

* match composer lock from pr
2025-05-03 08:21:02 -04:00
Charles
82ef6c1408 Add server power actions to new context menu (#1321)
* add server power action context menu

* Update app/Filament/App/Resources/ServerResource/Pages/ListServers.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

* Cleanup

* Add missed enable

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-05-02 12:15:05 -04:00
Charles
2d581c7cbd Remove get_fonts, Fix docker container console font selection (#1329)
* Update `get_fonts`

This should fix docker, Has to be changed as we use alpine for docker which does not support GLOB_BRACE

* #2?

* #3

* FINAL BOSS FIGHT

Fixes Docker image <3

* Update resources/views/filament/components/server-console.blade.php

Co-authored-by: Lance Pioch <git@lance.sh>

---------

Co-authored-by: Lance Pioch <git@lance.sh>
2025-05-02 08:37:27 -04:00
Lance Pioch
7f0266be5e Laravel 12.12.0 Shift (#1325)
Co-authored-by: Shift <shift@laravelshift.com>
2025-05-02 03:21:21 -04:00
Charles
1ae9490b8f update filament assets (#1328) 2025-05-01 19:20:54 -04:00
MartinOscar
a53b3fda10 Append / to EditFiles (#1322) 2025-05-01 21:26:16 +02:00
MartinOscar
e9ddf80d10 Use $id as primaryKey for File Model (#1323) 2025-05-01 21:26:01 +02:00
Lance Pioch
3f1e99f1df composer update (#1320)
Co-authored-by: Shift <shift@laravelshift.com>
2025-05-01 14:28:44 -04:00
MartinOscar
435c615ff1 Add throwIf to daemonRepository (#1301) 2025-05-01 15:49:35 +02:00
Charles
3effd98013 Allow changing of the console font (#1277)
* Custom Fonts

* Update app/Filament/Pages/Auth/EditProfile.php

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>

* wip

* wip

* Update app/Filament/Pages/Auth/EditProfile.php

Co-authored-by: Lance Pioch <git@lance.sh>

* Update app/helpers.php

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>

* update

* add fonts folder for docker

* Add default font

* Update server console to preload the font

* Update settings/trans

---------

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
Co-authored-by: Lance Pioch <git@lance.sh>
2025-05-01 09:47:59 -04:00
Lance Pioch
e354bc9be7 Laravel 12.11.0 Shift (#1317)
Co-authored-by: Shift <shift@laravelshift.com>
2025-04-29 21:01:28 -04:00
Boy132
14d351103c Fix database & user not being deleted (#1315) 2025-04-29 17:05:49 +02:00
Boy132
92c23451af Improve file error handling (#1314)
* improve file error handling

* small cleanup

* fix typo
2025-04-29 17:05:29 +02:00
pelican-vehikl
2046fa453a Pest Test Improvements (#1137)
Co-authored-by: Lance Pioch <git@lance.sh>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-04-28 10:20:33 -04:00
Michael (Parker) Parker
b39a8186ae Resolve issue with avatar storage (#1281)
* Resolve issue with avatar storage

This resolves the issue with getting avatar storage working

updates the entrypoint to create the `pelican-data/storage` folder on start.

Adds a dev dockerfile to build locally instead of needing to update the standard dockerfile.

* Move avatar folder

Moves the avatars folder in the storage folder in-case anything else needs storage as well.

Fixes an issue in the entrypoint where it wasn't creating the sub-folder correctly.
2025-04-27 20:56:10 -04:00
Letter N
8ae3c88c91 generalize sponge installation (#1300) 2025-04-26 14:06:30 -04:00
MartinOscar
329a29f7da Add missing disabled in AllocationsRelationManager (#1304) 2025-04-26 06:42:29 -04:00
MartinOscar
98a2cab5ca Case insensitive EggFeature Listeners (#1303) 2025-04-26 06:41:59 -04:00
pelican-vehikl
8407547574 Add back Egg Features (#1271)
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
Co-authored-by: Lance Pioch <git@lance.sh>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-04-24 18:24:18 -04:00
Lance Pioch
fccd7e5e75 composer update (#1298)
Co-authored-by: Shift <shift@laravelshift.com>
2025-04-24 15:33:26 -04:00
Lance Pioch
c0225b9e10 Laravel 12.10.1 Shift (#1294)
Co-authored-by: Shift <shift@laravelshift.com>
2025-04-24 10:08:49 -04:00
Boy132
544aaab960 Make sure 2fa requirement is enforced (#1289) 2025-04-23 16:03:10 +02:00
Boy132
914e215bc0 Separate user uploadable avatars into own setting (#1286) 2025-04-23 16:02:52 +02:00
Sebastien Green
90fd73f6a4 Change section header icon self alignment to centre (#1279) 2025-04-23 10:02:44 -04:00
Boy132
0037b4a1d4 Only use navigation groups when using sidebar (#1288)
* Revert "Remove `NavigationGroups` for Admin Navbar (#1248)"

This reverts commit a186900262.

* make navigation groups conditional
2025-04-23 16:02:21 +02:00
Boy132
3deada57c6 Remove DynamicDatabaseConnection (#1290) 2025-04-23 16:02:08 +02:00
Gabriel
6427903f9f feat(console): save command history in session (#1282) 2025-04-22 17:29:17 -04:00
PalmarHealer
b16e19b4fb Remove open in new tab since both are on filament now. (#1292) 2025-04-22 17:28:00 -04:00
Boy132
7e99d5cd8e Use Arr::dot to display multi-dimensional activity log properties (#1285) 2025-04-22 22:27:50 +02:00
Boy132
05b1a44a34 Fix metadata coming from wings activity logs (#1284) 2025-04-22 22:27:31 +02:00
Letter N
058b613c98 handle failed oauth (#1264)
* handle failed oauths

* fix linter

* small cleanup

---------

Co-authored-by: Boy132 <mail@boy132.de>
2025-04-22 15:57:44 -04:00
Boy132
0e2ab4b711 Fix activity log query (#1258) 2025-04-22 08:28:24 +02:00
Quinten
ee838316e6 Make avatars work (#1251) 2025-04-21 11:25:36 +02:00
MartinOscar
ffd94b8892 Fix develop Node Version reported as outdated (#1272) 2025-04-18 16:41:10 +02:00
MartinOscar
a186900262 Remove NavigationGroups for Admin Navbar (#1248) 2025-04-18 10:39:25 -04:00
Lance Pioch
bf14755287 Laravel 12.9.2 Shift (#1266)
Co-authored-by: Shift <shift@laravelshift.com>
2025-04-18 10:37:21 -04:00
MartinOscar
038504fbec Only chunk if rows exceeds sqlite variables limit (999) (#1270) 2025-04-17 16:24:57 -04:00
MartinOscar
22a0a52f7b Chunk Sushi inserts based on rows count (#1259) 2025-04-17 00:04:58 +02:00
Boy132
862afaa0e9 Fix api docs for server update requests (#1262)
* workaround for api docs error

* add deprecated notice
2025-04-15 23:47:31 +02:00
MartinOscar
a4dd8cca4c Add live() to KeyValue on CreateServer & EditServer (#1261) 2025-04-15 16:06:37 +02:00
Letter N
e67e0830eb Fix Node graph not rendering correctly (#1253)
* use round instead of `Number::format`

* remove unused

* also replace `Number::format` in cpu & memory charts

---------

Co-authored-by: Boy132 <mail@boy132.de>
2025-04-15 01:27:35 +02:00
Boy132
b444112085 Correctly display backup status (#1256)
* add status attribute to backup

* hide actions when backup is not successful

* small cleanup
2025-04-14 12:59:03 +02:00
Boy132
f23d4d6971 Fix action in notifications (#1257) 2025-04-14 12:57:38 +02:00
MartinOscar
2a3781f5a8 Add pdo_pgsql to Docker (#1244) 2025-04-13 02:34:27 +02:00
MartinOscar
cb245dc722 Use recommended PHP 8.4 for Docker (#1245) 2025-04-13 02:30:09 +02:00
MartinOscar
3ffbf9e46a Allow users to remove their Avatar (#1247) 2025-04-13 02:29:46 +02:00
MartinOscar
8221c80ec2 Only allow image/png mimetype for Avatar (#1246) 2025-04-13 02:27:36 +02:00
MartinOscar
702a6bb750 Restore exception_handler & error_handler for Tests (#1239) 2025-04-12 16:44:46 +02:00
MartinOscar
02d7ad04ad Fix serverVariables not saving due to join (#1235)
* Fix `serverVariables` not saving due to `join`

* Remove deprecated `viewableServerVariables`
2025-04-12 16:44:24 +02:00
Boy132
7409f020ba Add storage:link to setup command (#1233) 2025-04-11 23:23:23 +02:00
Lance Pioch
98d8510f11 Laravel 12.8.1 Shift (#1226) 2025-04-11 09:29:33 -04:00
Lance Pioch
6c6d458445 Laravel 12.7.2 Shift (#1213)
* Bump Laravel version constraint

* Bump community package dependencies

* composer update

---------

Co-authored-by: Shift <shift@laravelshift.com>
2025-04-07 21:08:27 -04:00
Lance Pioch
51fda2eaf4 These have to be nullable originally (#1222) 2025-04-07 21:08:03 -04:00
Boy132
92fbd75772 Show different roles CheckboxList for root admins and non root admins (#1219)
* show different roles checkbox list for root admins and non root admins

* simplify saveRelationshipsUsing

* remove disableOptionWhen

* add migration to remove additional roles from root admins
2025-04-07 16:10:31 +02:00
Boy132
fa8ae0aea5 Add avatar providers (#1192)
* Add avatar providers

* fix exists check for local avatar

* Use avatar in user lists

---------

Co-authored-by: Charles <charles@pelican.dev>
2025-04-07 16:06:19 +02:00
Charles
377b3f170d Change table row options (#1220)
It's a known filament issue that large tables are SUPER slow.
2025-04-06 15:03:40 -04:00
MartinOscar
566e7c1b24 Allow user to choose archive name in FileManager (#1206)
* Allow user to choose `archive` name in `FileManager`

* Rollback `file.compress` activity translation
2025-04-06 14:52:25 -04:00
tfcprivt
b9d4773bd7 Fixed the Select dropdown to use searchable on the Edit Files Page. (#1204)
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-04-06 14:05:54 -04:00
Boy132
49638e75e5 Add setup wizard to database host (#1216)
* add setup wizard to database host

* make phpstan happy

* remove `.` in lang

---------

Co-authored-by: notCharles <charles@pelican.dev>
2025-04-06 14:04:20 -04:00
MartinOscar
80c404a48c Chore filament:upgrade (#1210) 2025-04-05 02:56:52 +02:00
Charles
befe6be80b Update Overview, Again. Add some customization (#1200)
* wip

* wip

* wip

* overview 2.1

* Combine 2 branches into one

* updates

* Fix 500

* use my friend JSON

* Use switch
2025-04-04 12:08:43 -04:00
Boy132
3639d7ccec Fix file writing (#1218) 2025-04-04 14:38:08 +02:00
Boy132
20f271041a Allow to register custom role permissions (#1208) 2025-04-04 09:30:45 +02:00
Boy132
c3b8b71f9c Allow to register custom console widgets (#1209) 2025-04-04 09:30:25 +02:00
Boy132
c73d0544d9 Refactor admin dashboard to use widgets (#1207) 2025-04-04 09:30:00 +02:00
MartinOscar
484a3b445a Prevent Server primary allocation dissociation (#1197) 2025-04-04 00:56:15 +02:00
MartinOscar
c0fa8c1cd8 Use afterSave instead of handleRecordUpdate & move transferServer (#1195)
* Use `afterSave` instead of `handleRecordUpdate` & move `transferServer`

* Override `getSavedNotification` instead of `save`
2025-04-03 15:59:10 +02:00
MartinOscar
e562a35057 Add unique foreign keys for EggVariable (#1196)
* Fix tests \`egg_variable\` order

* Add `EggVariable` unique foreign key for `env_variable` & `name`
2025-04-03 15:58:49 +02:00
MartinOscar
636279c6eb Add FileNotEditableException (#1135)
* Add `FileNotEditableException`

* Send `Notification` instead of Throwing

* Remove useless `function`

* Make them all `AlertBanner`
2025-04-02 21:44:51 -04:00
Boy132
ed88ce9ae3 cleanup panel config file (#1198) 2025-04-02 21:44:33 -04:00
Lance Pioch
0cce716e2c Laravel 12.6.0 Shift (#1205)
* Bump Laravel version constraint

* composer update

* Force PHP `8.2` platform

* Fix `SplFileInfo` cast in `CleanServiceBackupFilesCommand`

* Bump larastan to dev commit

* Unpin filament

---------

Co-authored-by: Shift <shift@laravelshift.com>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-04-03 03:43:01 +02:00
tfcprivt
3639f0cb50 Added Icons for the Power Actions (#1203) 2025-04-02 21:29:29 -04:00
Boy132
9c3f47590c Fix server transfer backend (#1139)
* fix notify in transfer service

* remove magical array

* fix phpstan

* better validation for allocation_additional and better docs generation

* update transfer ui

* update request body
2025-04-01 11:19:14 +02:00
Boy132
630031e1c2 Add some refreshs & notifications to EditServer action buttons (#1174)
* add some refreshs & notifications to EditServer action buttons

* reinstall server when trying to toggle failed state

* don't show modal on normal toggle install

* don't print raw exception on reinstall & suspension
2025-04-01 08:36:19 +02:00
Boy132
2c00f90ba6 remove codeowners (#1193) 2025-03-31 07:32:01 -04:00
Lance Pioch
875dca54f5 Switch inserts to proper creates (#1190)
* Switch inserts to proper creates

* Push `$token` to `$tokens[]` in `ToggleTwoFactorService`

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-03-30 21:56:49 -04:00
Michael (Parker) Parker
a03b604f2d Merge pull request #1184 from JoanFo1456/main
Change console font
2025-03-30 14:53:28 -04:00
Lance Pioch
8261184b57 Officially support PostgreSQL database (#1066)
* Just skip this table because it no longer exists

* Add postgresql

* This no longer needs to be there

* These are the same output in mysql, but different in postgresql

* Fix these migrations for postgresql

* This table no longer exists

* This is expected to be a json column for json operations, required for postgresql

* Shoot for the stars

* Fix pint

* Why was this missing

* Updates

* Restore this

* This needs to be explicit

* Don’t like strings

* Fix these classes

* Use different method to compare dates

* Apparently postgresql doesn’t like case insensitivity

* Postgresql orders it backwards

* Ordered different by postgresql

* Unnecessary and breaking

* Make sure the order is correct for postresql

* Fix this with the order too

* Remove this

* Force email to be lowercased

* Update app/Models/User.php
2025-03-30 14:44:03 -04:00
MartinOscar
bca02ced86 Fix typo for SESSION_DRIVER in RedisSetupCommand (#1188) 2025-03-29 19:58:36 +01:00
Boy132
a768fadaea Reimplement password reset (#1182)
* add password reset to all panels

* remove old leftovers

* fix reset url in account created mail
2025-03-28 23:51:42 +01:00
Boy132
7471347b55 Improve alert banner fetching (#1173)
* use events for alert banner pulling

* add ids to alert banners to prevent duplicates
2025-03-28 23:50:34 +01:00
MartinOscar
1457c4bd06 Lint console.css 2025-03-28 23:09:32 +01:00
JoanFo
8b943fa160 Update server-console.blade.php
Removed font.css referrence (removed file)
2025-03-28 21:54:15 +01:00
JoanFo
5c5c9654b4 Update console.css
Added content from ./font.css
2025-03-28 21:53:29 +01:00
JoanFo
dd20cb0f11 Delete public/css/filament/server/font.css
Adding the contents on console.css
2025-03-28 21:50:50 +01:00
JoanFo
88deb35dc8 Create font.css
Forgot the font css file
2025-03-28 18:47:06 +01:00
JoanFo
0f92632c06 Update server-console.blade.php 2025-03-28 18:26:38 +01:00
JoanFo
a85fc5c88e Add files via upload 2025-03-28 18:23:36 +01:00
Charles
8d7eff13fb Update Overview and Server List (#1151)
* Update Overview and Server List

* Fix background on light mode
2025-03-28 11:57:40 -04:00
MartinOscar
c39c29e50b Must use Closure since MenuItem does not leverage Concerns (#1181)
* Must use `Closure` since `MenuItem` does not leverage Concerns

* Translate `Profile` in `ServerPanelProvider`

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-03-28 12:50:34 +01:00
MartinOscar
db3b16e609 Add Owner Filter to ListServers (#1180)
* Add `Owner` Filter to `ListServers`

* Make `Owner` filter show on `other` & `all` tabs

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-03-28 12:27:51 +01:00
MartinOscar
72b9c309d3 Fix EditProfile cannot use / in trans() (#1178) 2025-03-27 22:52:39 +01:00
MartinOscar
68a6dc45cb Set Schedule next_run_at in ListSchedules to Never if disabled (#1176) 2025-03-27 20:24:33 +01:00
MartinOscar
9a258efe53 Force app panel for EditProfile (#1162)
* Force `app` panel for `EditProfile`

* Force `app` panel for `OAuthController`

* Use translation in `AdminPanelProvider`

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-03-27 18:54:08 +01:00
MartinOscar
3310746107 Show different emptyStateHeading when activeTab is my in ListServers (#1157) 2025-03-27 15:43:07 +01:00
MartinOscar
42706dba14 Remove duplicated activity.read & use relations in EditUser (#1165) 2025-03-27 15:40:19 +01:00
MartinOscar
ec6529ac4c Fix \compose.yml\ spelling (#1170) 2025-03-27 05:46:40 -04:00
Morpheus636
bced93c5be Removed outdated docker documentation (#1166) 2025-03-27 05:46:27 -04:00
Charles
cb1c953540 Fix Egg Variable Order (#1172)
* Fix Egg Variable Order

* Fix Client Side Startup variable order
2025-03-27 05:45:59 -04:00
Boy132
c689f6860b Disallow 0.0.0.0, 127.0.0.1 and localhost as node fqdn (#1158)
* disallow `0.0.0.0`, `127.0.01` and `localhost` as node fqdn

* use rules of model
2025-03-26 09:03:13 +01:00
MartinOscar
a73404c1b4 Fix Server ForceDelete by adding missing redirect (#1156) 2025-03-24 22:28:40 +01:00
Boy132
61cbe5465f Schedules: Update next_run_at when editing & show notification if cron is invalid (#1141)
* update `next_run_at` when editing & show notification if cron is invalid

* move getNextRun to resource
2025-03-24 09:08:51 +01:00
Charles
5bea1ea80a Fix 500 when viewing node (#1144)
* Fix node 500

* this feels better

* Update app/Models/Node.php

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>

* Update app/Models/Node.php

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>

* pint

---------

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2025-03-23 17:43:51 -04:00
MartinOscar
b69136d7a4 Add Server ForceDelete (#1134) 2025-03-23 17:08:59 -04:00
Boy132
a8c3082b79 Add UI for server transfers (#1119)
* add ui for server transfers

* disable transfer button when server is in conflict state
2025-03-23 17:02:22 -04:00
Boy132
a47ad071c9 Fix activity log on rename api endpoint (#1149) 2025-03-22 17:01:11 +01:00
MartinOscar
ab953b2f4d Use composer to copy .env.example after its done installing packages. (#1073) 2025-03-22 14:06:47 +01:00
MartinOscar
03d6c88f65 Fix OAuth Modals CopyAction & use x-filament::link (#1146)
* Fix `CopyAction` & use `x-filament::link`

* PHPStan
2025-03-22 14:05:14 +01:00
PalmarHealer
b4eab02254 Remove cursor not allowed rule (#1147)
* remove cursor not allowed rule

* re-add css rule
Added the previously removed rule back and specified selector.

* Apply suggestions from code review

---------

Co-authored-by: Lance Pioch <lancepioch@gmail.com>
2025-03-22 08:31:57 -04:00
Boy132
23f39acd4e Add host config to gitlab oauth (& add setup steps) (#1142)
* add custom provider class for gitlab to allow custom hosts

* add setup steps
2025-03-21 21:23:38 +01:00
Boy132
82b0aff105 Fix SMTP scheme/ encryption (#1120) 2025-03-21 08:42:42 +01:00
Charles
adca50a372 Catch 500 on backup page when you hit the backup rate limit (#1132)
* Catch backup throwable

* phpstan

* Update notification
2025-03-20 10:38:24 -04:00
MartinOscar
c5230efad6 Catch NodeUpdateService in EditNode & NodeController (#1106) 2025-03-18 23:07:40 +01:00
Charles
e5d9d53aa3 Remove unused groupBy (#1130)
Fixes #1107
2025-03-18 18:07:35 -04:00
MartinOscar
29f3defc73 Catch DaemonFileRepository & show Alert (#1129)
* Catch `DaemonFileRepository`

Co-authored-by: notCharles <charles@pelican.dev>

* Pint

---------

Co-authored-by: notCharles <charles@pelican.dev>
2025-03-18 23:07:21 +01:00
Boy132
2dbb9a5f9b Add update egg bulk action (#1122)
* add update egg bulk action

* make phpstan happy

* use `before`
2025-03-18 17:42:04 +01:00
Boy132
a05e330b19 Fix path resolving when moving files (#1116)
* don't resolve new path when moving file

* use full path in activity log

* don't require file name when moving
2025-03-18 17:36:27 +01:00
Boy132
4a7951995e Add bulk move (#1117) 2025-03-18 17:36:13 +01:00
Boy132
3d29243cf0 Add tag filter to lists (#1124) 2025-03-18 17:35:36 +01:00
Boy132
c52439132d Add tags to egg importer & exporter, add tags to egg jsons (#1125)
* add tags to egg importer & exporter

* add tags to stock eggs
2025-03-18 17:35:15 +01:00
Boy132
517f17cbcc Add redirect after clicking reinstall (#1126) 2025-03-18 17:35:01 +01:00
Lance Pioch
f8d119b458 Update readme.md 2025-03-18 10:06:51 -04:00
MartinOscar
fbeb747fc3 Fix ImportEggAction (#1110)
* `Arr::Wrap` `data.files` cause if its unique its a string

* Use `data.url` first so it gets overwritten by `data.files`
2025-03-17 18:07:36 +01:00
Boy132
f563128237 Make sure to not sync root admin role (#1113) 2025-03-17 17:23:44 +01:00
MartinOscar
f2f3ee548f Add App Logo (#1104)
* Add `app.logo` to `Settings`

* Use `app.name` if `app.logo` is null
2025-03-17 13:28:32 +01:00
MartinOscar
0b3dce132f Add header, footer & body-end views (#1111) 2025-03-17 13:28:18 +01:00
MartinOscar
5bf23b972d Fix DaemonFileRepository in ListFiles (#1109)
* Fix `DaemonFileRepository` in `ListFiles`

* Use match for `getPermissionsFromModeBit`
2025-03-17 12:17:05 +01:00
MartinOscar
22d02c0df5 Remove NodeCreationService (#1092) 2025-03-17 05:46:33 +01:00
MartinOscar
253abf65b1 Hide Directory size in ListFiles (#1102)
* Hide `directory` size in `ListFiles`

* Use only one `DaemonFileRepository` in `ListFiles`
2025-03-16 15:51:02 +01:00
MartinOscar
d452e3d2f2 Use ContainerStatus::tryFrom in ProcessScheduleService (#1101) 2025-03-16 15:36:15 +01:00
Charles
0051370f24 Reduce svg size (#1100) 2025-03-16 10:20:19 -04:00
309 changed files with 5921 additions and 3496 deletions

View File

@@ -3,5 +3,4 @@ APP_DEBUG=false
APP_KEY=
APP_URL=http://panel.test
APP_INSTALLED=false
APP_TIMEZONE=UTC
APP_LOCALE=en

15
.github/CODEOWNERS vendored
View File

@@ -1,15 +0,0 @@
# Lines starting with '#' are comments.
# Each line is a file pattern followed by one or more owners.
# More details are here: https://help.github.com/articles/about-codeowners/
# The '*' pattern is global owners.
# Order is important. The last matching pattern has the most precedence.
# The folders are ordered as follows:
# In each subsection folders are ordered first by depth, then alphabetically.
# This should make it easy to add new rules without breaking existing ones.
# Global
* @pelican-dev/panel

View File

@@ -213,3 +213,79 @@ jobs:
- name: Integration tests
run: vendor/bin/pest tests/Integration
postgresql:
name: PostgreSQL
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
database: ["postgres:14"]
services:
database:
image: ${{ matrix.database }}
env:
POSTGRES_DB: testing
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: postgres
DB_PASSWORD: postgres
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
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: |
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Unit tests
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration

View File

@@ -35,7 +35,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
php: [ 8.2, 8.3, 8.4 ]
steps:
- name: Code Checkout
uses: actions/checkout@v4
@@ -68,4 +68,4 @@ jobs:
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: PHPStan
run: vendor/bin/phpstan --memory-limit=-1
run: vendor/bin/phpstan --memory-limit=-1 --error-format=github

4
.gitignore vendored
View File

@@ -1,7 +1,6 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
@@ -24,8 +23,7 @@ yarn-error.log
/.vscode
public/assets/manifest.json
/database/*.sqlite
/database/*.sqlite-journal
/database/*.sqlite*
filament-monaco-editor/
_ide_helper*
/.phpstorm.meta.php

View File

@@ -1,16 +1,9 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Production Dockerfile
# For those who want to build this Dockerfile themselves, uncomment lines 6-12 and replace "localhost:5000/base-php:$TARGETARCH" on lines 17 and 67 with "base".
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine as base
# ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
# RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql
# RUN rm /usr/local/bin/install-php-extensions
##
# If you want to build this locally you want to run `docker build -f Dockerfile.dev`
##
# ================================
# Stage 1-1: Composer Install
@@ -82,13 +75,16 @@ RUN chown root:www-data ./ \
&& chmod 750 ./ \
# Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \
# Symlink to env/database path, as www-data won't be able to write to webroot
# Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
# Create necessary directories
&& mkdir -p /pelican-data /var/run/supervisord /etc/supercronic \
# Finally allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# Allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord
# Configure Supervisor

View File

@@ -1,10 +1,10 @@
# ================================
# Stage 0: Build PHP Base Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN rm /usr/local/bin/install-php-extensions
RUN rm /usr/local/bin/install-php-extensions

112
Dockerfile.dev Normal file
View File

@@ -0,0 +1,112 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Development Dockerfile
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine AS base
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN rm /usr/local/bin/install-php-extensions
# ================================
# Stage 1-1: Composer Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS composer
WORKDIR /build
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Copy bare minimum to install Composer dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
# Copy bare minimum to install Yarn dependencies
COPY package.json yarn.lock ./
RUN yarn config set network-timeout 300000 \
&& yarn install --frozen-lockfile
# ================================
# Stage 2-1: Composer Optimize
# ================================
FROM --platform=$TARGETOS/$TARGETARCH composer AS composerbuild
# Copy full code to optimize autoload
COPY --exclude=Caddyfile --exclude=docker/ . ./
RUN composer dump-autoload --optimize
# ================================
# Stage 2-2: Build Frontend Assets
# ================================
FROM --platform=$TARGETOS/$TARGETARCH yarn AS yarnbuild
WORKDIR /build
# Copy full code
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --from=composer /build .
RUN yarn run build
# ================================
# Stage 5: Build Final Application Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS final
WORKDIR /var/www/html
# Install additional required libraries
RUN apk update && apk add --no-cache \
caddy ca-certificates supervisor supercronic
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
# Set permissions
# First ensure all files are owned by root and restrict www-data to read access
RUN chown root:www-data ./ \
&& chmod 750 ./ \
# Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \
# Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# Allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile
# Add Laravel scheduler to crontab
COPY docker/crontab /etc/supercronic/crontab
COPY docker/entrypoint.sh ./docker/entrypoint.sh
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/up || exit 1
EXPOSE 80 443
VOLUME /pelican-data
USER www-data
ENTRYPOINT [ "/bin/ash", "docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@@ -14,9 +14,9 @@ class NodeVersionsCheck extends Check
public function run(): Result
{
$all = Node::query()->count();
$all = Node::all();
if ($all === 0) {
if ($all->isEmpty()) {
$result = Result::make()
->notificationMessage(trans('admin/health.results.nodeversions.no_nodes_created'))
->shortSummary(trans('admin/health.results.nodeversions.no_nodes'));
@@ -25,16 +25,18 @@ class NodeVersionsCheck extends Check
return $result;
}
$latestVersion = $this->versionService->latestWingsVersion();
$outdated = Node::query()->get()
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion)
$outdated = $all
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && !$this->versionService->isLatestWings($node->systemInformation()['version']))
->count();
$all = $all->count();
$latestVersion = $this->versionService->latestWingsVersion();
$result = Result::make()
->meta([
'all' => $all,
'outdated' => $outdated,
'latestVersion' => $latestVersion,
])
->shortSummary($outdated === 0 ? trans('admin/health.results.nodeversions.all_up_to_date') : trans('admin/health.results.nodeversions.outdated', ['outdated' => $outdated, 'all' => $all]));

View File

@@ -3,7 +3,6 @@
namespace App\Console\Commands\Environment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class AppSettingsCommand extends Command
{
@@ -21,9 +20,13 @@ class AppSettingsCommand extends Command
if (!config('app.key')) {
$this->comment('Generating app key');
Artisan::call('key:generate');
$this->call('key:generate');
}
Artisan::call('filament:optimize');
$this->comment('Creating storage link');
$this->call('storage:link');
$this->comment('Caching components & icons');
$this->call('filament:optimize');
}
}

View File

@@ -35,7 +35,7 @@ class RedisSetupCommand extends Command
{
$this->variables['CACHE_STORE'] = 'redis';
$this->variables['QUEUE_CONNECTION'] = 'redis';
$this->variables['SESSION_DRIVERS'] = 'redis';
$this->variables['SESSION_DRIVER'] = 'redis';
$this->requestRedisSettings();

View File

@@ -6,6 +6,7 @@ use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use SplFileInfo;
class CleanServiceBackupFilesCommand extends Command
{
@@ -32,9 +33,10 @@ class CleanServiceBackupFilesCommand extends Command
*/
public function handle(): void
{
/** @var SplFileInfo[] */
$files = $this->disk->files('services/.bak');
collect($files)->each(function (\SplFileInfo $file) {
collect($files)->each(function ($file) {
$lastModified = Carbon::createFromTimestamp($this->disk->lastModified($file->getPath()));
if ($lastModified->diffInMinutes(Carbon::now()) > self::BACKUP_THRESHOLD_MINUTES) {
$this->disk->delete($file->getPath());

View File

@@ -2,8 +2,8 @@
namespace App\Console\Commands\Node;
use App\Models\Node;
use Illuminate\Console\Command;
use App\Services\Nodes\NodeCreationService;
class MakeNodeCommand extends Command
{
@@ -30,14 +30,6 @@ class MakeNodeCommand extends Command
protected $description = 'Creates a new node on the system via the CLI.';
/**
* MakeNodeCommand constructor.
*/
public function __construct(private NodeCreationService $creationService)
{
parent::__construct();
}
/**
* Handle the command execution process.
*
@@ -69,7 +61,7 @@ class MakeNodeCommand extends Command
$data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(trans('commands.make_node.daemonSFTPAlias'), '');
$data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(trans('commands.make_node.daemonBase'), '/var/lib/pelican/volumes');
$node = $this->creationService->handle($data);
$node = Node::create($data);
$this->line(trans('commands.make_node.success', ['name' => $data['name'], 'id' => $node->id]));
}
}

View File

@@ -31,8 +31,11 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags
$schedule->command('cache:prune-stale-tags')->hourly();
if (config('cache.default') === 'redis') {
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags
// This only needs to run when using redis. anything else throws an error.
$schedule->command('cache:prune-stale-tags')->hourly();
}
// Execute scheduled commands for servers every minute, as if there was a normal cron running.
$schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping();

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum BackupStatus: string implements HasColor, HasIcon, HasLabel
{
case InProgress = 'in_progress';
case Successful = 'successful';
case Failed = 'failed';
public function getIcon(): string
{
return match ($this) {
self::InProgress => 'tabler-circle-dashed',
self::Successful => 'tabler-circle-check',
self::Failed => 'tabler-circle-x',
};
}
public function getColor(): string
{
return match ($this) {
self::InProgress => 'primary',
self::Successful => 'success',
self::Failed => 'danger',
};
}
public function getLabel(): string
{
return str($this->value)->headline();
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum ConsoleWidgetPosition: string
{
case Top = 'top';
case AboveConsole = 'above_console';
case BelowConsole = 'below_console';
case Bottom = 'bottom';
}

View File

@@ -62,7 +62,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
self::Removing => 'warning',
self::Missing => 'danger',
self::Stopping => 'warning',
self::Offline => 'gray',
self::Offline => 'danger',
};
}

View File

@@ -14,4 +14,24 @@ enum RolePermissionModels: string
case Server = 'server';
case User = 'user';
case Webhook = 'webhook';
public function viewAny(): string
{
return RolePermissionPrefixes::ViewAny->value . ' ' . $this->value;
}
public function view(): string
{
return RolePermissionPrefixes::View->value . ' ' . $this->value;
}
public function create(): string
{
return RolePermissionPrefixes::Create->value . ' ' . $this->value;
}
public function update(): string
{
return RolePermissionPrefixes::Update->value . ' ' . $this->value;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Exceptions\Repository;
use Exception;
class FileNotEditableException extends Exception {}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Extensions\Avatar;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
abstract class AvatarProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
public static function getProvider(string $id): ?self
{
return Arr::get(static::$providers, $id);
}
/**
* @return array<string, static>
*/
public static function getAll(): array
{
return static::$providers;
}
public function __construct()
{
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
abstract public function get(User $user): ?string;
public function getName(): string
{
return Str::title($this->getId());
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use App\Models\User;
class GravatarProvider extends AvatarProvider
{
public function getId(): string
{
return 'gravatar';
}
public function get(User $user): string
{
return 'https://gravatar.com/avatar/' . md5($user->email);
}
public static function register(): self
{
return new self();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use App\Models\User;
class UiAvatarsProvider extends AvatarProvider
{
public function getId(): string
{
return 'uiavatars';
}
public function getName(): string
{
return 'UI Avatars';
}
public function get(User $user): ?string
{
// UI Avatars is the default of filament so just return null here
return null;
}
public static function register(): self
{
return new self();
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Extensions;
use App\Models\DatabaseHost;
class DynamicDatabaseConnection
{
public const DB_CHARSET = 'utf8';
public const DB_COLLATION = 'utf8_unicode_ci';
public const DB_DRIVER = 'mysql';
/**
* Adds a dynamic database connection entry to the runtime config.
*/
public function set(string $connection, DatabaseHost|int $host, string $database = 'mysql'): void
{
if (!$host instanceof DatabaseHost) {
$host = DatabaseHost::query()->findOrFail($host);
}
config()->set('database.connections.' . $connection, [
'driver' => self::DB_DRIVER,
'host' => $host->host,
'port' => $host->port,
'database' => $database,
'username' => $host->username,
'password' => $host->password,
'charset' => self::DB_CHARSET,
'collation' => self::DB_COLLATION,
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
abstract class FeatureProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
/**
* @param string[] $id
* @return self|static[]
*/
public static function getProviders(string|array|null $id = null): array|self
{
if (is_array($id)) {
return array_intersect_key(static::$providers, array_flip($id));
}
return $id ? static::$providers[$id] : static::$providers;
}
protected function __construct(protected Application $app)
{
if (array_key_exists($this->getId(), static::$providers)) {
if (!$this->app->runningUnitTests()) {
logger()->warning("Tried to create duplicate Feature provider with id '{$this->getId()}'");
}
return;
}
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
/**
* A matching subset string (case-insensitive) from the console output
*
* @return array<string>
*/
abstract public function getListeners(): array;
abstract public function getAction(): Action;
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Extensions\Features;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Repositories\Daemon\DaemonPowerRepository;
use Closure;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Validator;
class GSLToken extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'(gsl token expired)',
'(account not found)',
];
}
public function getId(): string
{
return 'gsltoken';
}
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var ServerVariable $serverVariable */
$serverVariable = $server->serverVariables()->where('env_variable', 'STEAM_ACC')->first();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->form([
Placeholder::make('info')
->label('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it completely.'),
TextInput::make('gsltoken')
->label('GSL Token')
->rules([
fn (): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $serverVariable->variable->rules,
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
$fail($message);
}
},
])
->hintIcon('tabler-code')
->label(fn () => $serverVariable->variable->name)
->hintIconTooltip(fn () => implode('|', $serverVariable->variable->rules))
->prefix(fn () => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn () => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description),
])
->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server, $serverVariable) {
/** @var Server $server */
$server = Filament::getTenant();
try {
$new = $data['gsltoken'] ?? '';
$original = $serverVariable->variable_value;
$serverVariable->update([
'variable_value' => $new,
]);
if ($original !== $new) {
Activity::event('server:startup.edit')
->property([
'variable' => $serverVariable->variable->env_variable,
'old' => $original,
'new' => $new,
])
->log();
}
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('GSL Token updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update GSL Token')
->body($exception->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Extensions\Features;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonPowerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
class JavaVersion extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'java.lang.UnsupportedClassVersionError',
'unsupported major.minor version',
'has been compiled by a more recent version of the java runtime',
'minecraft 1.17 requires running the server with java 16 or above',
'minecraft 1.18 requires running the server with java 17 or above',
'minecraft 1.19 requires running the server with java 17 or above',
];
}
public function getId(): string
{
return 'java_version';
}
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->form([
Placeholder::make('java')
->label('Please select a supported version from the list below to continue starting the server.'),
Select::make('image')
->label('Docker Image')
->disabled(fn () => !in_array($server->image, $server->egg->docker_images))
->options(fn () => collect($server->egg->docker_images)->mapWithKeys(fn ($key, $value) => [$key => $value]))
->selectablePlaceholder(false)
->default(fn () => $server->image)
->notIn(fn () => $server->image)
->required()
->preload()
->native(false),
])
->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server) {
try {
$new = $data['image'];
$original = $server->image;
$server->forceFill(['image' => $new])->saveOrFail();
if ($original !== $server->image) {
Activity::event('server:startup.image')
->property(['old' => $original, 'new' => $new])
->log();
}
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('Docker image updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update docker image')
->body($exception->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Extensions\Features;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonPowerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class MinecraftEula extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'you need to agree to the eula in order to run the server',
];
}
public function getId(): string
{
return 'eula';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Minecraft EULA')
->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the <x-filament::link href="https://minecraft.net/eula" target="_blank">Minecraft EULA </x-filament::link>.')))
->modalSubmitActionLabel('I Accept')
->action(function (DaemonFileRepository $fileRepository, DaemonPowerRepository $powerRepository) {
try {
/** @var Server $server */
$server = Filament::getTenant();
$fileRepository->setServer($server)->putContent('eula.txt', 'eula=true');
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('Minecraft EULA accepted')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not accept Minecraft EULA')
->body($exception->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class PIDLimit extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'pthread_create failed',
'failed to create thread',
'unable to create thread',
'unable to create native thread',
'unable to create new native thread',
'exception in thread "craft async scheduler management thread"',
];
}
public function getId(): string
{
return 'pid_limit';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->icon('tabler-alert-triangle')
->modalHeading(fn () => auth()->user()->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
<p>
This server has reached the maximum process or memory limit.
</p>
<p class="mt-4">
Increasing <code>container_pid_limit</code> in the wings
configuration, <code>config.yml</code>, might help resolve
this issue.
</p>
<p class="mt-4">
<b>Note: Wings must be restarted for the configuration file changes to take effect</b>
</p>
HTML
:
<<<'HTML'
<p>
This server is attempting to use more resources than allocated. Please contact the administrator
and give them the error below.
</p>
<p class="mt-4">
<code>
pthread_create failed, Possibly out of memory or process/resource limits reached
</code>
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class SteamDiskSpace extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'steamcmd needs 250mb of free disk space to update',
'0x202 after update job',
];
}
public function getId(): string
{
return 'steam_disk_space';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Out of available disk space...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process.
</p>
<p class="mt-4">
Ensure the machine has enough disk space by typing{' '}
<code class="rounded py-1 px-2">df -h</code> on the machine hosting
this server. Delete files or increase the available disk space to resolve the issue.
</p>
HTML
:
<<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process. Please get in touch with the administrator(s) and inform them of disk space issues.
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -6,8 +6,8 @@ use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use SocialiteProviders\Discord\Provider;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@@ -34,15 +34,15 @@ final class DiscordProvider extends OAuthProvider
Step::make('Register new Discord OAuth App')
->schema([
Placeholder::make('')
->content(new HtmlString('<p>Visit the <u><a href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</a></u> and click on <b>New Application</b>. Enter a <b>Name</b> (e.g. your panel name) and click on <b>Create</b>.</p><p>Copy the <b>Client ID</b> and the <b>Client Secret</b>, you will need them in the final step.</p>')),
->content(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</x-filament::link> and click on <b>New Application</b>. Enter a <b>Name</b> (e.g. your panel name) and click on <b>Create</b>.</p><p>Copy the <b>Client ID</b> and the <b>Client Secret</b> from the OAuth2 tab, you will need them in the final step.</p>'))),
Placeholder::make('')
->content(new HtmlString('<p>Under <b>Redirects</b> add the below URL.</p>')),
TextInput::make('_noenv_callback')
->label('Redirect URL')
->dehydrated()
->disabled()
->hintAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->formatStateUsing(fn () => config('app.url') . (Str::endsWith(config('app.url'), '/') ? '' : '/') . 'auth/oauth/callback/discord'),
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn () => url('/auth/oauth/callback/discord')),
]),
], parent::getSetupSteps());
}

View File

@@ -6,8 +6,8 @@ use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class GithubProvider extends OAuthProvider
@@ -28,13 +28,13 @@ final class GithubProvider extends OAuthProvider
Step::make('Register new Github OAuth App')
->schema([
Placeholder::make('')
->content(new HtmlString('<p>Visit the <u><a href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</a></u>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>')),
->content(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
TextInput::make('_noenv_callback')
->label('Authorization callback URL')
->dehydrated()
->disabled()
->hintAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->default(fn () => config('app.url') . (Str::endsWith(config('app.url'), '/') ? '' : '/') . 'auth/oauth/callback/github'),
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->default(fn () => url('/auth/oauth/callback/github')),
Placeholder::make('')
->content(new HtmlString('<p>When you filled all fields click on <b>Register application</b>.</p>')),
]),

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class GitlabProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'gitlab';
}
public function getServiceConfig(): array
{
return array_merge(parent::getServiceConfig(), [
'host' => env('OAUTH_GITLAB_HOST'),
]);
}
public function getSettingsForm(): array
{
return array_merge(parent::getSettingsForm(), [
TextInput::make('OAUTH_GITLAB_HOST')
->label('Custom Host')
->placeholder('Only set a custom host if you are self hosting gitlab')
->columnSpan(2)
->url()
->autocomplete(false)
->default(env('OAUTH_GITLAB_HOST')),
]);
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Gitlab OAuth App')
->schema([
Placeholder::make('')
->content(new HtmlString(Blade::render('Check out the <x-filament::link href="https://docs.gitlab.com/integration/oauth_provider/" target="_blank">Gitlab docs</x-filament::link> on how to create the oauth app.'))),
TextInput::make('_noenv_callback')
->label('Redirect URI')
->dehydrated()
->disabled()
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->default(fn () => url('/auth/oauth/callback/gitlab')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-gitlab';
}
public function getHexColor(): string
{
return '#fca326';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -6,6 +6,7 @@ use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use SocialiteProviders\Steam\Provider;
@@ -58,7 +59,7 @@ final class SteamProvider extends OAuthProvider
Step::make('Create API Key')
->schema([
Placeholder::make('')
->content(new HtmlString('Visit <u><a href="https://steamcommunity.com/dev/apikey" target="_blank">https://steamcommunity.com/dev/apikey</a></u> to generate an API key.')),
->content(new HtmlString(Blade::render('Visit <x-filament::link href="https://steamcommunity.com/dev/apikey" target="_blank">https://steamcommunity.com/dev/apikey</x-filament::link> to generate an API key.'))),
]),
], parent::getSetupSteps());
}

View File

@@ -2,36 +2,13 @@
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Resources\NodeResource\Pages\CreateNode;
use App\Filament\Admin\Resources\NodeResource\Pages\ListNodes;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Server;
use App\Models\User;
use App\Services\Helpers\SoftwareVersionService;
use Filament\Actions\CreateAction;
use Filament\Pages\Page;
use Filament\Pages\Dashboard as BaseDashboard;
class Dashboard extends Page
class Dashboard extends BaseDashboard
{
protected static ?string $navigationIcon = 'tabler-layout-dashboard';
protected static string $view = 'filament.pages.dashboard';
protected ?string $heading = '';
public function getTitle(): string
{
return trans('admin/dashboard.title');
}
public static function getNavigationLabel(): string
{
return trans('admin/dashboard.title');
}
protected static ?string $slug = '/';
private SoftwareVersionService $softwareVersionService;
public function mount(SoftwareVersionService $softwareVersionService): void
@@ -39,51 +16,18 @@ class Dashboard extends Page
$this->softwareVersionService = $softwareVersionService;
}
public function getViewData(): array
public function getColumns(): int
{
return [
'inDevelopment' => config('app.version') === 'canary',
'version' => $this->softwareVersionService->currentPanelVersion(),
'latestVersion' => $this->softwareVersionService->latestPanelVersion(),
'isLatest' => $this->softwareVersionService->isLatestPanel(),
'eggsCount' => Egg::query()->count(),
'nodesList' => ListNodes::getUrl(),
'nodesCount' => Node::query()->count(),
'serversCount' => Server::query()->count(),
'usersCount' => User::query()->count(),
return 1;
}
'devActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-developers.button_issues'))
->icon('tabler-brand-github')
->url('https://github.com/pelican-dev/panel/issues', true),
],
'updateActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-update-available.heading'))
->icon('tabler-clipboard-text')
->url('https://pelican.dev/docs/panel/update', true)
->color('warning'),
],
'nodeActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-first-node.button_label'))
->icon('tabler-server-2')
->url(CreateNode::getUrl()),
],
'supportActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-support.button_donate'))
->icon('tabler-cash')
->url('https://pelican.dev/donate', true)
->color('success'),
],
'helpActions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-help.button_docs'))
->icon('tabler-speedboat')
->url('https://pelican.dev/docs', true),
],
];
public function getHeading(): string
{
return trans('admin/dashboard.heading');
}
public function getSubheading(): string
{
return trans('admin/dashboard.version', ['version' => $this->softwareVersionService->currentPanelVersion()]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Admin\Pages;
use App\Extensions\Avatar\AvatarProvider;
use App\Extensions\Captcha\Providers\CaptchaProvider;
use App\Extensions\OAuth\Providers\OAuthProvider;
use App\Models\Backup;
@@ -12,6 +13,7 @@ use Filament\Actions\Action;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action as FormAction;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section;
@@ -32,6 +34,7 @@ use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\Str;
@@ -117,32 +120,67 @@ class Settings extends Page implements HasForms
->label(trans('admin/setting.general.app_name'))
->required()
->default(env('APP_NAME', 'Pelican')),
TextInput::make('APP_FAVICON')
->label(trans('admin/setting.general.app_favicon'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/setting.general.app_favicon_help'))
->required()
->default(env('APP_FAVICON', '/pelican.ico')),
Toggle::make('APP_DEBUG')
->label(trans('admin/setting.general.debug_mode'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state))
->default(env('APP_DEBUG', config('app.debug'))),
ToggleButtons::make('FILAMENT_TOP_NAVIGATION')
->label(trans('admin/setting.general.navigation'))
->inline()
->options([
false => trans('admin/setting.general.sidebar'),
true => trans('admin/setting.general.topbar'),
])
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
Group::make()
->columns(2)
->schema([
TextInput::make('APP_LOGO')
->label(trans('admin/setting.general.app_logo'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/setting.general.app_logo_help'))
->default(env('APP_LOGO'))
->placeholder('/pelican.svg'),
TextInput::make('APP_FAVICON')
->label(trans('admin/setting.general.app_favicon'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/setting.general.app_favicon_help'))
->required()
->default(env('APP_FAVICON', '/pelican.ico'))
->placeholder('/pelican.ico'),
]),
Group::make()
->columns(2)
->schema([
Toggle::make('APP_DEBUG')
->label(trans('admin/setting.general.debug_mode'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state))
->default(env('APP_DEBUG', config('app.debug'))),
ToggleButtons::make('FILAMENT_TOP_NAVIGATION')
->label(trans('admin/setting.general.navigation'))
->inline()
->options([
false => trans('admin/setting.general.sidebar'),
true => trans('admin/setting.general.topbar'),
])
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
]),
Group::make()
->columns(2)
->schema([
Select::make('FILAMENT_AVATAR_PROVIDER')
->label(trans('admin/setting.general.avatar_provider'))
->native(false)
->options(collect(AvatarProvider::getAll())->mapWithKeys(fn ($provider) => [$provider->getId() => $provider->getName()]))
->selectablePlaceholder(false)
->default(env('FILAMENT_AVATAR_PROVIDER', config('panel.filament.avatar-provider'))),
Toggle::make('FILAMENT_UPLOADABLE_AVATARS')
->label(trans('admin/setting.general.uploadable_avatars'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state) => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_UPLOADABLE_AVATARS', (bool) $state))
->default(env('FILAMENT_UPLOADABLE_AVATARS', config('panel.filament.uploadable-avatars'))),
]),
ToggleButtons::make('PANEL_USE_BINARY_PREFIX')
->label(trans('admin/setting.general.unit_prefix'))
->inline()
@@ -164,12 +202,18 @@ class Settings extends Page implements HasForms
->formatStateUsing(fn ($state): int => (int) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_2FA_REQUIRED', (int) $state))
->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))),
Select::make('FILAMENT_WIDTH')
->label(trans('admin/setting.general.display_width'))
->native(false)
->options(MaxWidth::class)
->selectablePlaceholder(false)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
TagsInput::make('TRUSTED_PROXIES')
->label(trans('admin/setting.general.trusted_proxies'))
->separator()
->splitKeys(['Tab', ' '])
->placeholder(trans('admin/setting.general.trusted_proxies_help'))
->default(env('TRUSTED_PROXIES', implode(',', config('trustedproxy.proxies'))))
->default(env('TRUSTED_PROXIES', implode(',', Arr::wrap(config('trustedproxy.proxies')))))
->hintActions([
FormAction::make('clear')
->label(trans('admin/setting.general.clear'))
@@ -204,12 +248,6 @@ class Settings extends Page implements HasForms
$set('TRUSTED_PROXIES', $ips->values()->all());
}),
]),
Select::make('FILAMENT_WIDTH')
->label(trans('admin/setting.general.display_width'))
->native(false)
->options(MaxWidth::class)
->selectablePlaceholder(false)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
];
}
@@ -297,7 +335,7 @@ class Settings extends Page implements HasForms
'mail.mailers.smtp.port' => config('mail.mailers.smtp.port'),
'mail.mailers.smtp.username' => config('mail.mailers.smtp.username'),
'mail.mailers.smtp.password' => config('mail.mailers.smtp.password'),
'mail.mailers.smtp.encryption' => config('mail.mailers.smtp.encryption'),
'mail.mailers.smtp.scheme' => config('mail.mailers.smtp.scheme'),
'mail.from.address' => config('mail.from.address'),
'mail.from.name' => config('mail.from.name'),
'services.mailgun.domain' => config('services.mailgun.domain'),
@@ -313,7 +351,7 @@ class Settings extends Page implements HasForms
'mail.mailers.smtp.port' => $get('MAIL_PORT'),
'mail.mailers.smtp.username' => $get('MAIL_USERNAME'),
'mail.mailers.smtp.password' => $get('MAIL_PASSWORD'),
'mail.mailers.smtp.encryption' => $get('MAIL_SCHEME'),
'mail.mailers.smtp.scheme' => $get('MAIL_SCHEME'),
'mail.from.address' => $get('MAIL_FROM_ADDRESS'),
'mail.from.name' => $get('MAIL_FROM_NAME'),
'services.mailgun.domain' => $get('MAILGUN_DOMAIN'),
@@ -377,22 +415,16 @@ class Settings extends Page implements HasForms
->revealable()
->default(env('MAIL_PASSWORD')),
ToggleButtons::make('MAIL_SCHEME')
->label(trans('admin/setting.mail.smtp.encryption'))
->label(trans('admin/setting.mail.smtp.scheme'))
->inline()
->options([
'tls' => trans('admin/setting.mail.smtp.tls'),
'ssl' => trans('admin/setting.mail.smtp.ssl'),
'' => trans('admin/setting.mail.smtp.none'),
'smtp' => 'SMTP',
'smtps' => 'SMTPS',
])
->default(env('MAIL_SCHEME', config('mail.mailers.smtp.encryption', 'tls')))
->default(env('MAIL_SCHEME', config('mail.mailers.smtp.scheme')))
->live()
->afterStateUpdated(function ($state, Set $set) {
$port = match ($state) {
'tls' => 587,
'ssl' => 465,
default => 25,
};
$set('MAIL_PORT', $port);
$set('MAIL_PORT', $state === 'smtps' ? 587 : 2525);
}),
]),
Section::make(trans('admin/setting.mail.mailgun.mailgun_title'))
@@ -697,10 +729,17 @@ class Settings extends Page implements HasForms
->onColor('success')
->offColor('danger')
->live()
->columnSpanFull()
->columnSpan(1)
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state))
->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))),
FileUpload::make('ConsoleFonts')
->hint(trans('admin/setting.misc.server.console_font_hint'))
->label(trans('admin/setting.misc.server.console_font_upload'))
->directory('fonts')
->columnSpan(1)
->maxFiles(1)
->preserveFilenames(),
]),
Section::make(trans('admin/setting.misc.webhook.title'))
->description(trans('admin/setting.misc.webhook.helper'))
@@ -729,6 +768,7 @@ class Settings extends Page implements HasForms
{
try {
$data = $this->form->getState();
unset($data['ConsoleFonts']);
// Convert bools to a string, so they are correctly written to the .env file
$data = array_map(fn ($value) => is_bool($value) ? ($value ? 'true' : 'false') : $value, $data);

View File

@@ -16,6 +16,7 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class DatabaseHostResource extends Resource
{
@@ -27,7 +28,7 @@ class DatabaseHostResource extends Resource
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getNavigationLabel(): string
@@ -144,7 +145,7 @@ class DatabaseHostResource extends Resource
->preload()
->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name'),
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
]),
]);
}
@@ -158,4 +159,13 @@ class DatabaseHostResource extends Resource
'edit' => Pages\EditDatabaseHost::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereHas('nodes', function (Builder $query) {
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
})->orDoesntHave('nodes');
}
}

View File

@@ -4,14 +4,30 @@ namespace App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Services\Databases\Hosts\HostCreationService;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Resources\Pages\CreateRecord\Concerns\HasWizard;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use PDOException;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class CreateDatabaseHost extends CreateRecord
{
use HasWizard;
protected static string $resource = DatabaseHostResource::class;
protected static bool $canCreateAnother = false;
@@ -23,18 +39,118 @@ class CreateDatabaseHost extends CreateRecord
$this->service = $service;
}
protected function getHeaderActions(): array
/** @return Step[] */
public function getSteps(): array
{
return [
$this->getCreateFormAction()->formId('form'),
Step::make(trans('admin/databasehost.setup.preparations'))
->columns()
->schema([
Placeholder::make('')
->content(trans('admin/databasehost.setup.note')),
Toggle::make('different_server')
->label(new HtmlString(trans('admin/databasehost.setup.different_server')))
->dehydrated(false)
->live()
->columnSpanFull()
->afterStateUpdated(fn ($state, Set $set) => $state ? $set('panel_ip', gethostbyname(str(config('app.url'))->replace(['http:', 'https:', '/'], ''))) : '127.0.0.1'),
Hidden::make('panel_ip')
->default('127.0.0.1')
->dehydrated(false),
TextInput::make('username')
->label(trans('admin/databasehost.username'))
->helperText(trans('admin/databasehost.username_help'))
->required()
->default('pelicanuser')
->maxLength(255),
TextInput::make('password')
->label(trans('admin/databasehost.password'))
->helperText(trans('admin/databasehost.password_help'))
->required()
->default(Str::password(16))
->password()
->revealable()
->maxLength(255),
])
->afterValidation(function (Get $get, Set $set) {
$set('create_user', "CREATE USER '{$get('username')}'@'{$get('panel_ip')}' IDENTIFIED BY '{$get('password')}';");
$set('assign_permissions', "GRANT ALL PRIVILEGES ON *.* TO '{$get('username')}'@'{$get('panel_ip')}' WITH GRANT OPTION;");
}),
Step::make(trans('admin/databasehost.setup.database_setup'))
->schema([
Fieldset::make(trans('admin/databasehost.setup.database_user'))
->schema([
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.cli_login')))
->columnSpanFull(),
TextInput::make('create_user')
->label(trans('admin/databasehost.setup.command_create_user'))
->default(fn (Get $get) => "CREATE USER '{$get('username')}'@'{$get('panel_ip')}' IDENTIFIED BY '{$get('password')}';")
->disabled()
->dehydrated(false)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull(),
TextInput::make('assign_permissions')
->label(trans('admin/databasehost.setup.command_assign_permissions'))
->default(fn (Get $get) => "GRANT ALL PRIVILEGES ON *.* TO '{$get('username')}'@'{$get('panel_ip')}' WITH GRANT OPTION;")
->disabled()
->dehydrated(false)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull(),
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.cli_exit')))
->columnSpanFull(),
]),
Fieldset::make(trans('admin/databasehost.setup.external_access'))
->schema([
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.allow_external_access')))
->columnSpanFull(),
]),
]),
Step::make(trans('admin/databasehost.setup.panel_setup'))
->columns([
'default' => 2,
'lg' => 3,
])
->schema([
TextInput::make('host')
->columnSpan(2)
->label(trans('admin/databasehost.host'))
->helperText(trans('admin/databasehost.host_help'))
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Set $set) => $set('name', $state))
->maxLength(255),
TextInput::make('port')
->label(trans('admin/databasehost.port'))
->helperText(trans('admin/databasehost.port_help'))
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
TextInput::make('max_databases')
->label(trans('admin/databasehost.max_database'))
->helpertext(trans('admin/databasehost.max_databases_help'))
->placeholder(trans('admin/databasehost.unlimited'))
->numeric(),
TextInput::make('name')
->label(trans('admin/databasehost.display_name'))
->helperText(trans('admin/databasehost.display_name_help'))
->required()
->maxLength(60),
Select::make('node_ids')
->multiple()
->searchable()
->preload()
->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
]),
];
}
protected function getFormActions(): array
{
return [];
}
protected function handleRecordCreation(array $data): Model
{
try {

View File

@@ -21,7 +21,7 @@ class EggResource extends Resource
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.server');
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
}
public static function getNavigationLabel(): string

View File

@@ -243,6 +243,7 @@ class CreateEgg extends CreateRecord
->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->native(false)
->selectablePlaceholder(false)
->default('bash')
->options(['bash', 'ash', '/bin/bash'])

View File

@@ -235,6 +235,7 @@ class EditEgg extends EditRecord
->placeholder('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->native(false)
->selectablePlaceholder(false)
->options(['bash', 'ash', '/bin/bash'])
->required(),

View File

@@ -7,6 +7,8 @@ use App\Filament\Components\Actions\ImportEggAction as ImportEggHeaderAction;
use App\Filament\Components\Tables\Actions\ExportEggAction;
use App\Filament\Components\Tables\Actions\ImportEggAction;
use App\Filament\Components\Tables\Actions\UpdateEggAction;
use App\Filament\Components\Tables\Actions\UpdateEggBulkAction;
use App\Filament\Components\Tables\Filters\TagsFilter;
use App\Models\Egg;
use Filament\Actions\CreateAction as CreateHeaderAction;
use Filament\Resources\Pages\ListRecords;
@@ -16,6 +18,7 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ReplicateAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
class ListEggs extends ListRecords
@@ -27,7 +30,6 @@ class ListEggs extends ListRecords
return $table
->searchable(true)
->defaultPaginationPageOption(25)
->checkIfRecordIsSelectableUsing(fn (Egg $egg) => $egg->servers_count <= 0)
->columns([
TextColumn::make('id')
->label('Id')
@@ -68,7 +70,16 @@ class ListEggs extends ListRecords
->successRedirectUrl(fn (Egg $replica) => EditEgg::getUrl(['record' => $replica])),
])
->groupedBulkActions([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->before(fn (DeleteBulkAction $action, Collection $records) => $action->records($records->filter(function ($egg) {
/** @var Egg $egg */
return $egg->servers_count <= 0;
}))),
UpdateEggBulkAction::make()
->before(fn (UpdateEggBulkAction $action, Collection $records) => $action->records($records->filter(function ($egg) {
/** @var Egg $egg */
return cache()->get("eggs.$egg->uuid.update", false);
}))),
])
->emptyStateIcon('tabler-eggs')
->emptyStateDescription('')
@@ -77,6 +88,10 @@ class ListEggs extends ListRecords
CreateAction::make(),
ImportEggAction::make()
->multiple(),
])
->filters([
TagsFilter::make()
->model(Egg::class),
]);
}

View File

@@ -18,6 +18,7 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class MountResource extends Resource
{
@@ -44,7 +45,7 @@ class MountResource extends Resource
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getNavigationGroup(): ?string
@@ -147,7 +148,7 @@ class MountResource extends Resource
->preload(),
Select::make('nodes')->multiple()
->label(trans('admin/mount.nodes'))
->relationship('nodes', 'name')
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id')))
->searchable(['name', 'fqdn'])
->preload(),
]),
@@ -170,4 +171,13 @@ class MountResource extends Resource
'edit' => Pages\EditMount::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereHas('nodes', function (Builder $query) {
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
})->orDoesntHave('nodes');
}
}

View File

@@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource\RelationManagers;
use App\Models\Node;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
class NodeResource extends Resource
{
@@ -32,12 +33,12 @@ class NodeResource extends Resource
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.server');
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getRelations(): array
@@ -56,4 +57,11 @@ class NodeResource extends Resource
'edit' => Pages\EditNode::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'));
}
}

View File

@@ -3,9 +3,11 @@
namespace App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource;
use App\Models\Node;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
@@ -44,7 +46,8 @@ class CreateNode extends CreateRecord
->required()
->autofocus()
->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
@@ -147,14 +150,15 @@ class CreateNode extends CreateRecord
->required()
->maxLength(100),
ToggleButtons::make('scheme')
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl'))
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->columnSpan(1)
->inline()
->helperText(function (Get $get) {
if (request()->isSecure()) {
@@ -167,20 +171,29 @@ class CreateNode extends CreateRecord
return '';
})
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
])
->colors([
'http' => 'warning',
'https' => 'success',
'https_proxy' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http'),
->default(fn () => request()->isSecure() ? 'https' : 'http')
->live()
->dehydrated(false)
->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy');
}),
]),
Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings'))

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource;
use App\Models\Node;
use App\Repositories\Daemon\DaemonConfigurationRepository;
use App\Services\Helpers\SoftwareVersionService;
use App\Services\Nodes\NodeAutoDeployService;
use App\Services\Nodes\NodeUpdateService;
@@ -13,6 +14,7 @@ use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
@@ -26,7 +28,7 @@ use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Alignment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@@ -34,12 +36,13 @@ class EditNode extends EditRecord
{
protected static string $resource = NodeResource::class;
private bool $errored = false;
private DaemonConfigurationRepository $daemonConfigurationRepository;
private NodeUpdateService $nodeUpdateService;
public function boot(NodeUpdateService $nodeUpdateService): void
public function boot(DaemonConfigurationRepository $daemonConfigurationRepository, NodeUpdateService $nodeUpdateService): void
{
$this->daemonConfigurationRepository = $daemonConfigurationRepository;
$this->nodeUpdateService = $nodeUpdateService;
}
@@ -108,7 +111,8 @@ class EditNode extends EditRecord
->required()
->autofocus()
->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
@@ -196,7 +200,9 @@ class EditNode extends EditRecord
])
->required()
->maxLength(100),
ToggleButtons::make('scheme')
Hidden::make('scheme'),
Hidden::make('behind_proxy'),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->inline()
@@ -211,20 +217,30 @@ class EditNode extends EditRecord
return '';
})
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
])
->colors([
'http' => 'warning',
'https' => 'success',
'https_proxy' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]),
->formatStateUsing(fn (Get $get) => $get('scheme') === 'http' ? 'http' : ($get('behind_proxy') ? 'https_proxy' : 'https'))
->live()
->dehydrated(false)
->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy');
}),
]),
Tab::make('adv')
->label(trans('admin/node.tabs.advanced_settings'))
->columns([
@@ -555,7 +571,18 @@ class EditNode extends EditRecord
->modalHeading(trans('admin/node.reset_token'))
->modalDescription(trans('admin/node.reset_help'))
->action(function (Node $node) {
$this->nodeUpdateService->handle($node, [], true);
try {
$this->nodeUpdateService->handle($node, [], true);
} catch (Exception) {
Notification::make()
->title(trans('admin/node.error_connecting', ['node' => $node->name]))
->body(trans('admin/node.error_connecting_description'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
Notification::make()->success()->title(trans('admin/node.token_reset'))->send();
$this->fillForm();
}),
@@ -585,39 +612,6 @@ class EditNode extends EditRecord
return $data;
}
protected function handleRecordUpdate(Model $record, array $data): Model
{
if (!$record instanceof Node) {
return $record;
}
try {
$record = $this->nodeUpdateService->handle($record, $data);
} catch (Exception $exception) {
$this->errored = true;
Notification::make()
->title(trans('admin/node.error_connecting', ['node' => $record->name]))
->body(trans('admin/node.error_connecting_description'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
return parent::handleRecordUpdate($record, $data);
}
protected function getSavedNotification(): ?Notification
{
if ($this->errored) {
return null;
}
return parent::getSavedNotification();
}
protected function getFormActions(): array
{
return [];
@@ -636,6 +630,31 @@ class EditNode extends EditRecord
protected function afterSave(): void
{
$this->fillForm();
/** @var Node $node */
$node = $this->record;
$changed = collect($node->getChanges())->except(['updated_at', 'name', 'tags', 'public', 'maintenance_mode', 'memory', 'memory_overallocate', 'disk', 'disk_overallocate', 'cpu', 'cpu_overallocate'])->all();
try {
if ($changed) {
$this->daemonConfigurationRepository->setNode($node)->update($node);
}
parent::getSavedNotification()?->send();
} catch (ConnectionException) {
Notification::make()
->title(trans('admin/node.error_connecting', ['node' => $node->name]))
->body(trans('admin/node.error_connecting_description'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
}
protected function getSavedNotification(): ?Notification
{
return null;
}
protected function getColumnSpan(): ?int

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\NodeResource\Pages;
use App\Filament\Admin\Resources\NodeResource;
use App\Filament\Components\Tables\Columns\NodeHealthColumn;
use App\Filament\Components\Tables\Filters\TagsFilter;
use App\Models\Node;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@@ -65,6 +66,10 @@ class ListNodes extends ListRecords
->emptyStateHeading(trans('admin/node.no_nodes'))
->emptyStateActions([
CreateAction::make(),
])
->filters([
TagsFilter::make()
->model(Node::class),
]);
}

View File

@@ -12,8 +12,7 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
@@ -32,18 +31,12 @@ class AllocationsRelationManager extends RelationManager
public function setTitle(): string
{
return trans('admin/server.allocations');
}
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
->recordTitleAttribute('address')
->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null)
->paginationPageOptions(['10', '20', '50', '100', '200', '500'])
->searchable()
@@ -72,14 +65,14 @@ class AllocationsRelationManager extends RelationManager
->label(trans('admin/node.table.ip')),
])
->headerActions([
Tables\Actions\Action::make('create new allocation')
Action::make('create new allocation')
->label(trans('admin/node.create_allocation'))
->form(fn () => [
Select::make('allocation_ip')
->options(collect($this->getOwnerRecord()->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/node.ip_address'))
->inlineLabel()
->ipv4()
->ip()
->helperText(trans('admin/node.ip_help'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->live()
@@ -96,19 +89,15 @@ class AllocationsRelationManager extends RelationManager
->inlineLabel()
->live()
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
CreateServer::retrieveValidPorts($this->getOwnerRecord(), $state, $get('allocation_ip')))
)
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', CreateServer::retrieveValidPorts($this->getOwnerRecord(), $state, $get('allocation_ip'))))
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord(), $data)),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('update node')),
]),
->groupedBulkActions([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('update node')),
]);
}
}

View File

@@ -23,7 +23,7 @@ class NodeCpuChart extends ChartWidget
$cpu = collect(cache()->get("nodes.{$this->node->id}.cpu_percent"))
->slice(-10)
->map(fn ($value, $key) => [
'cpu' => Number::format($value * $threads, maxPrecision: 2),
'cpu' => round($value * $threads, 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
])
->all();

View File

@@ -20,7 +20,7 @@ class NodeMemoryChart extends ChartWidget
{
$memUsed = collect(cache()->get("nodes.{$this->node->id}.memory_used"))->slice(-10)
->map(fn ($value, $key) => [
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2),
'memory' => round(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
])
->all();

View File

@@ -4,7 +4,6 @@ namespace App\Filament\Admin\Resources\NodeResource\Widgets;
use App\Models\Node;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
class NodeStorageChart extends ChartWidget
{
@@ -46,8 +45,8 @@ class NodeStorageChart extends ChartWidget
$unused = $total - $used;
$used = Number::format($used, maxPrecision: 2);
$unused = Number::format($unused, maxPrecision: 2);
$used = round($used, 2);
$unused = round($unused, 2);
return [
'datasets' => [

View File

@@ -2,8 +2,6 @@
namespace App\Filament\Admin\Resources;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use App\Filament\Admin\Resources\RoleResource\Pages;
use App\Models\Role;
use Filament\Forms\Components\Actions\Action;
@@ -12,6 +10,7 @@ use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
@@ -50,7 +49,7 @@ class RoleResource extends Resource
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.user');
return config('panel.filament.top-navigation', false) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
@@ -71,6 +70,11 @@ class RoleResource extends Resource
->badge()
->counts('permissions')
->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? trans('admin/role.all') : $state),
TextColumn::make('nodes.name')
->icon('tabler-server-2')
->label(trans('admin/role.nodes'))
->badge()
->placeholder(trans('admin/role.all')),
TextColumn::make('users_count')
->label(trans('admin/role.users'))
->counts('users')
@@ -95,32 +99,16 @@ class RoleResource extends Resource
public static function form(Form $form): Form
{
$permissions = [];
$permissionSections = [];
foreach (RolePermissionModels::cases() as $model) {
foreach (Role::getPermissionList() as $model => $permissions) {
$options = [];
foreach (RolePermissionPrefixes::cases() as $prefix) {
$options[$prefix->value . ' ' . strtolower($model->value)] = Str::headline($prefix->value);
foreach ($permissions as $permission) {
$options[$permission . ' ' . strtolower($model)] = Str::headline($permission);
}
if (array_key_exists($model->value, Role::MODEL_SPECIFIC_PERMISSIONS)) {
foreach (Role::MODEL_SPECIFIC_PERMISSIONS[$model->value] as $permission) {
$options[$permission . ' ' . strtolower($model->value)] = Str::headline($permission);
}
}
$permissions[] = self::makeSection($model->value, $options);
}
foreach (Role::SPECIAL_PERMISSIONS as $model => $prefixes) {
$options = [];
foreach ($prefixes as $prefix) {
$options[$prefix . ' ' . strtolower($model)] = Str::headline($prefix);
}
$permissions[] = self::makeSection($model, $options);
$permissionSections[] = self::makeSection($model, $options);
}
return $form
@@ -137,12 +125,20 @@ class RoleResource extends Resource
->hidden(),
Fieldset::make(trans('admin/role.permissions'))
->columns(3)
->schema($permissions)
->schema($permissionSections)
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Placeholder::make('permissions')
->label(trans('admin/role.permissions'))
->content(trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]))
->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Select::make('nodes')
->label(trans('admin/role.nodes'))
->multiple()
->relationship('nodes', 'name')
->searchable(['name', 'fqdn'])
->preload()
->hint(trans('admin/role.nodes_hint'))
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
]);
}

View File

@@ -3,8 +3,12 @@
namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\ServerResource\Pages;
use App\Models\Mount;
use App\Models\Server;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Builder;
class ServerResource extends Resource
{
@@ -31,12 +35,35 @@ class ServerResource extends Resource
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.server');
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getMountCheckboxList(Get $get): CheckboxList
{
$allowedMounts = Mount::all();
$node = $get('node_id');
$egg = $get('egg_id');
if ($node && $egg) {
$allowedMounts = $allowedMounts->filter(fn (Mount $mount) => ($mount->nodes->isEmpty() || $mount->nodes->contains($node)) &&
($mount->eggs->isEmpty() || $mount->eggs->contains($egg))
);
}
return CheckboxList::make('mounts')
->label('')
->relationship('mounts')
->live()
->options(fn () => $allowedMounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]))
->descriptions(fn () => $allowedMounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]))
->helperText(fn () => $allowedMounts->isEmpty() ? trans('admin/server.no_mounts') : null)
->bulkToggleable()
->columnSpanFull();
}
public static function getPages(): array
@@ -47,4 +74,13 @@ class ServerResource extends Resource
'edit' => Pages\EditServer::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->whereHas('node', function (Builder $query) {
$query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id'));
});
}
}

View File

@@ -15,7 +15,6 @@ use Closure;
use Exception;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
@@ -109,14 +108,20 @@ class CreateServer extends CreateRecord
->disabledOn('edit')
->prefixIcon('tabler-server-2')
->selectablePlaceholder(false)
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
->default(function () {
/** @var ?Node $latestNode */
$latestNode = auth()->user()->accessibleNodes()->latest()->first();
$this->node = $latestNode;
return $this->node?->id;
})
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->live()
->relationship('node', 'name')
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->searchable()
->preload()
->afterStateUpdated(function (Set $set, $state) {
@@ -183,10 +188,7 @@ class CreateServer extends CreateRecord
$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)" : '')
)
->getOptionLabelFromRecordUsing(fn (Allocation $allocation) => $allocation->address)
->placeholder(function (Get $get) {
$node = Node::find($get('node_id'));
@@ -212,7 +214,7 @@ class CreateServer extends CreateRecord
->label(trans('admin/server.ip_address'))->inlineLabel()
->helperText(trans('admin/server.ip_address_helper'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->ipv4()
->ip()
->live()
->required(),
TextInput::make('allocation_alias')
@@ -263,10 +265,7 @@ class CreateServer extends CreateRecord
->columnSpan(2)
->disabled(fn (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)" : '')
)
->getOptionLabelFromRecordUsing(fn (Allocation $allocation) => $allocation->address)
->placeholder(trans('admin/server.select_additional'))
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->relationship(
@@ -426,7 +425,7 @@ class CreateServer extends CreateRecord
Repeater::make('server_variables')
->label('')
->relationship('serverVariables')
->relationship('serverVariables', fn (Builder $query) => $query->orderByPowerJoins('variable.sort'))
->saveRelationshipsBeforeChildrenUsing(null)
->saveRelationshipsUsing(null)
->grid(2)
@@ -744,7 +743,7 @@ class CreateServer extends CreateRecord
'lg' => 4,
])
->columnSpan(6)
->schema([
->schema(fn (Get $get) => [
Select::make('select_image')
->label(trans('admin/server.image_name'))
->live()
@@ -792,19 +791,13 @@ class CreateServer extends CreateRecord
]),
KeyValue::make('docker_labels')
->live()
->label('Container Labels')
->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description'))
->columnSpanFull(),
CheckboxList::make('mounts')
->label('Mounts')
->live()
->relationship('mounts')
->options(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]) ?? [])
->descriptions(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]) ?? [])
->helperText(fn () => $this->node?->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
->columnSpanFull(),
ServerResource::getMountCheckboxList($get),
]),
]),
])

View File

@@ -2,16 +2,18 @@
namespace App\Filament\Admin\Resources\ServerResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Enums\SuspendAction;
use App\Filament\Admin\Resources\ServerResource;
use App\Filament\Admin\Resources\ServerResource\RelationManagers\AllocationsRelationManager;
use App\Filament\Components\Forms\Actions\PreviewStartupAction;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Server\Pages\Console;
use App\Models\Allocation;
use App\Models\Database;
use App\Models\DatabaseHost;
use App\Models\Egg;
use App\Models\Mount;
use App\Models\Node;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Models\User;
@@ -28,8 +30,9 @@ use Closure;
use Exception;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden;
@@ -48,10 +51,12 @@ use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Alignment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\HtmlString;
use LogicException;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@@ -59,8 +64,6 @@ class EditServer extends EditRecord
{
protected static string $resource = ServerResource::class;
private bool $errored = false;
private DaemonServerRepository $daemonServerRepository;
public function boot(DaemonServerRepository $daemonServerRepository): void
@@ -133,7 +136,39 @@ class EditServer extends EditRecord
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
])
->hintAction(
Action::make('view_install_log')
->label(trans('admin/server.view_install_log'))
//->visible(fn (Server $server) => $server->isFailedInstall())
->modalHeading('')
->modalSubmitAction(false)
->modalFooterActionsAlignment(Alignment::Right)
->modalCancelActionLabel(trans('filament::components/modal.actions.close.label'))
->form([
MonacoEditor::make('logs')
->hiddenLabel()
->placeholderText(trans('admin/server.no_log'))
->formatStateUsing(function (Server $server, DaemonServerRepository $serverRepository) {
try {
return $serverRepository->setServer($server)->getInstallLogs();
} catch (ConnectionException) {
Notification::make()
->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.notifications.log_failed'))
->color('warning')
->warning()
->send();
} catch (Exception) {
return '';
}
return '';
})
->language('shell')
->view('filament.plugins.monaco-editor-logs'),
])
),
Textarea::make('description')
->label(trans('admin/server.description'))
@@ -173,7 +208,7 @@ class EditServer extends EditRecord
->maxLength(255),
Select::make('node_id')
->label(trans('admin/server.node'))
->relationship('node', 'name')
->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', auth()->user()->accessibleNodes()->pluck('id')))
->columnSpan([
'default' => 2,
'sm' => 1,
@@ -482,6 +517,7 @@ class EditServer extends EditRecord
]),
KeyValue::make('docker_labels')
->live()
->label(trans('admin/server.container_labels'))
->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description'))
@@ -591,7 +627,7 @@ class EditServer extends EditRecord
]);
}
return $query;
return $query->orderByPowerJoins('variable.sort');
})
->grid()
->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
@@ -646,14 +682,8 @@ class EditServer extends EditRecord
]),
Tab::make(trans('admin/server.mounts'))
->icon('tabler-layers-linked')
->schema([
CheckboxList::make('mounts')
->label('')
->relationship('mounts')
->options(fn (Server $server) => $server->node->mounts->filter(fn (Mount $mount) => $mount->eggs->contains($server->egg))->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name]))
->descriptions(fn (Server $server) => $server->node->mounts->mapWithKeys(fn (Mount $mount) => [$mount->id => "$mount->source -> $mount->target"]))
->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : trans('admin/server.no_mounts'))
->columnSpanFull(),
->schema(fn (Get $get) => [
ServerResource::getMountCheckboxList($get),
]),
Tab::make(trans('admin/server.databases'))
->hidden(fn () => !auth()->user()->can('viewList database'))
@@ -731,7 +761,7 @@ class EditServer extends EditRecord
->deletable(false)
->addable(false)
->columnSpan(4),
Forms\Components\Actions::make([
FormActions::make([
Action::make('createDatabase')
->authorize(fn () => auth()->user()->can('create database'))
->disabled(fn () => DatabaseHost::query()->count() < 1)
@@ -799,14 +829,50 @@ class EditServer extends EditRecord
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
FormActions::make([
Action::make('toggleInstall')
->label(trans('admin/server.toggle_install'))
->disabled(fn (Server $server) => $server->isSuspended())
->action(function (ToggleInstallService $service, Server $server) {
$service->handle($server);
->modal(fn (Server $server) => $server->isFailedInstall())
->modalHeading(trans('admin/server.toggle_install_failed_header'))
->modalDescription(trans('admin/server.toggle_install_failed_desc'))
->modalSubmitActionLabel(trans('admin/server.reinstall'))
->action(function (ToggleInstallService $toggleService, ReinstallServerService $reinstallService, Server $server) {
if ($server->isFailedInstall()) {
try {
$reinstallService->handle($server);
$this->refreshFormData(['status', 'docker']);
Notification::make()
->title(trans('admin/server.notifications.reinstall_started'))
->success()
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->title(trans('admin/server.notifications.reinstall_failed'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
} else {
try {
$toggleService->handle($server);
Notification::make()
->title(trans('admin/server.notifications.install_toggled'))
->success()
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception $exception) {
Notification::make()
->title(trans('admin/server.notifications.install_toggle_failed'))
->body($exception->getMessage())
->danger()
->send();
}
}
}),
])->fullWidth(),
ToggleButtons::make('')
@@ -815,7 +881,7 @@ class EditServer extends EditRecord
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
FormActions::make([
Action::make('toggleSuspend')
->label(trans('admin/server.suspend'))
->color('warning')
@@ -823,12 +889,20 @@ class EditServer extends EditRecord
->action(function (SuspensionService $suspensionService, Server $server) {
try {
$suspensionService->handle($server, SuspendAction::Suspend);
} catch (\Exception $exception) {
Notification::make()->warning()->title(trans('admin/server.notifications.server_suspension'))->body($exception->getMessage())->send();
}
Notification::make()->success()->title(trans('admin/server.notifications.server_suspended'))->send();
$this->refreshFormData(['status', 'docker']);
Notification::make()
->success()
->title(trans('admin/server.notifications.server_suspended'))
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->send();
}
}),
Action::make('toggleUnsuspend')
->label(trans('admin/server.unsuspend'))
@@ -837,12 +911,20 @@ class EditServer extends EditRecord
->action(function (SuspensionService $suspensionService, Server $server) {
try {
$suspensionService->handle($server, SuspendAction::Unsuspend);
} catch (\Exception $exception) {
Notification::make()->warning()->title(trans('admin/server.notifications.server_suspension'))->body($exception->getMessage())->send();
}
Notification::make()->success()->title(trans('admin/server.notifications.server_unsuspended'))->send();
$this->refreshFormData(['status', 'docker']);
Notification::make()
->success()
->title(trans('admin/server.notifications.server_unsuspended'))
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->warning()
->title(trans('admin/server.notifications.server_suspension'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->send();
}
}),
])->fullWidth(),
ToggleButtons::make('')
@@ -855,42 +937,36 @@ class EditServer extends EditRecord
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
FormActions::make([
Action::make('transfer')
->label(trans('admin/server.transfer'))
// ->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, []))
->disabled() //TODO!
->form([ //TODO!
Select::make('newNode')
->label('New Node')
->required()
->options([
true => 'on',
false => 'off',
]),
Select::make('newMainAllocation')
->label('New Main Allocation')
->required()
->options([
true => 'on',
false => 'off',
]),
Select::make('newAdditionalAllocation')
->label('New Additional Allocations')
->options([
true => 'on',
false => 'off',
]),
])
->modalheading(trans('admin/server.transfer')),
->disabled(fn (Server $server) => Node::count() <= 1 || $server->isInConflictState())
->modalheading(trans('admin/server.transfer'))
->form($this->transferServer())
->action(function (TransferServerService $transfer, Server $server, $data) {
try {
$transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []));
Notification::make()
->title('Transfer started')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Transfer failed')
->body($exception->getMessage())
->danger()
->send();
}
}),
])->fullWidth(),
ToggleButtons::make('')
->hint(trans('admin/server.transfer_help')),
->hint(new HtmlString(trans('admin/server.transfer_help'))),
]),
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
FormActions::make([
Action::make('reinstall')
->label(trans('admin/server.reinstall'))
->color('danger')
@@ -898,7 +974,24 @@ class EditServer extends EditRecord
->modalHeading(trans('admin/server.reinstall_modal_heading'))
->modalDescription(trans('admin/server.reinstall_modal_description'))
->disabled(fn (Server $server) => $server->isSuspended())
->action(fn (ReinstallServerService $service, Server $server) => $service->handle($server)),
->action(function (ReinstallServerService $service, Server $server) {
try {
$service->handle($server);
Notification::make()
->title(trans('admin/server.notifications.reinstall_started'))
->success()
->send();
$this->refreshFormData(['status', 'docker']);
} catch (Exception) {
Notification::make()
->title(trans('admin/server.notifications.reinstall_failed'))
->body(trans('admin/server.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
}),
])->fullWidth(),
ToggleButtons::make('')
->hint(trans('admin/server.reinstall_help')),
@@ -909,32 +1002,86 @@ class EditServer extends EditRecord
]);
}
protected function transferServer(Form $form): Form
/** @return Component[] */
protected function transferServer(): array
{
return $form
->columns()
->schema([
Select::make('toNode')
->label('New Node'),
TextInput::make('newAllocation')
->label('Allocation'),
]);
return [
Select::make('node_id')
->label(trans('admin/server.node'))
->prefixIcon('tabler-server-2')
->selectablePlaceholder(false)
->default(fn (Server $server) => Node::whereNot('id', $server->node->id)->first()?->id)
->required()
->live()
->options(fn (Server $server) => Node::whereNot('id', $server->node->id)->pluck('name', 'id')->all()),
Select::make('allocation_id')
->label(trans('admin/server.primary_allocation'))
->required()
->prefixIcon('tabler-network')
->disabled(fn (Get $get) => !$get('node_id'))
->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address]))
->searchable(['ip', 'port', 'ip_alias'])
->placeholder(trans('admin/server.select_allocation')),
Select::make('allocation_additional')
->label(trans('admin/server.additional_allocations'))
->multiple()
->prefixIcon('tabler-network')
->disabled(fn (Get $get) => !$get('node_id'))
->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->when($get('allocation_id'), fn ($query) => $query->whereNot('id', $get('allocation_id')))->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address]))
->searchable(['ip', 'port', 'ip_alias'])
->placeholder(trans('admin/server.select_additional')),
];
}
protected function getHeaderActions(): array
{
/** @var Server $server */
$server = $this->getRecord();
$canForceDelete = cache()->get("servers.$server->uuid.canForceDelete", false);
return [
Actions\Action::make('Delete')
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger')
->label(trans('filament-actions::delete.single.modal.actions.delete.label'))
->label(trans('filament-actions::delete.single.label'))
->modalHeading(trans('filament-actions::delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
->requiresConfirmation()
->action(function (Server $server, ServerDeletionService $service) {
$service->handle($server);
try {
$service->handle($server);
return redirect(ListServers::getUrl(panel: 'admin'));
return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {
cache()->put("servers.$server->uuid.canForceDelete", true, now()->addMinutes(5));
Notification::make()
->title(trans('admin/server.notifications.error_server_delete'))
->body(trans('admin/server.notifications.error_server_delete_body'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
})
->hidden(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('ForceDelete')
->color('danger')
->label(trans('filament-actions::force-delete.single.label'))
->modalHeading(trans('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
->modalSubmitActionLabel(trans('filament-actions::force-delete.single.label'))
->requiresConfirmation()
->action(function (Server $server, ServerDeletionService $service) {
try {
$service->withForce()->handle($server);
return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {
cache()->forget("servers.$server->uuid.canForceDelete");
}
})
->visible(fn () => $canForceDelete)
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console')
->label(trans('admin/server.console'))
@@ -956,44 +1103,37 @@ class EditServer extends EditRecord
$data['description'] = '';
}
unset($data['docker'], $data['status']);
unset($data['docker'], $data['status'], $data['allocation_id']);
return $data;
}
protected function handleRecordUpdate(Model $record, array $data): Model
protected function afterSave(): void
{
if (!$record instanceof Server) {
return $record;
}
/** @var Server $server */
$server = $this->record;
/** @var Server $record */
$record = parent::handleRecordUpdate($record, $data);
$changed = collect($server->getChanges())->except(['updated_at', 'name', 'owner_id', 'condition', 'description', 'external_id', 'tags', 'cpu_pinning', 'allocation_limit', 'database_limit', 'backup_limit', 'skip_scripts'])->all();
try {
$this->daemonServerRepository->setServer($record)->sync();
if ($changed) {
$this->daemonServerRepository->setServer($server)->sync();
}
parent::getSavedNotification()?->send();
} catch (ConnectionException) {
$this->errored = true;
Notification::make()
->title(trans('admin/server.notifications.error_connecting', ['node' => $record->node->name]))
->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
->body(trans('admin/server.notifications.error_connecting_description'))
->color('warning')
->icon('tabler-database')
->warning()
->send();
}
return $record;
}
protected function getSavedNotification(): ?Notification
{
if ($this->errored) {
return null;
}
return parent::getSavedNotification();
return null;
}
public function getRelationManagers(): array

View File

@@ -12,14 +12,17 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Support\Exceptions\Halt;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AssociateAction;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DissociateAction;
use Filament\Tables\Actions\DissociateBulkAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
/**
* @method Server getOwnerRecord()
@@ -32,15 +35,18 @@ class AllocationsRelationManager extends RelationManager
{
return $table
->selectCurrentPageOnly()
->recordTitleAttribute('ip')
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port")
->recordTitleAttribute('address')
->recordTitle(fn (Allocation $allocation) => $allocation->address)
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
->inverseRelationship('server')
->heading(trans('admin/server.allocations'))
->columns([
TextColumn::make('ip')->label(trans('admin/server.ip_address')),
TextColumn::make('port')->label(trans('admin/server.port')),
TextInputColumn::make('ip_alias')->label(trans('admin/server.alias')),
TextColumn::make('ip')
->label(trans('admin/server.ip_address')),
TextColumn::make('port')
->label(trans('admin/server.port')),
TextInputColumn::make('ip_alias')
->label(trans('admin/server.alias')),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled',
@@ -56,8 +62,11 @@ class AllocationsRelationManager extends RelationManager
])
->actions([
Action::make('make-primary')
->label(trans('admin/server.make_primary'))
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : trans('admin/server.make_primary')),
->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
DissociateAction::make()
->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id),
])
->headerActions([
CreateAction::make()->label(trans('admin/server.create_allocation'))
@@ -67,7 +76,8 @@ class AllocationsRelationManager extends RelationManager
->options(collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address'))
->inlineLabel()
->ipv4()
->ip()
->live()
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(),
TextInput::make('allocation_alias')
@@ -81,9 +91,8 @@ class AllocationsRelationManager extends RelationManager
->label(trans('admin/server.ports'))
->inlineLabel()
->live()
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip')))
)
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports', CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip'))))
->splitKeys(['Tab', ' ', ','])
->required(),
])
@@ -96,10 +105,21 @@ class AllocationsRelationManager extends RelationManager
->recordSelectSearchColumns(['ip', 'port'])
->label(trans('admin/server.add_allocation')),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DissociateBulkAction::make(),
]),
->groupedBulkActions([
DissociateBulkAction::make()
->before(function (DissociateBulkAction $action, Collection $records) {
$records = $records->filter(function ($allocation) {
/** @var Allocation $allocation */
return $allocation->id !== $this->getOwnerRecord()->allocation_id;
});
if ($records->isEmpty()) {
$action->failureNotificationTitle(trans('admin/server.notifications.dissociate_primary'))->failure();
throw new Halt();
}
return $records;
}),
]);
}
}

View File

@@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\UserResource\Pages;
use App\Filament\Admin\Resources\UserResource\RelationManagers;
use App\Models\Role;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
@@ -17,6 +18,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class UserResource extends Resource
{
@@ -43,7 +45,7 @@ class UserResource extends Resource
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.user');
return config('panel.filament.top-navigation', false) ? null : trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
@@ -58,8 +60,9 @@ class UserResource extends Resource
ImageColumn::make('picture')
->visibleFrom('lg')
->label('')
->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
->circular()
->alignCenter()
->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)),
TextColumn::make('username')
->label(trans('admin/user.username')),
TextColumn::make('email')
@@ -120,12 +123,26 @@ class UserResource extends Resource
->hintIconTooltip(fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null)
->password(),
CheckboxList::make('roles')
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->hidden(fn (User $user) => $user->isRootAdmin())
->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id))
->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role))))
->dehydrated()
->label(trans('admin/user.admin_roles'))
->columnSpanFull()
->bulkToggleable(false),
CheckboxList::make('root_admin_role')
->visible(fn (User $user) => $user->isRootAdmin())
->disabled()
->options([
'root_admin' => Role::ROOT_ADMIN,
])
->descriptions([
'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]),
])
->formatStateUsing(fn () => ['root_admin'])
->dehydrated(false)
->label(trans('admin/user.admin_roles'))
->columnSpanFull(),
]);
}

View File

@@ -33,6 +33,13 @@ class CreateUser extends CreateRecord
return [];
}
protected function prepareForValidation($attributes): array
{
$attributes['data']['email'] = mb_strtolower($attributes['data']['email']);
return $attributes;
}
protected function handleRecordCreation(array $data): Model
{
$data['root_admin'] = false;

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Admin\Widgets;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class CanaryWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.canary-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 1;
public static function canView(): bool
{
return config('app.version') === 'canary';
}
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-developers.button_issues'))
->icon('tabler-brand-github')
->url('https://github.com/pelican-dev/panel/issues', true),
],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Filament\Admin\Widgets;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class HelpWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.help-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 4;
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-help.button_docs'))
->icon('tabler-speedboat')
->url('https://pelican.dev/docs', true),
],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Admin\Widgets;
use App\Filament\Admin\Resources\NodeResource\Pages\CreateNode;
use App\Models\Node;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class NoNodesWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.no-nodes-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 2;
public static function canView(): bool
{
return Node::count() <= 0;
}
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-first-node.button_label'))
->icon('tabler-server-2')
->url(CreateNode::getUrl()),
],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Filament\Admin\Widgets;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class SupportWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.support-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 3;
public function getViewData(): array
{
return [
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-support.button_donate'))
->icon('tabler-cash')
->url('https://pelican.dev/donate', true)
->color('success'),
],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Admin\Widgets;
use App\Services\Helpers\SoftwareVersionService;
use Filament\Actions\CreateAction;
use Filament\Widgets\Widget;
class UpdateWidget extends Widget
{
protected static string $view = 'filament.admin.widgets.update-widget';
protected static bool $isLazy = false;
protected static ?int $sort = 0;
private SoftwareVersionService $softwareVersionService;
public function mount(SoftwareVersionService $softwareVersionService): void
{
$this->softwareVersionService = $softwareVersionService;
}
public function getViewData(): array
{
return [
'version' => $this->softwareVersionService->currentPanelVersion(),
'latestVersion' => $this->softwareVersionService->latestPanelVersion(),
'isLatest' => $this->softwareVersionService->isLatestPanel(),
'actions' => [
CreateAction::make()
->label(trans('admin/dashboard.sections.intro-update-available.heading'))
->icon('tabler-clipboard-text')
->url('https://pelican.dev/docs/panel/update', true)
->color('warning'),
],
];
}
}

View File

@@ -2,52 +2,179 @@
namespace App\Filament\App\Resources\ServerResource\Pages;
use App\Enums\ServerResourceType;
use App\Filament\App\Resources\ServerResource;
use App\Filament\Components\Tables\Columns\ServerEntryColumn;
use App\Filament\Server\Pages\Console;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonPowerRepository;
use AymanAlhattami\FilamentContextMenu\Columns\ContextMenuTextColumn;
use Filament\Notifications\Notification;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\ColumnGroup;
use Filament\Tables\Columns\Layout\Stack;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\ConnectionException;
use Livewire\Attributes\On;
class ListServers extends ListRecords
{
protected static string $resource = ServerResource::class;
public const DANGER_THRESHOLD = 0.9;
public const WARNING_THRESHOLD = 0.7;
private DaemonPowerRepository $daemonPowerRepository;
public function boot(): void
{
$this->daemonPowerRepository = new DaemonPowerRepository();
}
public function table(Table $table): Table
{
$baseQuery = auth()->user()->accessibleServers();
$menuOptions = function (Server $server) {
$status = $server->retrieveStatus();
return [
Action::make('start')
->color('primary')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->visible(fn () => $status->isStartable())
->dispatch('powerAction', ['server' => $server, 'action' => 'start'])
->icon('tabler-player-play-filled'),
Action::make('restart')
->color('gray')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->visible(fn () => $status->isRestartable())
->dispatch('powerAction', ['server' => $server, 'action' => 'restart'])
->icon('tabler-refresh'),
Action::make('stop')
->color('danger')
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => $status->isStoppable())
->dispatch('powerAction', ['server' => $server, 'action' => 'stop'])
->icon('tabler-player-stop-filled'),
Action::make('kill')
->color('danger')
->tooltip('This can result in data corruption and/or data loss!')
->dispatch('powerAction', ['server' => $server, 'action' => 'kill'])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->visible(fn () => $status->isKillable())
->icon('tabler-alert-square'),
];
};
$viewOne = [
ContextMenuTextColumn::make('condition')
->label('')
->default('unknown')
->wrap()
->badge()
->alignCenter()
->tooltip(fn (Server $server) => $server->formatResource('uptime', type: ServerResourceType::Time))
->icon(fn (Server $server) => $server->condition->getIcon())
->color(fn (Server $server) => $server->condition->getColor())
->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
];
$viewTwo = [
ContextMenuTextColumn::make('name')
->label('')
->size('md')
->searchable()
->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
ContextMenuTextColumn::make('allocation.address')
->label('')
->badge()
->copyable(request()->isSecure())
->contextMenuActions($menuOptions)
->enableContextMenu(fn (Server $server) => !$server->isInConflictState()),
];
$viewThree = [
TextColumn::make('cpuUsage')
->label('')
->icon('tabler-cpu')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('cpu', limit: true, type: ServerResourceType::Percentage, precision: 0))
->state(fn (Server $server) => $server->formatResource('cpu_absolute', type: ServerResourceType::Percentage))
->color(fn (Server $server) => $this->getResourceColor($server, 'cpu')),
TextColumn::make('memoryUsage')
->label('')
->icon('tabler-memory')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('memory', limit: true))
->state(fn (Server $server) => $server->formatResource('memory_bytes'))
->color(fn (Server $server) => $this->getResourceColor($server, 'memory')),
TextColumn::make('diskUsage')
->label('')
->icon('tabler-device-floppy')
->tooltip(fn (Server $server) => 'Usage Limit: ' . $server->formatResource('disk', limit: true))
->state(fn (Server $server) => $server->formatResource('disk_bytes'))
->color(fn (Server $server) => $this->getResourceColor($server, 'disk')),
];
return $table
->paginated(false)
->query(fn () => $baseQuery)
->poll('15s')
->columns([
Stack::make([
ServerEntryColumn::make('server_entry')
->searchable(['name']),
]),
])
->columns(
(auth()->user()->getCustomization()['dashboard_layout'] ?? 'grid') === 'grid'
? [
Stack::make([
ServerEntryColumn::make('server_entry')
->searchable(['name']),
]),
]
: [
ColumnGroup::make('Status')
->label('Status')
->columns($viewOne),
ColumnGroup::make('Server')
->label('Servers')
->columns($viewTwo),
ColumnGroup::make('Resources')
->label('Resources')
->columns($viewThree),
]
)
->recordUrl(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->contentGrid([
'default' => 1,
'md' => 2,
])
->recordUrl(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->emptyStateIcon('tabler-brand-docker')
->emptyStateDescription('')
->emptyStateHeading('You don\'t have access to any servers!')
->emptyStateHeading(fn () => $this->activeTab === 'my' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!')
->persistFiltersInSession()
->filters([
SelectFilter::make('egg')
->relationship('egg', 'name', fn (Builder $query) => $query->whereIn('id', $baseQuery->pluck('egg_id')))
->searchable()
->preload(),
SelectFilter::make('owner')
->relationship('user', 'username', fn (Builder $query) => $query->whereIn('id', $baseQuery->pluck('owner_id')))
->searchable()
->hidden(fn () => $this->activeTab === 'my')
->preload(),
]);
}
public function updatedActiveTab(): void
{
$this->resetTable();
}
public function getTabs(): array
{
$all = auth()->user()->accessibleServers();
@@ -67,4 +194,71 @@ class ListServers extends ListRecords
->badge($all->count()),
];
}
public function getResourceColor(Server $server, string $resource): ?string
{
$current = null;
$limit = null;
switch ($resource) {
case 'cpu':
$current = $server->resources()['cpu_absolute'] ?? 0;
$limit = $server->cpu;
if ($server->cpu === 0) {
return null;
}
break;
case 'memory':
$current = $server->resources()['memory_bytes'] ?? 0;
$limit = $server->memory * 2 ** 20;
if ($server->memory === 0) {
return null;
}
break;
case 'disk':
$current = $server->resources()['disk_bytes'] ?? 0;
$limit = $server->disk * 2 ** 20;
if ($server->disk === 0) {
return null;
}
break;
default:
return null;
}
if ($current >= $limit * self::DANGER_THRESHOLD) {
return 'danger';
}
if ($current >= $limit * self::WARNING_THRESHOLD) {
return 'warning';
}
return null;
}
#[On('powerAction')]
public function powerAction(Server $server, string $action): void
{
try {
$this->daemonPowerRepository->setServer($server)->send($action);
Notification::make()
->title('Power Action')
->body($action . ' sent to ' . $server->name)
->success()
->send();
$this->redirect(self::getUrl(['activeTab' => $this->activeTab]));
} catch (ConnectionException) {
Notification::make()
->title(trans('exceptions.node.error_connecting', ['node' => $server->node->name]))
->danger()
->send();
}
}
}

View File

@@ -13,6 +13,7 @@ use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Illuminate\Support\Arr;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ImportEggAction extends Action
@@ -31,7 +32,7 @@ class ImportEggAction extends Action
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->action(function (array $data, EggImporterService $eggImportService): void {
$eggs = array_merge($data['files'], collect($data['urls'])->flatten()->whereNotNull()->unique()->all());
$eggs = array_merge(collect($data['urls'])->flatten()->whereNotNull()->unique()->all(), Arr::wrap($data['files']));
if (empty($eggs)) {
return;
}

View File

@@ -17,6 +17,12 @@ class CopyFrom extends Select
$this->placeholder(trans('admin/egg.none'));
$this->preload();
$this->searchable();
$this->native(false);
$this->live();
}

View File

@@ -13,6 +13,7 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;
use Illuminate\Support\Arr;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ImportEggAction extends Action
@@ -31,7 +32,7 @@ class ImportEggAction extends Action
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->action(function (array $data, EggImporterService $eggImportService): void {
$eggs = array_merge($data['files'], collect($data['urls'])->flatten()->whereNotNull()->unique()->all());
$eggs = array_merge(collect($data['urls'])->flatten()->whereNotNull()->unique()->all(), Arr::wrap($data['files']));
if (empty($eggs)) {
return;
}

View File

@@ -20,7 +20,7 @@ class UpdateEggAction extends Action
{
parent::setUp();
$this->label(trans('admin/egg.update'));
$this->label(trans_choice('admin/egg.update', 1));
$this->icon('tabler-cloud-download');
@@ -28,9 +28,9 @@ class UpdateEggAction extends Action
$this->requiresConfirmation();
$this->modalHeading(trans('admin/egg.update_question'));
$this->modalHeading(trans_choice('admin/egg.update_question', 1));
$this->modalDescription(trans('admin/egg.update_description'));
$this->modalDescription(trans_choice('admin/egg.update_description', 1));
$this->modalIconColor('danger');
@@ -54,7 +54,7 @@ class UpdateEggAction extends Action
}
Notification::make()
->title(trans('admin/egg.updated'))
->title(trans_choice('admin/egg.updated', 1))
->body($egg->name)
->success()
->send();

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Filament\Components\Tables\Actions;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions\StaticAction;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\BulkAction;
use Illuminate\Database\Eloquent\Collection;
class UpdateEggBulkAction extends BulkAction
{
public static function getDefaultName(): ?string
{
return 'update';
}
protected function setUp(): void
{
parent::setUp();
$this->label(trans_choice('admin/egg.update', 2));
$this->icon('tabler-cloud-download');
$this->color('success');
$this->requiresConfirmation();
$this->modalHeading(trans_choice('admin/egg.update_question', 2));
$this->modalDescription(trans_choice('admin/egg.update_description', 2));
$this->modalIconColor('danger');
$this->modalSubmitAction(fn (StaticAction $action) => $action->color('danger'));
$this->action(function (Collection $records, EggImporterService $eggImporterService) {
if ($records->count() === 0) {
Notification::make()
->title(trans('admin/egg.no_updates'))
->warning()
->send();
return;
}
$success = 0;
$failed = 0;
/** @var Egg $egg */
foreach ($records as $egg) {
try {
$eggImporterService->fromUrl($egg->update_url, $egg);
$success++;
cache()->forget("eggs.$egg->uuid.update");
} catch (Exception $exception) {
$failed++;
report($exception);
}
}
Notification::make()
->title(trans_choice('admin/egg.updated', 2, ['count' => $success, 'total' => $records->count()]))
->body($failed > 0 ? trans('admin/egg.updated_failed', ['count' => $failed]) : null)
->status($failed > 0 ? 'warning' : 'success')
->persistent()
->send();
});
$this->authorize(fn () => auth()->user()->can('import egg'));
$this->deselectRecordsAfterCompletion();
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Filament\Components\Tables\Filters;
use Filament\Forms\Components\Field;
use Filament\Forms\Components\Select;
use Filament\Tables\Filters\BaseFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class TagsFilter extends BaseFilter
{
protected string $model;
public static function getDefaultName(): ?string
{
return 'tags';
}
protected function setUp(): void
{
parent::setUp();
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'], fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
$this->indicateUsing(fn (array $data) => $data['tag'] ? 'Tag: ' . $data['tag'] : null);
$this->resetState(['tag' => null]);
$this->visible(fn () => $this->getTags()->count() > 0);
}
private function getTags(): Collection
{
return $this->getModel()::query()->pluck('tags')->flatten()->unique();
}
public function getFormField(): Field
{
return Select::make('tag')
->preload()
->searchable()
->options(fn () => $this->getTags()->mapWithKeys(fn ($tag) => [$tag => $tag]));
}
public function model(string $model): static
{
$this->model = $model;
return $this;
}
public function getModel(): string
{
return $this->model;
}
}

View File

@@ -19,6 +19,7 @@ use chillerlan\QRCode\QROptions;
use DateTimeZone;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
@@ -29,6 +30,7 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Pages\Auth\EditProfile as BaseEditProfile;
@@ -38,6 +40,7 @@ use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Password;
use Laravel\Socialite\Facades\Socialite;
@@ -125,6 +128,21 @@ class EditProfile extends BaseEditProfile
->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? '' : trans('profile.language_help', ['state' => $state])))
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages())
->native(false),
FileUpload::make('avatar')
->visible(fn () => config('panel.filament.uploadable-avatars'))
->avatar()
->acceptedFileTypes(['image/png'])
->directory('avatars')
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png')
->hintAction(function (FileUpload $fileUpload) {
$path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png';
return Action::make('remove_avatar')
->icon('tabler-photo-minus')
->iconButton()
->hidden(fn () => !$fileUpload->getDisk()->exists($path))
->action(fn () => $fileUpload->getDisk()->delete($path));
}),
]),
Tab::make(trans('profile.tabs.oauth'))
@@ -242,6 +260,7 @@ class EditProfile extends BaseEditProfile
->password(),
];
}),
Tab::make(trans('profile.tabs.api_keys'))
->icon('tabler-key')
->schema([
@@ -261,7 +280,7 @@ class EditProfile extends BaseEditProfile
Action::make('Create')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => $get('description') === null)
->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab']))
->successRedirectUrl(self::getUrl(['tab' => '-api-keys-tab'], panel: 'app'))
->action(function (Get $get, Action $action, User $user) {
$token = $user->createToken(
$get('description'),
@@ -308,9 +327,11 @@ class EditProfile extends BaseEditProfile
]),
]),
]),
Tab::make(trans('profile.tabs.ssh_keys'))
->icon('tabler-lock-code')
->hidden(),
Tab::make(trans('profile.tabs.activity'))
->icon('tabler-history')
->schema([
@@ -325,6 +346,105 @@ class EditProfile extends BaseEditProfile
Placeholder::make('activity!')->label('')->content(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
Tab::make(trans('profile.tabs.customization'))
->icon('tabler-adjustments')
->schema([
Section::make(trans('profile.dashboard'))
->collapsible()
->icon('tabler-dashboard')
->schema([
ToggleButtons::make('dashboard_layout')
->label(trans('profile.dashboard_layout'))
->inline()
->required()
->options([
'grid' => trans('profile.grid'),
'table' => trans('profile.table'),
]),
]),
Section::make(trans('profile.console'))
->collapsible()
->icon('tabler-brand-tabler')
->columns(4)
->schema([
TextInput::make('console_font_size')
->label(trans('profile.font_size'))
->columnSpan(1)
->minValue(1)
->numeric()
->required()
->default(14),
Select::make('console_font')
->label(trans('profile.font'))
->required()
->options(function () {
$fonts = [
'monospace' => 'monospace', //default
];
if (!Storage::disk('public')->exists('storage/fonts')) {
Storage::disk('public')->makeDirectory('storage/fonts');
$this->fillForm();
}
foreach (Storage::disk('public')->allFiles('storage/fonts') as $file) {
$fileInfo = pathinfo($file);
if ($fileInfo['extension'] === 'ttf') {
$fonts[$fileInfo['filename']] = $fileInfo['filename'];
}
}
return $fonts;
})
->reactive()
->default('monospace')
->afterStateUpdated(fn ($state, callable $set) => $set('font_preview', $state)),
Placeholder::make('font_preview')
->label(trans('profile.font_preview'))
->columnSpan(2)
->content(function (Get $get) {
$fontName = $get('console_font') ?? 'monospace';
$fontSize = $get('console_font_size') . 'px';
$fontUrl = asset("storage/fonts/{$fontName}.ttf");
return new HtmlString(<<<HTML
<style>
@font-face {
font-family: "CustomPreviewFont";
src: url("$fontUrl");
}
.preview-text {
font-family: "CustomPreviewFont";
font-size: $fontSize;
margin-top: 10px;
display: block;
}
</style>
<span class="preview-text">The quick blue pelican jumps over the lazy pterodactyl. :)</span>
HTML);
}),
TextInput::make('console_graph_period')
->label(trans('profile.graph_period'))
->suffix(trans('profile.seconds'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('profile.graph_period_helper'))
->columnSpan(2)
->numeric()
->default(30)
->minValue(10)
->maxValue(120)
->required(),
TextInput::make('console_rows')
->label(trans('profile.rows'))
->minValue(1)
->numeric()
->required()
->columnSpan(2)
->default(30),
]),
]),
]),
])
->operation('edit')
@@ -345,7 +465,7 @@ class EditProfile extends BaseEditProfile
$tokens = $this->toggleTwoFactorService->handle($record, $token, true);
cache()->put("users.$record->id.2fa.tokens", implode("\n", $tokens), now()->addSeconds(15));
$this->redirectRoute('filament.admin.auth.profile', ['tab' => '-2fa-tab']);
$this->redirect(self::getUrl(['tab' => '-2fa-tab'], panel: 'app'));
}
if ($token = $data['2fa-disable-code'] ?? null) {
@@ -381,4 +501,33 @@ class EditProfile extends BaseEditProfile
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
$moarbetterdata = [
'console_font' => $data['console_font'],
'console_font_size' => $data['console_font_size'],
'console_rows' => $data['console_rows'],
'console_graph_period' => $data['console_graph_period'],
'dashboard_layout' => $data['dashboard_layout'],
];
unset($data['console_font'],$data['console_font_size'], $data['console_rows'], $data['dashboard_layout']);
$data['customization'] = json_encode($moarbetterdata);
return $data;
}
protected function mutateFormDataBeforeFill(array $data): array
{
$moarbetterdata = json_decode($data['customization'], true);
$data['console_font'] = $moarbetterdata['console_font'] ?? 'monospace';
$data['console_font_size'] = $moarbetterdata['console_font_size'] ?? 14;
$data['console_rows'] = $moarbetterdata['console_rows'] ?? 30;
$data['console_graph_period'] = $moarbetterdata['console_graph_period'] ?? 30;
$data['dashboard_layout'] = $moarbetterdata['dashboard_layout'] ?? 'grid';
return $data;
}
}

View File

@@ -2,18 +2,21 @@
namespace App\Filament\Server\Pages;
use App\Enums\ConsoleWidgetPosition;
use App\Enums\ContainerStatus;
use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Extensions\Features\FeatureProvider;
use App\Filament\Server\Widgets\ServerConsole;
use App\Filament\Server\Widgets\ServerCpuChart;
use App\Filament\Server\Widgets\ServerMemoryChart;
// use App\Filament\Server\Widgets\ServerNetworkChart;
use App\Filament\Server\Widgets\ServerNetworkChart;
use App\Filament\Server\Widgets\ServerOverview;
use App\Livewire\AlertBanner;
use App\Models\Permission;
use App\Models\Server;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Facades\Filament;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Support\Enums\ActionSize;
use Filament\Widgets\Widget;
@@ -22,6 +25,8 @@ use Livewire\Attributes\On;
class Console extends Page
{
use InteractsWithActions;
protected static ?string $navigationIcon = 'tabler-brand-tabler';
protected static ?int $navigationSort = 1;
@@ -38,14 +43,38 @@ class Console extends Page
try {
$server->validateCurrentState();
} catch (ServerStateConflictException $exception) {
AlertBanner::make()
->warning()
AlertBanner::make('server_conflict')
->title('Warning')
->body($exception->getMessage())
->warning()
->send();
}
}
public function boot(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var FeatureProvider $feature */
foreach ($server->egg->features() as $feature) {
$this->cacheAction($feature->getAction());
}
}
#[On('mount-feature')]
public function mountFeature(string $data): void
{
$data = json_decode($data);
$feature = data_get($data, 'key');
$feature = FeatureProvider::getProviders($feature);
if ($this->getMountedAction()) {
return;
}
$this->mountAction($feature->getId());
sleep(2); // TODO find a better way
}
public function getWidgetData(): array
{
return [
@@ -54,18 +83,41 @@ class Console extends Page
];
}
/** @var array<string, array<class-string<Widget>>> */
protected static array $customWidgets = [];
/** @param class-string<Widget>[] $customWidgets */
public static function registerCustomWidgets(ConsoleWidgetPosition $position, array $customWidgets): void
{
static::$customWidgets[$position->value] = array_unique(array_merge(static::$customWidgets[$position->value] ?? [], $customWidgets));
}
/**
* @return class-string<Widget>[]
*/
public function getWidgets(): array
{
return [
ServerOverview::class,
ServerConsole::class,
$allWidgets = [];
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::Top->value] ?? []);
$allWidgets[] = ServerOverview::class;
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::AboveConsole->value] ?? []);
$allWidgets[] = ServerConsole::class;
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::BelowConsole->value] ?? []);
$allWidgets = array_merge($allWidgets, [
ServerCpuChart::class,
ServerMemoryChart::class,
//ServerNetworkChart::class, TODO: convert units.
];
ServerNetworkChart::class,
]);
$allWidgets = array_merge($allWidgets, static::$customWidgets[ConsoleWidgetPosition::Bottom->value] ?? []);
return array_unique($allWidgets);
}
/**
@@ -102,32 +154,33 @@ class Console extends Page
Action::make('start')
->color('primary')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid))
->dispatch('setServerState', ['state' => 'start', 'uuid' => $server->uuid])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable()),
->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable())
->icon('tabler-player-play-filled'),
Action::make('restart')
->color('gray')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid))
->dispatch('setServerState', ['state' => 'restart', 'uuid' => $server->uuid])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server))
->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable()),
->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable())
->icon('tabler-reload'),
Action::make('stop')
->color('danger')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid))
->dispatch('setServerState', ['state' => 'stop', 'uuid' => $server->uuid])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable())
->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable()),
->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable())
->icon('tabler-player-stop-filled'),
Action::make('kill')
->color('danger')
->requiresConfirmation()
->modalHeading('Do you wish to kill this server?')
->modalDescription('This can result in data corruption and/or data loss!')
->modalSubmitActionLabel('Kill Server')
->tooltip('This can result in data corruption and/or data loss!')
->size(ActionSize::ExtraLarge)
->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid))
->dispatch('setServerState', ['state' => 'kill', 'uuid' => $server->uuid])
->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server))
->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable()),
->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable())
->icon('tabler-alert-square'),
];
}
}

View File

@@ -192,7 +192,7 @@ class Settings extends ServerFormPage
]),
Section::make('Reinstall Server')
->hidden(fn () => !auth()->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->collapsible()->collapsed()
->collapsible()
->footerActions([
Action::make('reinstall')
->color('danger')
@@ -226,6 +226,8 @@ class Settings extends ServerFormPage
->success()
->title('Server Reinstall started')
->send();
redirect(Console::getUrl());
}),
])
->footerActionsAlignment(Alignment::Right)
@@ -257,7 +259,6 @@ class Settings extends ServerFormPage
Notification::make()
->success()
->duration(5000) // 5 seconds
->title('Updated Server Name')
->body(fn () => $original . ' -> ' . $name)
->send();
@@ -289,7 +290,6 @@ class Settings extends ServerFormPage
Notification::make()
->success()
->duration(5000) // 5 seconds
->title('Updated Server Description')
->body(fn () => $original . ' -> ' . $description)
->send();

View File

@@ -18,6 +18,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
class Startup extends ServerFormPage
@@ -100,7 +101,7 @@ class Startup extends ServerFormPage
->schema([
Repeater::make('server_variables')
->label('')
->relationship('viewableServerVariables')
->relationship('serverVariables', fn (Builder $query) => $query->where('egg_variables.user_viewable', true)->orderByPowerJoins('variable.sort'))
->grid()
->disabled(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->reorderable(false)->addable(false)->deletable(false)

View File

@@ -30,8 +30,7 @@ class ActivityResource extends Resource
/** @var Server $server */
$server = Filament::getTenant();
return $server->activity()
->getQuery()
return ActivityLog::whereHas('subjects', fn (Builder $query) => $query->where('subject_id', $server->id))
->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS)
->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) {
// We could do this with a query and a lot of joins, but that gets pretty

View File

@@ -19,6 +19,7 @@ use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Arr;
use Illuminate\Support\HtmlString;
class ListActivities extends ListRecords
@@ -31,7 +32,7 @@ class ListActivities extends ListRecords
$server = Filament::getTenant();
return $table
->paginated([25, 50, 100, 250])
->paginated([25, 50])
->defaultPaginationPageOption(25)
->columns([
TextColumn::make('event')
@@ -98,7 +99,7 @@ class ListActivities extends ListRecords
DateTimePicker::make('timestamp'),
KeyValue::make('properties')
->label('Metadata')
->formatStateUsing(fn ($state) => collect($state)->filter(fn ($item) => !is_array($item))->all()),
->formatStateUsing(fn ($state) => Arr::dot($state)),
]),
])
->filters([

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Resources\BackupResource\Pages;
use App\Enums\BackupStatus;
use App\Enums\ServerState;
use App\Facades\Activity;
use App\Filament\Server\Resources\BackupResource;
@@ -31,6 +32,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ListBackups extends ListRecords
{
@@ -69,13 +71,14 @@ class ListBackups extends ListRecords
->label('Created')
->since()
->sortable(),
IconColumn::make('is_successful')
->label('Successful')
->boolean(),
TextColumn::make('status')
->label('Status')
->badge(),
IconColumn::make('is_locked')
->visibleFrom('md')
->label('Lock Status')
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock-open' : 'tabler-lock'),
->trueIcon('tabler-lock')
->falseIcon('tabler-lock-open'),
])
->actions([
ActionGroup::make([
@@ -83,12 +86,14 @@ class ListBackups extends ListRecords
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DELETE, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? 'Lock' : 'Unlock')
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup)),
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('download')
->color('primary')
->icon('tabler-download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true),
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('restore')
->color('success')
->icon('tabler-folder-up')
@@ -137,12 +142,14 @@ class ListBackups extends ListRecords
return Notification::make()
->title('Restoring Backup')
->send();
}),
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
DeleteAction::make('delete')
->disabled(fn (Backup $backup): bool => $backup->is_locked)
->disabled(fn (Backup $backup) => $backup->is_locked)
->modalDescription(fn (Backup $backup) => 'Do you wish to delete, ' . $backup->name . '?')
->modalSubmitActionLabel('Delete Backup')
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->delete($request, $server, $backup)),
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->delete($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status !== BackupStatus::InProgress),
]),
]);
}
@@ -166,18 +173,26 @@ class ListBackups extends ListRecords
$action->setIsLocked((bool) $data['is_locked']);
}
$backup = $action->handle($server, $data['name']);
try {
$backup = $action->handle($server, $data['name']);
Activity::event('server:backup.start')
->subject($backup)
->property(['name' => $backup->name, 'locked' => (bool) $data['is_locked']])
->log();
Activity::event('server:backup.start')
->subject($backup)
->property(['name' => $backup->name, 'locked' => (bool) $data['is_locked']])
->log();
return Notification::make()
->title('Backup Created')
->body($backup->name . ' created.')
->success()
->send();
return Notification::make()
->title('Backup Created')
->body($backup->name . ' created.')
->success()
->send();
} catch (HttpException $e) {
return Notification::make()
->danger()
->title('Backup Failed')
->body($e->getMessage() . ' Try again' . ($e->getHeaders()['Retry-After'] ? ' in ' . $e->getHeaders()['Retry-After'] . ' seconds.' : ''))
->send();
}
}),
];
}

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Server\Resources\DatabaseResource\Pages;
use App\Facades\Activity;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\DatabaseResource;
@@ -82,12 +81,7 @@ class ListDatabases extends ListRecords
ViewAction::make()
->modalHeading(fn (Database $database) => 'Viewing ' . $database->database),
DeleteAction::make()
->after(function (Database $database) {
Activity::event('server:database.delete')
->subject($database)
->property('name', $database->database)
->log();
}),
->using(fn (Database $database, DatabaseManagementService $service) => $service->delete($database)),
]);
}

View File

@@ -4,6 +4,8 @@ namespace App\Filament\Server\Resources\FileResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Enums\EditorLanguages;
use App\Exceptions\Http\Server\FileSizeTooLargeException;
use App\Exceptions\Repository\FileNotEditableException;
use App\Facades\Activity;
use App\Filament\Server\Resources\FileResource;
use App\Livewire\AlertBanner;
@@ -24,6 +26,8 @@ use Filament\Resources\Pages\Page;
use Filament\Resources\Pages\PageRegistration;
use Filament\Support\Enums\Alignment;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked;
@@ -45,6 +49,8 @@ class EditFiles extends Page
#[Locked]
public string $path;
private DaemonFileRepository $fileRepository;
/** @var array<mixed> */
public ?array $data = [];
@@ -66,12 +72,8 @@ class EditFiles extends Page
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+shift+s')
->action(function (DaemonFileRepository $fileRepository) use ($server) {
$data = $this->form->getState();
$fileRepository
->setServer($server)
->putContent($this->path, $data['editor'] ?? '');
->action(function () {
$this->getDaemonFileRepository()->putContent($this->path, $this->data['editor'] ?? '');
Activity::event('server:file.write')
->property('file', $this->path)
@@ -90,12 +92,8 @@ class EditFiles extends Page
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+s')
->action(function (DaemonFileRepository $fileRepository) use ($server) {
$data = $this->form->getState();
$fileRepository
->setServer($server)
->putContent($this->path, $data['editor'] ?? '');
->action(function () {
$this->getDaemonFileRepository()->putContent($this->path, $this->data['editor'] ?? '');
Activity::event('server:file.write')
->property('file', $this->path)
@@ -117,21 +115,48 @@ class EditFiles extends Page
->schema([
Select::make('lang')
->label('Syntax Highlighting')
->searchable()
->native(false)
->live()
->options(EditorLanguages::class)
->selectablePlaceholder(false)
->afterStateUpdated(fn ($state) => $this->dispatch('setLanguage', lang: $state))
->default(fn () => EditorLanguages::fromWithAlias(pathinfo($this->path, PATHINFO_EXTENSION))),
MonacoEditor::make('editor')
->label('')
->placeholderText('')
->default(function (DaemonFileRepository $fileRepository) use ($server) {
->hiddenLabel()
->showPlaceholder(false)
->default(function () {
try {
return $fileRepository
->setServer($server)
->getContent($this->path, config('panel.files.max_edit_size'));
return $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
} catch (FileSizeTooLargeException) {
AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> is too large!')
->body('Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (FileNotFoundException) {
abort(404, $this->path . ' not found.');
AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> not found!')
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (FileNotEditableException) {
AlertBanner::make()
->title('<code>' . basename($this->path) . '</code> is a directory')
->danger()
->closable()
->send();
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
} catch (ConnectionException) {
// Alert banner for this one will be handled by ListFiles
$this->redirect(ListFiles::getUrl(['path' => dirname($this->path)]));
}
})
->language(fn (Get $get) => $get('lang'))
@@ -149,12 +174,21 @@ class EditFiles extends Page
$this->form->fill();
if (str($path)->endsWith('.pelicanignore')) {
AlertBanner::make()
AlertBanner::make('.pelicanignore_info')
->title('You\'re editing a <code>.pelicanignore</code> file!')
->body('Any files or directories listed in here will be excluded from backups. Wildcards are supported by using an asterisk (<code>*</code>).<br>You can negate a prior rule by prepending an exclamation point (<code>!</code>).')
->info()
->closable()
->send();
try {
$this->getDaemonFileRepository()->getDirectory('/');
} catch (ConnectionException) {
AlertBanner::make('node_connection_error')
->title('Could not connect to the node!')
->danger()
->send();
}
}
}
@@ -200,6 +234,23 @@ class EditFiles extends Page
return $breadcrumbs;
}
private function getDaemonFileRepository(): DaemonFileRepository
{
/** @var Server $server */
$server = Filament::getTenant();
$this->fileRepository ??= (new DaemonFileRepository())->setServer($server);
return $this->fileRepository;
}
/**
* @param array<string, mixed> $parameters
*/
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string
{
return parent::getUrl($parameters, $isAbsolute, $panel, $tenant) . '/';
}
public static function route(string $path): PageRegistration
{
return new PageRegistration(

View File

@@ -29,7 +29,6 @@ use Filament\Resources\Pages\PageRegistration;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
@@ -38,6 +37,7 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked;
@@ -46,13 +46,9 @@ class ListFiles extends ListRecords
protected static string $resource = FileResource::class;
#[Locked]
public string $path;
public string $path = '/';
public function mount(?string $path = null): void
{
parent::mount();
$this->path = $path ?? '/';
}
private DaemonFileRepository $fileRepository;
public function getBreadcrumbs(): array
{
@@ -76,10 +72,12 @@ class ListFiles extends ListRecords
/** @var Server $server */
$server = Filament::getTenant();
$files = File::get($server, $this->path);
return $table
->paginated([25, 50, 100, 250])
->defaultPaginationPageOption(50)
->query(fn () => File::get($server, $this->path)->orderByDesc('is_directory'))
->paginated([25, 50])
->defaultPaginationPageOption(25)
->query(fn () => $files->orderByDesc('is_directory'))
->defaultSort('name')
->columns([
TextColumn::make('name')
@@ -88,6 +86,7 @@ class ListFiles extends ListRecords
->icon(fn (File $file) => $file->getIcon()),
BytesColumn::make('size')
->visibleFrom('md')
->state(fn (File $file) => $file->is_directory ? null : $file->size)
->sortable(),
DateTimeColumn::make('modified_at')
->visibleFrom('md')
@@ -128,12 +127,10 @@ class ListFiles extends ListRecords
->default(fn (File $file) => $file->name)
->required(),
])
->action(function ($data, File $file, DaemonFileRepository $fileRepository) use ($server) {
->action(function ($data, File $file) {
$files = [['to' => $data['name'], 'from' => $file->name]];
$fileRepository
->setServer($server)
->renameFiles($this->path, $files);
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
Activity::event('server:file.rename')
->property('directory', $this->path)
@@ -153,10 +150,8 @@ class ListFiles extends ListRecords
->label('Copy')
->icon('tabler-copy')
->visible(fn (File $file) => $file->is_file)
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->copyFile(join_paths($this->path, $file->name));
->action(function (File $file) {
$this->getDaemonFileRepository()->copyFile(join_paths($this->path, $file->name));
Activity::event('server:file.copy')
->property('file', join_paths($this->path, $file->name))
@@ -181,32 +176,32 @@ class ListFiles extends ListRecords
->icon('tabler-replace')
->form([
TextInput::make('location')
->label('File name')
->hint('Enter the new name and directory of this file or folder, relative to the current directory.')
->default(fn (File $file) => $file->name)
->label('New location')
->hint('Enter the location of this file or folder, relative to the current directory.')
->required()
->live(),
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location')))),
->content(fn (Get $get, File $file) => resolve_path('./' . join_paths($this->path, $get('location') ?? '/', $file->name))),
])
->action(function ($data, File $file, DaemonFileRepository $fileRepository) use ($server) {
$location = resolve_path(join_paths($this->path, $data['location']));
->action(function ($data, File $file) {
$location = rtrim($data['location'], '/');
$files = [['to' => join_paths($location, $file->name), 'from' => $file->name]];
$files = [['to' => $location, 'from' => $file->name]];
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
$fileRepository
->setServer($server)
->renameFiles($this->path, $files);
$oldLocation = join_paths($this->path, $file->name);
$newLocation = resolve_path(join_paths($this->path, $location, $file->name));
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->property('to', $location)
->property('from', $file->name)
->property('to', $newLocation)
->property('from', $oldLocation)
->log();
Notification::make()
->title(join_paths($this->path, $file->name) . ' was moved to ' . $location)
->title('File Moved')
->body($oldLocation . ' -> ' . $newLocation)
->success()
->send();
}),
@@ -252,16 +247,14 @@ class ListFiles extends ListRecords
return $this->getPermissionsFromModeBit($mode);
}),
])
->action(function ($data, File $file, DaemonFileRepository $fileRepository) use ($server) {
->action(function ($data, File $file) {
$owner = (in_array('read', $data['owner']) ? 4 : 0) | (in_array('write', $data['owner']) ? 2 : 0) | (in_array('execute', $data['owner']) ? 1 : 0);
$group = (in_array('read', $data['group']) ? 4 : 0) | (in_array('write', $data['group']) ? 2 : 0) | (in_array('execute', $data['group']) ? 1 : 0);
$public = (in_array('read', $data['public']) ? 4 : 0) | (in_array('write', $data['public']) ? 2 : 0) | (in_array('execute', $data['public']) ? 1 : 0);
$mode = $owner . $group . $public;
$fileRepository
->setServer($server)
->chmodFiles($this->path, [['file' => $file->name, 'mode' => $mode]]);
$this->getDaemonFileRepository()->chmodFiles($this->path, [['file' => $file->name, 'mode' => $mode]]);
Notification::make()
->title('Permissions changed to ' . $mode)
@@ -272,18 +265,24 @@ class ListFiles extends ListRecords
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->label('Archive')
->icon('tabler-archive')
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->compressFiles($this->path, [$file->name]);
->form([
TextInput::make('name')
->label('Archive name')
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
])
->action(function ($data, File $file) {
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, [$file->name], $data['name']);
Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path)
->property('files', [$file->name])
->log();
Notification::make()
->title('Archive created')
->body($archive['name'])
->success()
->send();
@@ -294,10 +293,8 @@ class ListFiles extends ListRecords
->label('Unarchive')
->icon('tabler-archive')
->visible(fn (File $file) => $file->isArchive())
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->decompressFile($this->path, $file->name);
->action(function (File $file) {
$this->getDaemonFileRepository()->decompressFile($this->path, $file->name);
Activity::event('server:file.decompress')
->property('directory', $this->path)
@@ -319,10 +316,8 @@ class ListFiles extends ListRecords
->requiresConfirmation()
->modalDescription(fn (File $file) => $file->name)
->modalHeading('Delete file?')
->action(function (File $file, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->deleteFiles($this->path, [$file->name]);
->action(function (File $file) {
$this->getDaemonFileRepository()->deleteFiles($this->path, [$file->name]);
Activity::event('server:file.delete')
->property('directory', $this->path)
@@ -330,79 +325,77 @@ class ListFiles extends ListRecords
->log();
}),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->hidden() // TODO
->form([
TextInput::make('location')
->label('File name')
->hint('Enter the new name and directory of this file or folder, relative to the current directory.')
->default(fn (File $file) => $file->name)
->required()
->live(),
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
])
->action(function (Collection $files, $data, DaemonFileRepository $fileRepository) use ($server) {
$location = resolve_path(join_paths($this->path, $data['location']));
->groupedBulkActions([
BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->form([
TextInput::make('location')
->label('Directory')
->hint('Enter the new directory, relative to the current directory.')
->required()
->live(),
Placeholder::make('new_location')
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
])
->action(function (Collection $files, $data) {
$location = rtrim($data['location'], '/');
$files = $files->map(fn ($file) => ['to' => $location, 'from' => $file['name']])->toArray();
$fileRepository
->setServer($server)
->renameFiles($this->path, $files);
$files = $files->map(fn ($file) => ['to' => join_paths($location, $file['name']), 'from' => $file['name']])->toArray();
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files were moved from ' . $location)
->success()
->send();
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->action(function (Collection $files, DaemonFileRepository $fileRepository) use ($server) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
Notification::make()
->title(count($files) . ' Files were moved to ' . resolve_path(join_paths($this->path, $location)))
->success()
->send();
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->form([
TextInput::make('name')
->label('Archive name')
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
])
->action(function ($data, Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$fileRepository
->setServer($server)
->compressFiles($this->path, $files);
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']);
Activity::event('server:file.compress')
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title('Archive created')
->success()
->send();
Notification::make()
->title('Archive created')
->body($archive['name'])
->success()
->send();
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->action(function (Collection $files, DaemonFileRepository $fileRepository) use ($server) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$fileRepository
->setServer($server)
->deleteFiles($this->path, $files);
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files);
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files deleted.')
->success()
->send();
}),
]),
Notification::make()
->title(count($files) . ' Files deleted.')
->success()
->send();
}),
]);
}
@@ -418,10 +411,8 @@ class ListFiles extends ListRecords
->color('gray')
->keyBindings('')
->modalSubmitActionLabel('Create')
->action(function ($data, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->putContent(join_paths($this->path, $data['name']), $data['editor'] ?? '');
->action(function ($data) {
$this->getDaemonFileRepository()->putContent(join_paths($this->path, $data['name']), $data['editor'] ?? '');
Activity::event('server:file.write')
->property('file', join_paths($this->path, $data['name']))
@@ -433,6 +424,8 @@ class ListFiles extends ListRecords
->required(),
Select::make('lang')
->label('Syntax Highlighting')
->searchable()
->native(false)
->live()
->options(EditorLanguages::class)
->selectablePlaceholder(false)
@@ -447,10 +440,8 @@ class ListFiles extends ListRecords
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->label('New Folder')
->color('gray')
->action(function ($data, DaemonFileRepository $fileRepository) use ($server) {
$fileRepository
->setServer($server)
->createDirectory($data['name'], $this->path);
->action(function ($data) {
$this->getDaemonFileRepository()->createDirectory($data['name'], $this->path);
Activity::event('server:file.create-directory')
->property(['directory' => $this->path, 'name' => $data['name']])
@@ -464,13 +455,11 @@ class ListFiles extends ListRecords
HeaderAction::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->label('Upload')
->action(function ($data, DaemonFileRepository $fileRepository) use ($server) {
->action(function ($data) {
if (count($data['files']) > 0 && !isset($data['url'])) {
/** @var UploadedFile $file */
foreach ($data['files'] as $file) {
$fileRepository
->setServer($server)
->putContent(join_paths($this->path, $file->getClientOriginalName()), $file->getContent());
$this->getDaemonFileRepository()->putContent(join_paths($this->path, $file->getClientOriginalName()), $file->getContent());
Activity::event('server:file.uploaded')
->property('directory', $this->path)
@@ -478,9 +467,7 @@ class ListFiles extends ListRecords
->log();
}
} elseif ($data['url'] !== null) {
$fileRepository
->setServer($server)
->pull($data['url'], $this->path);
$this->getDaemonFileRepository()->pull($data['url'], $this->path);
Activity::event('server:file.pull')
->property('url', $data['url'])
@@ -521,8 +508,9 @@ class ListFiles extends ListRecords
->form([
TextInput::make('searchTerm')
->placeholder('Enter a search term, e.g. *.txt')
->required()
->regex('/^[^*]*\*?[^*]*$/')
->minLength(3),
->minValue(3),
])
->action(fn ($data) => redirect(SearchFiles::getUrl([
'searchTerm' => $data['searchTerm'],
@@ -531,6 +519,32 @@ class ListFiles extends ListRecords
];
}
/**
* @return string[]
*/
private function getPermissionsFromModeBit(int $mode): array
{
return match ($mode) {
1 => ['execute'],
2 => ['write'],
3 => ['write', 'execute'],
4 => ['read'],
5 => ['read', 'execute'],
6 => ['read', 'write'],
7 => ['read', 'write', 'execute'],
default => [],
};
}
private function getDaemonFileRepository(): DaemonFileRepository
{
/** @var Server $server */
$server = Filament::getTenant();
$this->fileRepository ??= (new DaemonFileRepository())->setServer($server);
return $this->fileRepository;
}
public static function route(string $path): PageRegistration
{
return new PageRegistration(
@@ -541,28 +555,4 @@ class ListFiles extends ListRecords
->where('path', '.*'),
);
}
/**
* @return string[]
*/
private function getPermissionsFromModeBit(int $mode): array
{
if ($mode === 1) {
return ['execute'];
} elseif ($mode === 2) {
return ['write'];
} elseif ($mode === 3) {
return ['write', 'execute'];
} elseif ($mode === 4) {
return ['read'];
} elseif ($mode === 5) {
return ['read', 'execute'];
} elseif ($mode === 6) {
return ['read', 'write'];
} elseif ($mode === 7) {
return ['read', 'write', 'execute'];
}
return [];
}
}

View File

@@ -12,6 +12,7 @@ use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
class SearchFiles extends ListRecords
{
@@ -22,15 +23,8 @@ class SearchFiles extends ListRecords
#[Locked]
public string $searchTerm;
#[Locked]
public string $path;
public function mount(?string $searchTerm = null, ?string $path = null): void
{
parent::mount();
$this->searchTerm = $searchTerm;
$this->path = $path ?? '/';
}
#[Url]
public string $path = '/';
public function getBreadcrumbs(): array
{

View File

@@ -4,9 +4,12 @@ namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Filament\Server\Resources\ScheduleResource\RelationManagers\TasksRelationManager;
use App\Helpers\Utilities;
use App\Models\Permission;
use App\Models\Schedule;
use App\Models\Server;
use Carbon\Carbon;
use Exception;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
@@ -17,7 +20,9 @@ use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model;
class ScheduleResource extends Resource
@@ -314,4 +319,18 @@ class ScheduleResource extends Resource
'edit' => Pages\EditSchedule::route('/{record}/edit'),
];
}
public static function getNextRun(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): Carbon
{
try {
return Utilities::getScheduleNextRunDate($minute, $hour, $dayOfMonth, $month, $dayOfWeek);
} catch (Exception) {
Notification::make()
->title('The cron data provided does not evaluate to a valid expression')
->danger()
->send();
throw new Halt();
}
}
}

View File

@@ -2,14 +2,10 @@
namespace App\Filament\Server\Resources\ScheduleResource\Pages;
use App\Exceptions\DisplayException;
use App\Facades\Activity;
use App\Filament\Server\Resources\ScheduleResource;
use App\Helpers\Utilities;
use App\Models\Schedule;
use App\Models\Server;
use Carbon\Carbon;
use Exception;
use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord;
@@ -39,27 +35,18 @@ class CreateSchedule extends CreateRecord
}
if (!isset($data['next_run_at'])) {
$data['next_run_at'] = $this->getNextRunAt($data['cron_minute'], $data['cron_hour'], $data['cron_day_of_month'], $data['cron_month'], $data['cron_day_of_week']);
$data['next_run_at'] = ScheduleResource::getNextRun(
$data['cron_minute'],
$data['cron_hour'],
$data['cron_day_of_month'],
$data['cron_month'],
$data['cron_day_of_week']
);
}
return $data;
}
protected function getNextRunAt(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek): Carbon
{
try {
return Utilities::getScheduleNextRunDate(
$minute,
$hour,
$dayOfMonth,
$month,
$dayOfWeek
);
} catch (Exception) {
throw new DisplayException('The cron data provided does not evaluate to a valid expression.');
}
}
public function getBreadcrumbs(): array
{
return [];

View File

@@ -22,6 +22,19 @@ class EditSchedule extends EditRecord
->log();
}
protected function mutateFormDataBeforeSave(array $data): array
{
$data['next_run_at'] = ScheduleResource::getNextRun(
$data['cron_minute'],
$data['cron_hour'],
$data['cron_day_of_month'],
$data['cron_month'],
$data['cron_day_of_week']
);
return $data;
}
protected function getHeaderActions(): array
{
return [

View File

@@ -39,8 +39,10 @@ class ListSchedules extends ListRecords
->sortable(),
DateTimeColumn::make('next_run_at')
->label('Next run')
->placeholder('Never')
->since()
->sortable(),
->sortable()
->state(fn (Schedule $schedule) => $schedule->is_active ? $schedule->next_run_at : null),
])
->actions([
ViewAction::make(),

View File

@@ -5,7 +5,6 @@ namespace App\Filament\Server\Resources;
use App\Filament\Server\Resources\UserResource\Pages;
use App\Models\Permission;
use App\Models\Server;
use App\Models\Subuser;
use App\Models\User;
use App\Services\Subusers\SubuserDeletionService;
use App\Services\Subusers\SubuserUpdateService;
@@ -20,8 +19,8 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\DeleteAction;
use Filament\Resources\Resource;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
@@ -84,6 +83,35 @@ class UserResource extends Resource
/** @var Server $server */
$server = Filament::getTenant();
$tabs = [];
$permissionsArray = [];
foreach (Permission::permissionData() as $data) {
$options = [];
$descriptions = [];
foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/users.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission;
}
$tabs[] = Tab::make(str($data['name'])->headline())
->schema([
Section::make()
->description(trans('server/users.permissions.' . $data['name'] . '_desc'))
->icon($data['icon'])
->schema([
CheckboxList::make($data['name'])
->label('')
->bulkToggleable()
->columns(2)
->options($options)
->descriptions($descriptions),
]),
]);
}
return $table
->paginated(false)
->searchable(false)
@@ -91,21 +119,21 @@ class UserResource extends Resource
ImageColumn::make('picture')
->visibleFrom('lg')
->label('')
->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
->alignCenter()->circular()
->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)),
TextColumn::make('username')
->searchable(),
TextColumn::make('email')
->searchable(),
TextColumn::make('permissions')
->state(fn (User $user) => count(Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first()->permissions)),
->state(fn (User $user) => count($server->subusers->where('user_id', $user->id)->first()->permissions)),
])
->actions([
DeleteAction::make()
->label('Remove User')
->hidden(fn (User $user) => auth()->user()->id === $user->id)
->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuser = Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first();
$subuser = $server->subusers->where('user_id', $user->id)->first();
$subuserDeletionService->handle($subuser, $server);
Notification::make()
@@ -119,7 +147,7 @@ class UserResource extends Resource
->authorize(fn () => auth()->user()->can(Permission::ACTION_USER_UPDATE, $server))
->modalHeading(fn (User $user) => 'Editing ' . $user->email)
->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) {
$subuser = Subuser::query()->where('user_id', $user->id)->where('server_id', $server->id)->first();
$subuser = $server->subusers->where('user_id', $user->id)->first();
$permissions = collect($data)
->forget('email')
@@ -159,67 +187,8 @@ class UserResource extends Resource
Actions::make([
Action::make('assignAll')
->label('Assign All')
->action(function (Set $set) {
$permissions = [
'control' => [
'console',
'start',
'stop',
'restart',
],
'user' => [
'read',
'create',
'update',
'delete',
],
'file' => [
'read',
'read-content',
'create',
'update',
'delete',
'archive',
'sftp',
],
'backup' => [
'read',
'create',
'delete',
'download',
'restore',
],
'allocation' => [
'read',
'create',
'update',
'delete',
],
'startup' => [
'read',
'update',
'docker-image',
],
'database' => [
'read',
'create',
'update',
'delete',
'view_password',
],
'schedule' => [
'read',
'create',
'update',
'delete',
],
'settings' => [
'rename',
'reinstall',
'activity',
],
];
->action(function (Set $set) use ($permissionsArray) {
$permissions = $permissionsArray;
foreach ($permissions as $key => $value) {
$allValues = array_unique($value);
$set($key, $allValues);
@@ -234,243 +203,25 @@ class UserResource extends Resource
]),
Tabs::make()
->columnSpanFull()
->schema([
Tab::make('Console')
->schema([
Section::make()
->description(trans('server/users.permissions.control_desc'))
->icon('tabler-terminal-2')
->schema([
CheckboxList::make('control')
->formatStateUsing(function (User $user, Set $set) use ($server) {
$permissionsArray = Subuser::query()
->where('user_id', $user->id)
->where('server_id', $server->id)
->first()
->permissions;
$transformedPermissions = [];
foreach ($permissionsArray as $permission) {
[$group, $action] = explode('.', $permission, 2);
$transformedPermissions[$group][] = $action;
}
foreach ($transformedPermissions as $key => $value) {
$set($key, $value);
}
return $transformedPermissions['control'] ?? [];
})
->bulkToggleable()
->label('')
->options([
'console' => 'Console',
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
])
->descriptions([
'console' => trans('server/users.permissions.control_console'),
'start' => trans('server/users.permissions.control_start'),
'stop' => trans('server/users.permissions.control_stop'),
'restart' => trans('server/users.permissions.control_restart'),
]),
]),
]),
Tab::make('User')
->schema([
Section::make()
->description(trans('server/users.permissions.user_desc'))
->icon('tabler-users')
->schema([
CheckboxList::make('user')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'create' => trans('server/users.permissions.user_create'),
'read' => trans('server/users.permissions.user_read'),
'update' => trans('server/users.permissions.user_update'),
'delete' => trans('server/users.permissions.user_delete'),
]),
]),
]),
Tab::make('File')
->schema([
Section::make()
->description(trans('server/users.permissions.file_desc'))
->icon('tabler-folders')
->schema([
CheckboxList::make('file')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'read-content' => 'Read Content',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'archive' => 'Archive',
'sftp' => 'SFTP',
])
->descriptions([
'create' => trans('server/users.permissions.file_create'),
'read' => trans('server/users.permissions.file_read'),
'read-content' => trans('server/users.permissions.file_read_content'),
'update' => trans('server/users.permissions.file_update'),
'delete' => trans('server/users.permissions.file_delete'),
'archive' => trans('server/users.permissions.file_archive'),
'sftp' => trans('server/users.permissions.file_sftp'),
]),
]),
]),
Tab::make('Backup')
->schema([
Section::make()
->description(trans('server/users.permissions.backup_desc'))
->icon('tabler-download')
->schema([
CheckboxList::make('backup')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'delete' => 'Delete',
'download' => 'Download',
'restore' => 'Restore',
])
->descriptions([
'create' => trans('server/users.permissions.backup_create'),
'read' => trans('server/users.permissions.backup_read'),
'delete' => trans('server/users.permissions.backup_delete'),
'download' => trans('server/users.permissions.backup_download'),
'restore' => trans('server/users.permissions.backup_restore'),
]),
]),
]),
Tab::make('Allocation')
->schema([
Section::make()
->description(trans('server/users.permissions.allocation_desc'))
->icon('tabler-network')
->schema([
CheckboxList::make('allocation')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.allocation_read'),
'create' => trans('server/users.permissions.allocation_create'),
'update' => trans('server/users.permissions.allocation_update'),
'delete' => trans('server/users.permissions.allocation_delete'),
]),
]),
]),
Tab::make('Startup')
->schema([
Section::make()
->description(trans('server/users.permissions.startup_desc'))
->icon('tabler-question-mark')
->schema([
CheckboxList::make('startup')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'update' => 'Update',
'docker-image' => 'Docker Image',
])
->descriptions([
'read' => trans('server/users.permissions.startup_read'),
'update' => trans('server/users.permissions.startup_update'),
'docker-image' => trans('server/users.permissions.startup_docker_image'),
]),
]),
]),
Tab::make('Database')
->schema([
Section::make()
->description(trans('server/users.permissions.database_desc'))
->icon('tabler-database')
->schema([
CheckboxList::make('database')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'view_password' => 'View Password',
])
->descriptions([
'read' => trans('server/users.permissions.database_read'),
'create' => trans('server/users.permissions.database_create'),
'update' => trans('server/users.permissions.database_update'),
'delete' => trans('server/users.permissions.database_delete'),
'view_password' => trans('server/users.permissions.database_view_password'),
]),
]),
]),
Tab::make('Schedule')
->schema([
Section::make()
->description(trans('server/users.permissions.schedule_desc'))
->icon('tabler-clock')
->schema([
CheckboxList::make('schedule')
->bulkToggleable()
->label('')
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.schedule_read'),
'create' => trans('server/users.permissions.schedule_create'),
'update' => trans('server/users.permissions.schedule_update'),
'delete' => trans('server/users.permissions.schedule_delete'),
]),
]),
]),
Tab::make('Settings')
->schema([
Section::make()
->description(trans('server/users.permissions.settings_desc'))
->icon('tabler-settings')
->schema([
CheckboxList::make('settings')
->bulkToggleable()
->label('')
->options([
'rename' => 'Rename',
'reinstall' => 'Reinstall',
'activity' => 'Activity',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.activity_desc'),
]),
]),
]),
]),
->schema($tabs),
]),
]),
])
->mutateRecordDataUsing(function ($data, User $user) use ($server) {
$permissionsArray = $server->subusers->where('user_id', $user->id)->first()->permissions;
$transformedPermissions = [];
foreach ($permissionsArray as $permission) {
[$group, $action] = explode('.', $permission, 2);
$transformedPermissions[$group][] = $action;
}
foreach ($transformedPermissions as $key => $value) {
$data[$key] = $value;
}
return $data;
}),
]);
}

View File

@@ -10,12 +10,13 @@ use App\Services\Subusers\SubuserCreationService;
use Exception;
use Filament\Actions;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions as assignAll;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Actions as assignAll;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Forms\Set;
@@ -31,6 +32,35 @@ class ListUsers extends ListRecords
/** @var Server $server */
$server = Filament::getTenant();
$tabs = [];
$permissionsArray = [];
foreach (Permission::permissionData() as $data) {
$options = [];
$descriptions = [];
foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/users.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission;
}
$tabs[] = Tab::make(str($data['name'])->headline())
->schema([
Section::make()
->description(trans('server/users.permissions.' . $data['name'] . '_desc'))
->icon($data['icon'])
->schema([
CheckboxList::make($data['name'])
->label('')
->bulkToggleable()
->columns(2)
->options($options)
->descriptions($descriptions),
]),
]);
}
return [
Actions\CreateAction::make('invite')
->label('Invite User')
@@ -59,72 +89,10 @@ class ListUsers extends ListRecords
assignAll::make([
Action::make('assignAll')
->label('Assign All')
->action(function (Set $set, Get $get) {
$permissions = [
'control' => [
'console',
'start',
'stop',
'restart',
],
'user' => [
'read',
'create',
'update',
'delete',
],
'file' => [
'read',
'read-content',
'create',
'update',
'delete',
'archive',
'sftp',
],
'backup' => [
'read',
'create',
'delete',
'download',
'restore',
],
'allocation' => [
'read',
'create',
'update',
'delete',
],
'startup' => [
'read',
'update',
'docker-image',
],
'database' => [
'read',
'create',
'update',
'delete',
'view_password',
],
'schedule' => [
'read',
'create',
'update',
'delete',
],
'settings' => [
'rename',
'reinstall',
],
'activity' => [
'read',
],
];
->action(function (Set $set, Get $get) use ($permissionsArray) {
$permissions = $permissionsArray;
foreach ($permissions as $key => $value) {
$currentValues = $get($key) ?? [];
$allValues = array_unique(array_merge($currentValues, $value));
$allValues = array_unique($value);
$set($key, $allValues);
}
}),
@@ -137,249 +105,7 @@ class ListUsers extends ListRecords
]),
Tabs::make()
->columnSpanFull()
->schema([
Tabs\Tab::make('Console')
->schema([
Section::make()
->description(trans('server/users.permissions.control_desc'))
->icon('tabler-terminal-2')
->schema([
CheckboxList::make('control')
->bulkToggleable()
->label('')
->columns(2)
->options([
'console' => 'Console',
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
])
->descriptions([
'console' => trans('server/users.permissions.control_console'),
'start' => trans('server/users.permissions.control_start'),
'stop' => trans('server/users.permissions.control_stop'),
'restart' => trans('server/users.permissions.control_restart'),
]),
]),
]),
Tabs\Tab::make('User')
->schema([
Section::make()
->description(trans('server/users.permissions.user_desc'))
->icon('tabler-users')
->schema([
CheckboxList::make('user')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'create' => trans('server/users.permissions.user_create'),
'read' => trans('server/users.permissions.user_read'),
'update' => trans('server/users.permissions.user_update'),
'delete' => trans('server/users.permissions.user_delete'),
]),
]),
]),
Tabs\Tab::make('File')
->schema([
Section::make()
->description(trans('server/users.permissions.file_desc'))
->icon('tabler-folders')
->schema([
CheckboxList::make('file')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'read-content' => 'Read Content',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'archive' => 'Archive',
'sftp' => 'SFTP',
])
->descriptions([
'create' => trans('server/users.permissions.file_create'),
'read' => trans('server/users.permissions.file_read'),
'read-content' => trans('server/users.permissions.file_read_content'),
'update' => trans('server/users.permissions.file_update'),
'delete' => trans('server/users.permissions.file_delete'),
'archive' => trans('server/users.permissions.file_archive'),
'sftp' => trans('server/users.permissions.file_sftp'),
]),
]),
]),
Tabs\Tab::make('Backup')
->schema([
Section::make()
->description(trans('server/users.permissions.backup_desc'))
->icon('tabler-download')
->schema([
CheckboxList::make('backup')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'delete' => 'Delete',
'download' => 'Download',
'restore' => 'Restore',
])
->descriptions([
'create' => trans('server/users.permissions.backup_create'),
'read' => trans('server/users.permissions.backup_read'),
'delete' => trans('server/users.permissions.backup_delete'),
'download' => trans('server/users.permissions.backup_download'),
'restore' => trans('server/users.permissions.backup_restore'),
]),
]),
]),
Tabs\Tab::make('Allocation')
->schema([
Section::make()
->description(trans('server/users.permissions.allocation_desc'))
->icon('tabler-network')
->schema([
CheckboxList::make('allocation')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.allocation_read'),
'create' => trans('server/users.permissions.allocation_create'),
'update' => trans('server/users.permissions.allocation_update'),
'delete' => trans('server/users.permissions.allocation_delete'),
]),
]),
]),
Tabs\Tab::make('Startup')
->schema([
Section::make()
->description(trans('server/users.permissions.startup_desc'))
->icon('tabler-question-mark')
->schema([
CheckboxList::make('startup')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'update' => 'Update',
'docker-image' => 'Docker Image',
])
->descriptions([
'read' => trans('server/users.permissions.startup_read'),
'update' => trans('server/users.permissions.startup_update'),
'docker-image' => trans('server/users.permissions.startup_docker_image'),
]),
]),
]),
Tabs\Tab::make('Database')
->schema([
Section::make()
->description(trans('server/users.permissions.database_desc'))
->icon('tabler-database')
->schema([
CheckboxList::make('database')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
'view_password' => 'View Password',
])
->descriptions([
'read' => trans('server/users.permissions.database_read'),
'create' => trans('server/users.permissions.database_create'),
'update' => trans('server/users.permissions.database_update'),
'delete' => trans('server/users.permissions.database_delete'),
'view_password' => trans('server/users.permissions.database_view_password'),
]),
]),
]),
Tabs\Tab::make('Schedule')
->schema([
Section::make()
->description(trans('server/users.permissions.schedule_desc'))
->icon('tabler-clock')
->schema([
CheckboxList::make('schedule')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
'create' => 'Create',
'update' => 'Update',
'delete' => 'Delete',
])
->descriptions([
'read' => trans('server/users.permissions.schedule_read'),
'create' => trans('server/users.permissions.schedule_create'),
'update' => trans('server/users.permissions.schedule_update'),
'delete' => trans('server/users.permissions.schedule_delete'),
]),
]),
]),
Tabs\Tab::make('Settings')
->schema([
Section::make()
->description(trans('server/users.permissions.settings_desc'))
->icon('tabler-settings')
->schema([
CheckboxList::make('settings')
->bulkToggleable()
->label('')
->columns(2)
->options([
'rename' => 'Rename',
'reinstall' => 'Reinstall',
'activity' => 'Activity',
])
->descriptions([
'rename' => trans('server/users.permissions.setting_rename'),
'reinstall' => trans('server/users.permissions.setting_reinstall'),
'activity' => trans('server/users.permissions.activity_desc'),
]),
]),
]),
Tabs\Tab::make('Activity')
->schema([
Section::make()
->description(trans('server/users.permissions.activity_desc'))
->icon('tabler-stack')
->schema([
CheckboxList::make('activity')
->bulkToggleable()
->label('')
->columns(2)
->options([
'read' => 'Read',
])
->descriptions([
'read' => trans('server/users.permissions.activity_read'),
]),
]),
]),
]),
->schema($tabs),
]),
])
->modalHeading('Invite User')

View File

@@ -11,6 +11,7 @@ use App\Services\Nodes\NodeJWTService;
use App\Services\Servers\GetUserPermissionsService;
use Filament\Widgets\Widget;
use Illuminate\Support\Arr;
use Livewire\Attributes\Session;
use Livewire\Attributes\On;
class ServerConsole extends Widget
@@ -26,6 +27,7 @@ class ServerConsole extends Widget
public ?User $user = null;
/** @var string[] */
#[Session(key: 'server.{server.id}.history')]
public array $history = [];
public int $historyIndex = 0;
@@ -130,7 +132,7 @@ class ServerConsole extends Widget
#[On('websocket-error')]
public function websocketError(): void
{
AlertBanner::make()
AlertBanner::make('websocket_error')
->title('Could not connect to websocket!')
->body('Check your browser console for more details.')
->danger()

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
@@ -16,10 +17,19 @@ class ServerCpuChart extends ChartWidget
public ?Server $server = null;
public static function canView(): bool
{
/** @var Server $server */
$server = Filament::getTenant();
return !$server->isInConflictState() && !$server->retrieveStatus()->isOffline();
}
protected function getData(): array
{
$period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$cpu = collect(cache()->get("servers.{$this->server->id}.cpu_absolute"))
->slice(-10)
->slice(-$period)
->map(fn ($value, $key) => [
'cpu' => Number::format($value, maxPrecision: 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Number;
@@ -16,9 +17,19 @@ class ServerMemoryChart extends ChartWidget
public ?Server $server = null;
public static function canView(): bool
{
/** @var Server $server */
$server = Filament::getTenant();
return !$server->isInConflictState() && !$server->retrieveStatus()->isOffline();
}
protected function getData(): array
{
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))->slice(-10)
$period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$memUsed = collect(cache()->get("servers.{$this->server->id}.memory_bytes"))
->slice(-$period)
->map(fn ($value, $key) => [
'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),

View File

@@ -4,61 +4,72 @@ namespace App\Filament\Server\Widgets;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
class ServerNetworkChart extends ChartWidget
{
protected static ?string $heading = 'Network';
protected static ?string $pollingInterval = '1s';
protected static ?string $maxHeight = '300px';
protected static ?string $maxHeight = '200px';
public ?Server $server = null;
public static function canView(): bool
{
/** @var Server $server */
$server = Filament::getTenant();
return !$server->isInConflictState() && !$server->retrieveStatus()->isOffline();
}
protected function getData(): array
{
$data = cache()->get("servers.{$this->server->id}.network");
$previous = null;
$rx = collect($data)
->slice(-10)
->map(fn ($value, $key) => [
'rx' => $value->rx_bytes,
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
$period = auth()->user()->getCustomization()['console_graph_period'] ?? 30;
$net = collect(cache()->get("servers.{$this->server->id}.network"))
->slice(-$period)
->map(function ($current, $timestamp) use (&$previous) {
$net = null;
$tx = collect($data)
->slice(-10)
->map(fn ($value, $key) => [
'tx' => $value->rx_bytes,
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
if ($previous !== null) {
$net = [
'rx' => max(0, $current->rx_bytes - $previous->rx_bytes),
'tx' => max(0, $current->tx_bytes - $previous->tx_bytes),
'timestamp' => Carbon::createFromTimestamp($timestamp, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
];
}
$previous = $current;
return $net;
})
->all();
return [
'datasets' => [
[
'label' => 'Inbound',
'data' => array_column($rx, 'rx'),
'data' => array_column($net, 'rx'),
'backgroundColor' => [
'rgba(96, 165, 250, 0.3)',
'rgba(100, 255, 105, 0.5)',
],
'tension' => '0.3',
'fill' => true,
],
[
'label' => 'Outbound',
'data' => array_column($tx, 'tx'),
'data' => array_column($net, 'tx'),
'backgroundColor' => [
'rgba(165, 96, 250, 0.3)',
'rgba(96, 165, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
],
'labels' => array_column($rx, 'timestamp'),
'labels' => array_column($net, 'timestamp'),
];
}
@@ -69,25 +80,38 @@ class ServerNetworkChart extends ChartWidget
protected function getOptions(): RawJs
{
// TODO: use "panel.use_binary_prefix" config value
return RawJs::make(<<<'JS'
{
scales: {
x: {
grid: {
display: false,
},
ticks: {
display: true,
},
display: false, //debug
display: false,
},
y: {
min: 0,
ticks: {
display: true,
callback(value) {
const bytes = typeof value === 'string' ? parseInt(value, 10) : value;
if (bytes < 1) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const number = Number((bytes / Math.pow(1024, i)).toFixed(2));
return `${number} ${['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'][i]}`;
},
},
},
}
}
JS);
}
public function getHeading(): string
{
$lastData = collect(cache()->get("servers.{$this->server->id}.network"))->last();
return 'Network - ↓' . convert_bytes_to_readable($lastData->rx_bytes ?? 0) . ' - ↑' . convert_bytes_to_readable($lastData->tx_bytes ?? 0);
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Filament\Server\Widgets;
use App\Enums\ContainerStatus;
use App\Filament\Server\Components\SmallStatBlock;
use App\Filament\Server\Components\StatBlock;
use App\Models\Server;
use Carbon\CarbonInterface;
use Filament\Widgets\StatsOverviewWidget;
@@ -19,13 +18,12 @@ class ServerOverview extends StatsOverviewWidget
protected function getStats(): array
{
return [
StatBlock::make('Name', $this->server->name)
->description($this->server->description)
SmallStatBlock::make('Name', $this->server->name)
->extraAttributes([
'class' => 'overflow-x-auto',
]),
StatBlock::make('Status', $this->status()),
StatBlock::make('Address', $this->server->allocation->address)
SmallStatBlock::make('Status', $this->status()),
SmallStatBlock::make('Address', $this->server->allocation->address)
->extraAttributes([
'class' => 'overflow-x-auto',
]),

View File

@@ -6,7 +6,6 @@ use App\Models\Node;
use Illuminate\Http\JsonResponse;
use Spatie\QueryBuilder\QueryBuilder;
use App\Services\Nodes\NodeUpdateService;
use App\Services\Nodes\NodeCreationService;
use App\Services\Nodes\NodeDeletionService;
use App\Transformers\Api\Application\NodeTransformer;
use App\Http\Requests\Api\Application\Nodes\GetNodeRequest;
@@ -16,6 +15,7 @@ use App\Http\Requests\Api\Application\Nodes\DeleteNodeRequest;
use App\Http\Requests\Api\Application\Nodes\UpdateNodeRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use Dedoc\Scramble\Attributes\Group;
use Exception;
#[Group('Node', weight: 0)]
class NodeController extends ApplicationApiController
@@ -24,7 +24,6 @@ class NodeController extends ApplicationApiController
* NodeController constructor.
*/
public function __construct(
private NodeCreationService $creationService,
private NodeDeletionService $deletionService,
private NodeUpdateService $updateService
) {
@@ -74,7 +73,7 @@ class NodeController extends ApplicationApiController
*/
public function store(StoreNodeRequest $request): JsonResponse
{
$node = $this->creationService->handle($request->validated());
$node = Node::create($request->validated());
return $this->fractal->item($node)
->transformWith($this->getTransformer(NodeTransformer::class))
@@ -97,11 +96,15 @@ class NodeController extends ApplicationApiController
*/
public function update(UpdateNodeRequest $request, Node $node): array
{
$node = $this->updateService->handle(
$node,
$request->validated(),
$request->input('reset_secret') === true
);
try {
$node = $this->updateService->handle(
$node,
$request->validated(),
$request->input('reset_secret') === true
);
} catch (Exception $exception) {
report($exception);
}
return $this->fractal->item($node)
->transformWith($this->getTransformer(NodeTransformer::class))

View File

@@ -13,6 +13,7 @@ use App\Services\Servers\TransferServerService;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
#[Group('Server', weight: 4)]
class ServerManagementController extends ApplicationApiController
@@ -82,15 +83,24 @@ class ServerManagementController extends ApplicationApiController
$validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'allocation_additional' => 'nullable',
'allocation_additional' => 'nullable|array',
'allocation_additional.*' => 'integer|exists:allocations,id',
]);
if ($this->transferServerService->handle($server, $validatedData)) {
// Transfer started
if ($this->transferServerService->handle($server, Arr::get($validatedData, 'node_id'), Arr::get($validatedData, 'allocation_id'), Arr::get($validatedData, 'allocation_additional', []))) {
/**
* Transfer started
*
* @status 204
*/
return $this->returnNoContent();
}
// Node was not viable
/**
* Node was not viable
*
* @status 406
*/
return $this->returnNotAcceptable();
}
@@ -104,7 +114,11 @@ class ServerManagementController extends ApplicationApiController
public function cancelTransfer(ServerWriteRequest $request, Server $server): Response
{
if (!$transfer = $server->transfer) {
// Server is not transferring
/**
* Server is not transferring
*
* @status 406
*/
return $this->returnNotAcceptable();
}
@@ -113,6 +127,11 @@ class ServerManagementController extends ApplicationApiController
$this->daemonServerRepository->setServer($server)->cancelTransfer();
/**
* Transfer cancelled
*
* @status 204
*/
return $this->returnNoContent();
}
}

View File

@@ -172,8 +172,8 @@ class FileController extends ClientApiController
Activity::event('server:file.rename')
->property('directory', $request->input('root'))
->property('files', $files)
->property('to', $files['to'])
->property('from', $files['from'])
->property('to', $files[0]['to'])
->property('from', $files[0]['from'])
->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
@@ -210,10 +210,12 @@ class FileController extends ClientApiController
{
$file = $this->fileRepository->setServer($server)->compressFiles(
$request->input('root'),
$request->input('files')
$request->input('files'),
$request->input('name')
);
Activity::event('server:file.compress')
->property('name', $file['name'])
->property('directory', $request->input('root'))
->property('files', $request->input('files'))
->log();

View File

@@ -9,6 +9,7 @@ use App\Repositories\Daemon\DaemonPowerRepository;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\SendPowerRequest;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Http\Client\ConnectionException;
#[Group('Server', weight: 2)]
class PowerController extends ClientApiController
@@ -25,6 +26,8 @@ class PowerController extends ClientApiController
* Send power action
*
* Send a power action to a server.
*
* @throws ConnectionException
*/
public function index(SendPowerRequest $request, Server $server): Response
{

View File

@@ -36,26 +36,22 @@ class SettingsController extends ClientApiController
$name = $request->input('name');
$description = $request->has('description') ? (string) $request->input('description') : $server->description;
$server->name = $name;
if (config('panel.editable_server_descriptions')) {
$server->description = $description;
}
$server->save();
if ($server->name !== $name) {
Activity::event('server:settings.rename')
->property(['old' => $server->name, 'new' => $name])
->log();
$server->name = $name;
}
if ($server->description !== $description) {
if ($server->description !== $description && config('panel.editable_server_descriptions')) {
Activity::event('server:settings.description')
->property(['old' => $server->description, 'new' => $description])
->log();
$server->description = $description;
}
$server->save();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}

View File

@@ -37,7 +37,7 @@ class StartupController extends ClientApiController
$startup = $this->startupCommandService->handle($server);
return $this->fractal->collection(
$server->variables()->orderBy('sort')->where('user_viewable', true)->get()
$server->variables()->where('user_viewable', true)->orderBy('sort')->get()
)
->transformWith($this->getTransformer(EggVariableTransformer::class))
->addMeta([

View File

@@ -5,10 +5,8 @@ namespace App\Http\Controllers\Api\Remote;
use Carbon\Carbon;
use Illuminate\Support\Str;
use App\Models\User;
use Webmozart\Assert\Assert;
use App\Models\Server;
use App\Models\ActivityLog;
use App\Models\ActivityLogSubject;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ActivityEventRequest;
@@ -16,8 +14,6 @@ class ActivityProcessingController extends Controller
{
public function __invoke(ActivityEventRequest $request): void
{
$tz = Carbon::now()->getTimezone();
/** @var \App\Models\Node $node */
$node = $request->attributes->get('node');
@@ -51,11 +47,8 @@ class ActivityProcessingController extends Controller
$log = [
'ip' => empty($datum['ip']) ? '127.0.0.1' : $datum['ip'],
'event' => $datum['event'],
'properties' => json_encode($datum['metadata'] ?? []),
// We have to change the time to the current timezone due to the way Laravel is handling
// the date casting internally. If we just leave it in UTC it ends up getting double-cast
// and the time is way off.
'timestamp' => $when->setTimezone($tz),
'properties' => $datum['metadata'] ?? [],
'timestamp' => $when,
];
if ($user = $users->get($datum['user'])) {
@@ -71,19 +64,17 @@ class ActivityProcessingController extends Controller
}
foreach ($logs as $key => $data) {
Assert::isInstanceOf($server = $servers->get($key), Server::class);
$server = $servers->get($key);
assert($server instanceof Server);
$batch = [];
foreach ($data as $datum) {
$id = ActivityLog::insertGetId($datum);
$batch[] = [
'activity_log_id' => $id,
/** @var ActivityLog $activityLog */
$activityLog = ActivityLog::forceCreate($datum);
$activityLog->subjects()->create([
'subject_id' => $server->id,
'subject_type' => $server->getMorphClass(),
];
]);
}
ActivityLogSubject::insert($batch);
}
}
}

View File

@@ -44,6 +44,20 @@ class OAuthController extends Controller
return redirect()->route('auth.login');
}
// Check for errors (https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/)
if ($request->get('error')) {
report($request->get('error_description') ?? $request->get('error'));
Notification::make()
->title('Something went wrong')
->body($request->get('error'))
->danger()
->persistent()
->send();
return redirect()->route('auth.login');
}
$oauthUser = Socialite::driver($driver)->user();
// User is already logged in and wants to link a new OAuth Provider
@@ -53,7 +67,7 @@ class OAuthController extends Controller
$this->updateService->handle($request->user(), ['oauth' => $oauth]);
return redirect(EditProfile::getUrl(['tab' => '-oauth-tab']));
return redirect(EditProfile::getUrl(['tab' => '-oauth-tab'], panel: 'app'));
}
try {

View File

@@ -5,6 +5,9 @@ namespace App\Http\Middleware;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Exceptions\Http\TwoFactorAuthRequiredException;
use App\Filament\Pages\Auth\EditProfile;
use App\Livewire\AlertBanner;
use App\Models\User;
class RequireTwoFactorAuthentication
{
@@ -14,11 +17,6 @@ class RequireTwoFactorAuthentication
public const LEVEL_ALL = 2;
/**
* The route to redirect a user to enable 2FA.
*/
protected string $redirectRoute = '/account';
/**
* Check the user state on the incoming request to determine if they should be allowed to
* proceed or not. This checks if the Panel is configured to require 2FA on an account in
@@ -29,31 +27,37 @@ class RequireTwoFactorAuthentication
*/
public function handle(Request $request, \Closure $next): mixed
{
/** @var ?User $user */
$user = $request->user();
$uri = rtrim($request->getRequestUri(), '/') . '/';
$current = $request->route()->getName();
if (!$user || Str::startsWith($uri, ['/auth/']) || Str::startsWith($current, ['auth.', 'account.'])) {
if (!$user || Str::startsWith($uri, ['/auth/', '/profile']) || Str::startsWith($current, ['auth.', 'account.', 'filament.app.auth.'])) {
return $next($request);
}
/** @var \App\Models\User $user */
$level = (int) config('panel.auth.2fa_required');
// If this setting is not configured, or the user is already using 2FA then we can just
// send them right through, nothing else needs to be checked.
//
// If the level is set as admin and the user is not an admin, pass them through as well.
if ($level === self::LEVEL_NONE || $user->use_totp) {
// If this setting is not configured, or the user is already using 2FA then we can just send them right through, nothing else needs to be checked.
return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) {
} elseif ($level === self::LEVEL_ADMIN && !$user->isAdmin()) {
// If the level is set as admin and the user is not an admin, pass them through as well.
return $next($request);
}
// For API calls return an exception which gets rendered nicely in the API response.
// For API calls return an exception which gets rendered nicely in the API response...
if ($request->isJson() || Str::startsWith($uri, '/api/')) {
throw new TwoFactorAuthRequiredException();
}
return redirect()->to($this->redirectRoute);
// ... otherwise display banner and redirect to profile
AlertBanner::make('2fa_must_be_enabled')
->body(trans('auth.2fa_must_be_enabled'))
->warning()
->send();
return redirect(EditProfile::getUrl(['tab' => '-2fa-tab'], panel: 'app'));
}
}

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