Compare commits

..

162 Commits

Author SHA1 Message Date
github-actions[bot]
27c5167bfe ci(release): bump version 2025-03-15 20:22:50 +00:00
Charles
4e85180b3d Fix Release Build (#1089) 2025-03-15 16:21:31 -04:00
Charles
9f4a3b1c0d Fix Releases (#1088) 2025-03-15 16:13:55 -04:00
Boy132
45db06a1bd Refactor captcha (#1068)
* refactor captcha

* add default error message

* prevent rule from being called multiple times

* fixes

* use config

* Update this to latest

* Remove this

---------

Co-authored-by: Lance Pioch <git@lance.sh>
2025-03-15 15:52:38 -04:00
Charles
3e26a1cf09 save record, then try to update (#1087) 2025-03-15 20:33:20 +01:00
Lance Pioch
44111696df Laravel 12.2.0 Shift (#1082)
* Bump Laravel version constraint

* composer update

* Fix php8.2

* Pin filament for now

---------

Co-authored-by: Shift <shift@laravelshift.com>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-03-15 15:27:06 -04:00
MartinOscar
e04abcbcf9 Replace existing Egg Reserved_Env_Variables with SERVER_ prefix (#1070)
* Add migration that updates egg->variables->env_variable, egg->startup, egg->servers->startup

* Update `EggImporterService` to replace `EggVariable::RESERVED_ENV_NAMES`

* Use `EggImporterService::parseReservedEnvNames`

* Refactor & Remove `Migration`
2025-03-15 14:51:10 -04:00
MartinOscar
ea5914f362 Add url Repeater to ImportEggHeaderAction (#1071)
* Add url `Repeater` to `ImportEggAction`

* Addtranslation

* Requested changes

* Only allow `multiple` when not editing `Egg`

* Only `deletable` & `grid` if `multiple`

* Fix `FileUpload` & Make sure its a json file
2025-03-15 14:46:10 -04:00
MartinOscar
98c36c4cc3 Fix revamp api_keys migration (#987) 2025-03-15 14:42:43 -04:00
MartinOscar
6bc55b1039 Silent file_exists when its not in defaults allowed open_basedir (#1086) 2025-03-15 14:28:59 -04:00
MartinOscar
11b153d23c Fix null Node Stats (#1075)
* Make sure we are talking to the right wings using `getSystemInformation` as a gate keeper

* Re use method

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

---------

Co-authored-by: Lance Pioch <git@lance.sh>
2025-03-15 14:28:15 -04:00
Charles
998ad2ee31 Add hint about overhead when using memory limit (#1069)
* Add hint about overhead when using memory limit

* Update lang/en/admin/server.php

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

* escape `'`

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-03-15 13:10:25 -04:00
Thibault Junin
7f0c7da37f Fix FindViableNodeService to actually filter Tags (#1080)
* fix viable node service to take into account tags

* Update app/Services/Deployment/FindViableNodesService.php

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

---------

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2025-03-13 17:04:44 +01:00
MartinOscar
e93d122a27 Server does not use SoftDelete so deleted_at does not exist (#1083) 2025-03-13 01:00:55 +01:00
MartinOscar
9aaf6b3798 Make redirect & callback public instead of private as required by Laravel 12 (#1081) 2025-03-12 19:32:16 +01:00
MartinOscar
fd6e7eb314 Fix missing space in OAuth modal (#1078) 2025-03-10 19:28:39 +01:00
Lance Pioch
4e694b50ca Make sure the app key is always set (#1074)
* Make sure the key is always set
2025-03-08 21:32:28 -05:00
MartinOscar
3a24edfe1d Tests: Make PHPstan run in 8.2, 8.3 & 8.4 (#1072)
* Add PHP [8.2,8.3,8.4] matrices to `phpstan`

* Use a pointer with `unset($this)` to make PHP 8.4 happy
2025-03-09 01:58:50 +01:00
Lance Pioch
0179ade557 Add Laravel Data package, also some small fixes (#1065)
* Simplify

* Update these

* Add Laravel Data

* Remove unused imports

* Quick fix

* Fix double array

* Update app/Console/Commands/Egg/CheckEggUpdatesCommand.php
2025-03-08 19:56:06 -05:00
MartinOscar
05d74232af Fix Build UI Tests running twice (#1067) 2025-03-08 16:13:55 +01:00
Boy132
a2b2e373be Fix subuser activity log (#1063)
* use user for subject

* add permissions to properties

* always add websocket.connect permission (because it's default)

* small cleanup

* also update editing
2025-03-07 17:29:09 +01:00
MartinOscar
0a17e78f33 Force 2fa_required to no one by default (#1058) 2025-03-06 20:53:29 -05:00
Lance Pioch
c3a65aed07 Laravel 12.1.1 Shift (#1057)
* Bump Laravel version constraint

* Bump community package dependencies

* composer update

---------

Co-authored-by: Shift <shift@laravelshift.com>
2025-03-06 18:37:45 -05:00
MartinOscar
d438e29154 Add missing Database address field (#1049)
* Add address field to display `host:port` to enduser on `ListDatabases` & `EditServer`

* Add `CopyAction` to `EditServer`

* Update databaseHost `display_name_help`
2025-03-06 15:55:40 +01:00
MartinOscar
1fdc428f3e Allow sendCommand on Starting or Running Servers (#1061)
* Replace `string` with `enum`

* Add title

* Allow sendCommand on `Starting` or `Running` servers

* refactor: Use Filament interfaces

* Use `getLabel` instead of `str->headline`

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

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-03-06 15:55:00 +01:00
Charles
a9e4495c91 Add missing activity loggers on client area (#1060)
* Update Subuser

Adds user deleted notification, Adds logger for creating subusers.

* Update Tasks

* ...

* Update Schedule

* Update Files

* Update Database

* Move `reinstall` to proper array

* Add `:action` to deleted task log

* Updates

* Fix CreateSchedule

* Fix Editing/Saving

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-03-06 09:28:45 -05:00
MartinOscar
98ddb65509 Revert Monaco Changes... (#1062)
Reintroduced the ever expanding editor.

Co-authored-by: notCharles <charles@pelican.dev>
2025-03-06 12:50:34 +01:00
MartinOscar
6caa741798 Make restart the default payload when using PowerAction in Schedules (#1059) 2025-03-05 22:10:48 +01:00
MartinOscar
5512c10ee1 Use daemonRepository instead of BuildModificationService (#1053) 2025-03-04 00:48:22 +01:00
MartinOscar
5331c5abfa Use predis as default redis driver (#1054) 2025-03-03 22:47:01 +01:00
Lance Pioch
36a38ab947 Basic two factor auth implementation (#1050)
* Basic two factor auth

* Remove unused import

* Add translation
2025-03-03 15:22:12 -05:00
Lance Pioch
da195fd2fe PHPstan updates (#1047)
* Not found property rule

* Make these “better”

* Day 1

* Day 2

* Day 3

* Dat 4

* Remove disabled check

* Day 4 continued

* Run pint

* Final changes hopefully

* Pint fixes

* Fix again

* Reset these

* Update app/Filament/Admin/Pages/Health.php

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

* Update app/Traits/CheckMigrationsTrait.php

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

---------

Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2025-03-03 14:41:19 -05:00
Lance Pioch
82409f2fba Laravel 12.x Shift (#1045)
* Convert route options to fluent methods

Laravel 8 adopts the tuple syntax for controller actions. Since the old options array is incompatible with this syntax, Shift converted them to use modern, fluent methods.

* Slim `lang` files

* Shift core files

* Validate via object directly within Controllers

* Use `Gate` facade for controller authorization

* Dispatch jobs directly

* Remove base controller inheritance

* Default config files

In an effort to make upgrading the constantly changing config files easier, Shift defaulted them and merged your true customizations - where ENV variables may not be used.

* Set new `ENV` variables

* Add new Laravel `composer run dev` script

* Add `storage/app/private` folder

* Bump Composer dependencies

* Convert `$casts` property to method

* Adopt Laravel type hints

* Shift cleanup

* Apply suggestions from code review

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

* Add old key as backup

* Update composer

* Remove extra line

* Update this

---------

Co-authored-by: Shift <shift@laravelshift.com>
Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2025-03-03 14:41:00 -05:00
MartinOscar
839be53231 Use BuildModificationService on EditServer (#1042)
* Use `BuildModificationService` on `EditServer` & make it throw if we can't reach wings

* Use Node name on `EditServer` & `EditNode`
2025-03-03 19:49:42 +01:00
Charles
d79d461e7c Fix total disk storage (#1040) 2025-03-01 07:30:29 -05:00
Boy132
d8e8240756 Fix EditUser (#1046)
* fix unique when editing user

* unset roles when editing
2025-02-28 13:28:18 +01:00
MartinOscar
0b84b0c08c Make sure tests fails on composer error (#1034)
* Remove `--prefer-dist`

* Add missing args `--no-autoloader` `--no-suggest` `--no-progress` `--no-scripts` `--no-dev`
2025-02-28 02:59:51 +01:00
Lance Pioch
e2045e334f This has been replaced with pint (#1044) 2025-02-27 20:18:09 -05:00
Boy132
5e2d106bb9 Call parent constructor in custom oauth provider classes (#1039) 2025-02-27 17:22:32 +01:00
Charles
40c138f086 Update admin resources (#1038) 2025-02-27 09:28:00 -05:00
Boy132
ab543a399b Fix composer.lock (#1036) 2025-02-27 15:10:21 +01:00
Charles
0308045738 Delete mysql-schema (#1037)
Just build the database from migrations... remove  the requirement for mysql-client on installs
2025-02-27 08:17:17 -05:00
Boy132
cd9cbf20ce Downgrade myclabs/deep-copy back to 1.12.1 (#1033) 2025-02-26 16:27:31 +01:00
Boy132
e1308cb04d Small api docs improvements (#1032)
* update scramble

* cleanup application api endpoints

* cleanup client api endpoints

* fix security schema and make docs homepage nicer

* remove duplicate myclabs/deep-copy

* style(api-docs): use Blade template and Tailwind for styling

* Publish scramble view

* Use localStorage theme instead of config

* Update routes/docs.php

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

---------

Co-authored-by: Quinten <67589015+QuintenQVD0@users.noreply.github.com>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
Co-authored-by: Lance Pioch <git@lance.sh>
2025-02-26 16:12:19 +01:00
Charles
2d937229fb Add Custom StatBlocks, Add Stats (#1027)
* add custom statblock

* add custom datablock

* Use real values, not placeholders

* More Changes

* remove unused var

* Remove old code

* Remove more

* Updates

* Add LineHeight

Changing the font size cut off the j/g and _

* Fix invisible console selection

Closes #874

* Add Missing to `offline` detection

* Use helper

* Update

* Removals

* Move to `SmallStatBlock`
2025-02-26 10:08:42 -05:00
MartinOscar
3d764a89f7 chore: Upgrade Dependencies (#1005)
* chore: yarn upgrade

* chore: composer upgrade

* chore: php artisan filament:upgrade

* chore: update filament-monaco-editor-views

* chore: update filament-monaco-editor-configs

* chore: move turnstile-views to plugins

* fix monaco-editor loader & css
2025-02-25 14:22:07 +01:00
Boy132
2f56ca5ed5 Add deleteAny and replicate to policies (#1030)
* add `deleteAny` to policies

* add `replicate` to policies
2025-02-25 13:50:15 +01:00
Boy132
fe8e6fcfda Fix StoreServerRequest for deployment (#1031) 2025-02-25 13:49:55 +01:00
MartinOscar
1e7a901371 Don't log duplicated OauthProviders during tests (#1015)
* Make sure OauthProviders we only log if not running tests

* Dependency inject
2025-02-24 19:37:41 +01:00
Boy132
d53820bbdc Add view pages for "simple" resources (#963)
* update ApiKeyResource

* update DatabaseHostResource

* update MountResource

* update RoleResource

* update UserResource

* WebhookResource

* fix phpstan

* add back label translations for resources

* add back other labels

* upstream changes
2025-02-24 15:44:47 +01:00
MartinOscar
d03366cf3d Enhance Node health column (#1023)
* Make sure we are talking to a `Pelican Wings` instance

* Enforce matching `token_id`

* Refactor `NodeSystemInformation`
2025-02-22 21:44:49 +01:00
MartinOscar
7d68da41f4 Add HOSTNAME TERM LANG PWD TZ TIMEZONE to Egg RESERVED_ENV_NAMES (#1026) 2025-02-22 21:44:07 +01:00
MartinOscar
599d53b4f2 Fix Node & Server Create/Edit Page (#1019)
* Add missing `dehydrated` on `Node`

* Add missing `dehydrated` on `Server`
2025-02-21 11:55:11 +01:00
Boy132
f0f04fd86a Add backend validation to subuser permissions (#1014)
* add backend validation to subuser permissions

* always allow websocket.connect

* use collection to clean permissions
2025-02-21 11:02:08 +01:00
MartinOscar
324fc4b7d5 Add Egg copy from & ReplicateAction (#1013)
* Add `Egg` `copy from` for Process & Install Script

* Add builtin `ReplicateAction`

* Use `CopyFrom` for less duplicated code

* Hide label & add tooltip to `ReplicateAction`

* use `iconButton()` instead of `hiddenLabel()`

* use `iconButton()` for every Actions

* Use our translation instead

* Copy egg_variables aswell

* remove `get()`

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

---------
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-02-19 19:52:10 +01:00
Quinten
5be4e22a0c Merge pull request #1012 from QuintenQVD0/docker-schedule-health
(docker) supercronic: allow overlapping
2025-02-16 19:32:40 +01:00
Quinten
75aae3e45b supercronic: allow overlapping 2025-02-16 18:44:54 +01:00
MartinOscar
c1704eef3b Interpret Server StartupCommand variables (#1009)
* Use `StartupCommandService`

* Simplify variable name

* Add `PreviewStartupAction`
2025-02-15 17:46:25 -05:00
Quinten
09abec6ee6 fix(docker): enable multi-arch builds (#993)
* fix(docker): enable multi-arch builds

* Remove workflow_dispatch and add missing space

* There is no need for a matrix in the job build-and-push

* Update docker-publish.yml

* Only keep the artifacts for 7 days

* Bump dockerfile labs version to 1.13

* Added a comment in the Dockerfile explaining how to self-build it

* build-php-base cache should not be tagged
2025-02-15 23:32:15 +01:00
David Groselj
206cc76a8b Fix deleted users being shown as "System" in activity log (#1010)
* Show deleted users as "Deleted user"

* Update shown icon

* Apply suggestions from code review

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

* Update app/Models/ActivityLog.php

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

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-02-15 17:43:32 +01:00
MartinOscar
b355830db4 Fix File Upload (#952)
* Log correct file name

* Remove duplicated throws comment

* Set maxSize

* Add hints

* Fix unit conversion

* Add translations
2025-02-14 11:11:52 +01:00
MartinOscar
09375df8a7 Add missing selectablePlaceholder(false) & native(false) Fix 500 (#1008)
* Add missing `selectablePlaceholder(false)`

* Add missing `native(false)`
2025-02-14 11:11:16 +01:00
Boy132
96ec2eb3c2 Small translation fixes (#1006)
* display_name_help: replace location with node

* cpu_helper: 100% is one thread, not core

* remove unnecessary "create_action" translation

* nobody saw anything
2025-02-13 21:50:23 +01:00
MartinOscar
b464bb4d25 Add ignoreRecord: true to Server ExternalId (#1004) 2025-02-13 00:35:20 +01:00
MartinOscar
c561035c75 Fix incorrect Allocation permission in Node's AllocationsRelationManager (#995) 2025-02-12 20:35:55 +01:00
MartinOscar
48d1ef5d26 Add WordWrap to MonacoEditor (#1001) 2025-02-12 20:35:23 +01:00
MartinOscar
1f6b659546 Fix Translations (#994)
* Fix copy paste AllocationsRelationManager

* We shouldn't let the user know if the user is correct but the password isn't

* Add missing `trans()` `EditServer`

* Add missing `trans()` User `ServersRelationManager`

* Replace every `__()` with `trans()` helper

* Fix `exceptions` `User` Model

* Replace `Translator->get()` with `trans()` helper

* Revert "We shouldn't let the user know if the user is correct but the password isn't"

This reverts commit e156ee4b38.
that's stock laravel, therefore it needs to stay
2025-02-11 22:16:48 +01:00
MartinOscar
8f47ccfbf7 Fix Health ScheduleCheck (#999)
* Use `ScheduleCheck` instead of a blank `Check`
2025-02-11 22:11:07 +01:00
MartinOscar
35d25d216e Cleanup OAuth _noenv & enabled providers (#989) 2025-02-11 22:10:27 +01:00
MartinOscar
a6963ad802 Remove Deprecated PHPDoc comment & AuditLog Model (#997)
* Remove missleading deprecation, you cant use can/cannot on apikeys

* Remove unused `AuditLog` Model
2025-02-11 19:25:36 +01:00
Thibault Junin
d48cf6b722 Add Webhook Event header (#996)
* Add Webhook Event header
2025-02-11 13:43:40 +01:00
MartinOscar
cba4cf11aa Fix Admin Area translations (#991)
* Fix button

* Replace array with index

* Fix Server ToggleInstallService

* FiNodeVersionsCheck

* Fix CreateWebhookConfiguration

* Fixdatabasehost post_help > port_help

* Fix User CreateServer

* Fix Profile language_help

* Fix Role permission UserResource

* Remove debug & Pint
2025-02-10 10:28:14 -05:00
MartinOscar
96c09acc52 Fix translation (#990) 2025-02-10 00:06:11 -05:00
Charles
7f697017a7 Fix flipped translation keys (#988) 2025-02-10 00:58:08 +01:00
Charles
f8ad720f52 Admin Area Translations (#965)
* Init

* Health Page

* Admin API Keys

* Update API Keys

* Database Hosts

* Mounts

* remove `s`

* Users

* Webhooks

* Server

never again...

* Fix Server

* Settings

* Update Mounts

* Update Databasehost

* Update Server

* Oops, Update Server

* Nodes

* Update User

* Dashboard

* Update Server

* Profile

* Egg

* Role & Update Egg

* Add base Laravel lang files

* update apikey

* remove html back to settings, remove comment

* add `:resource` to create_action

* Update Egg

* Update Egg v2

* Update 1

* trans cf info label

* Update charts

* more trans

* Update Webhook

* update Health

* Update Server

* Update Role

* Fixes

* Bulk Update

* AnotherOne

* Fix relation button label

* rename `admin1` to `admin`

Leftover from testing... oops

* More Translations

* Updates

* `pint` + Relation Manager Titles
2025-02-08 23:16:54 -05:00
Boy132
513117cc42 Fix event listeners for notifications (#971)
* fix event listeners for notifications

* fix "visit panel" url
2025-02-08 14:32:56 +01:00
MartinOscar
5797b790fd Fix ServerList Filter query (#977) 2025-02-08 12:45:36 +01:00
MartinOscar
9ec2f6eae1 Fix OAuthProvider & Add ColorPicker for Authentik (#975)
* Fix driver name

* Fix AuthentikProvider config & Add ColorPicker

* Add sqlite-journal to .gitignore
2025-02-07 17:28:06 +01:00
MartinOscar
77bf70b063 Add default Egg import url (#972) 2025-02-07 15:38:25 +01:00
MartinOscar
b8c1b68328 Add back TransientToken check (#968) 2025-02-05 12:58:10 +01:00
MartinOscar
431c1977e3 Filter out wings metadata in ListActivities (#961) 2025-02-02 15:07:03 +01:00
Lance Pioch
f8ad9a1805 Use PestPHP (#962)
* Install Pest

* Don’t use bootstrap file anymore

* Fix comment

* Think this is needed

* Reset this

* Switch dataproviders to attributes

* Fix these

* Support in memory databases

* Fix this migration

* Switch this back for now

* Add missing import

* Truncate and reseed database

* These are replaced now

* Switch ci to use pest
2025-01-30 16:39:17 -05:00
Lance Pioch
635cc6a029 Add PHP 8.4 Support (#858)
* Add php 8.4

* Update ide helper

* Add php 8.4

* Update laravel sanctum

* Update laravel framework

* Hash rounds were increased

* This is always false

* Extend model now

* This does nothing

* Move model validation methods to trait

* Remove base model

* Backup routes were previously referenced by uuids

* Remove commented code

* Upgrade laravel/framework

* Fix migration

* Update ide helper

* Update sanctum

* Add version to composer

* Add this back in, fixed

* Make this protected to be safer
2025-01-30 16:39:00 -05:00
Charles
20125dbc6f Add front end badges (#960)
* Add front end badges

* I identify as a `string`

* Display even if there's no limit

* use `const`'s

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-30 06:21:28 -05:00
Boy132
d5b8a4c501 Fix file download link (#959)
* fix mount of DownloadFiles

* fix path in download url
2025-01-29 08:32:51 +01:00
MartinOscar
dde5305b3f Add validation & missing reserved vars to EggVariables (#954)
* Add validation & Add missing reserved vars

* env_var not env_name 🤦‍

* Custom validationMessages
2025-01-28 14:22:03 +01:00
MartinOscar
e352754e6f Fix CopyAction & Add to Server Settings page (#950)
* Fix & Add to Server Settings page

* Add `request()->isSecure()`

CopyAction only works on SSL, no point in showing it when its not SSL

---------

Co-authored-by: notCharles <charles@pelican.dev>
2025-01-27 19:41:57 +01:00
MartinOscar
7cde90a39a Fix schedules (#949)
* Fix schedules

* Only explode when payload isn't a power action

* Run only on first day of the month

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

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-01-27 17:57:17 +01:00
Boy132
3202a59b07 Activity log list improvements (#939)
* handle "server:crashed" log

* update activity log list

* add event filter

* add email to user column

* fix phpstan

* only show the email if the actor is the server owner/ a subuser or if the viewing user is an admin

* Apply same logic from ViewAction & make sure user is admi for url

* Add pagination to avoid showing 2000 records at once

* update can check & pagination

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-27 09:46:39 +01:00
Boy132
71f3abe464 File manager improvements (#936)
* add separate button for "save & close"

* make language selection for editor work

* fix download url

* add info banner for .pelicanignore files

* small cleanup

* fix import

* Move File Lang

* add `ctrl+shift+s` for save & close

* fix keybind

* cleanup and fix default value for edit

* remove unnecessary File::get & trait

* More EditorLanguages not matching their names

* mdx has its own highlighter

---------

Co-authored-by: notCharles <charles@pelican.dev>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-26 14:29:53 +01:00
Alexander Featherson
401026efa1 [Fix] Websocket Tokens Refresh issue (#944)
* - Temporary fix for token refresh issue.

More testing is needed.

* Update server-console.blade.php

Removal of final old token var (no longer needed as livewire will handle it through piping)
2025-01-25 22:29:01 +01:00
MartinOscar
654143addc Fix ServerList Filter badge count (#946) 2025-01-25 22:24:55 +01:00
Scai
37f9725f27 chore: add codeowners (#941) 2025-01-24 21:00:15 +02:00
dependabot[bot]
98c915490d Bump vite from 6.0.7 to 6.0.9 (#940)
* Bump vite from 6.0.7 to 6.0.9

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.0.7 to 6.0.9.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.0.9/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Also bump laravel-vite-plugin

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-24 14:06:00 +02:00
Boy132
6fb54e32f1 Use tabs instead of filter for server list (#937)
* use tabs instead of filter for server list

* move "all servers" to end
2025-01-24 08:29:07 +01:00
Boy132
fef19b9fdd files tooltip for activity logs (#938)
* add files tooltip to activity logs

* fix when "files" isn't an array
2025-01-24 08:28:40 +01:00
Josh
6a4963200c Rootless Docker/Optimized build (#932)
* Rootless Dockerfile/Optimized build

Add unneeded files to .dockerignore
Split Dockerfile into more stages to allow Composer/Yarn to run concurrently
Don't log supervisord to a file, as file logging in a Docker container makes no sense
Redirect process output to container output for log processors
Run all processes as non-root
Minimize files with write permission for non-root user
Move docker folder out of .github, as it has nothing to do with GitHub

* Remove install-php-extensions utility after use and name final stage

* Test arm64 runner

* Allow Docker workflow caching multi-arch separately

* Fix Docker publish workflow branches

* Move Caddyfile/crontab config into docker directory, remove redundant supervisord user

* Further restrict permissions

* Supervisord logs
2025-01-23 11:01:14 +02:00
Boy132
37ba62410f Fix translations for activity logs (#907)
* fix translations for activity logs

* add backwards compatibility for old logs

* update lang file

* small cleanup

* fix singular/ plural for "file"

* fix for "rename" + disable bulk move (because it's not working)
2025-01-23 09:05:23 +01:00
MartinOscar
262e2fd09a Add roles to owner selector on Create/Edit Server page (#935)
* Add roles to owner selector on Create/Edit Server page
2025-01-23 02:47:13 +01:00
Boy132
9e8b9cd599 Update node record after updating (#929)
* refresh node model after updating

* update record so form is correctly filled
2025-01-19 01:28:52 +01:00
Boy132
3411e5e65c NodeStorageChart: Format data after math (#931) 2025-01-19 01:09:54 +01:00
Charles
7e6769c96e Match the owner selection on create server (#927) 2025-01-19 00:21:58 +01:00
Boy132
03eaddb126 Fix server access for admins without subuser (#919)
* fix server access for admins without subuser

* add permission checks to power buttons

* add permission check for console command sending

* fix tests

* fix websocket token permissions

* fix sftp access

* fix server api + small cleanup

* it's "update", not "edit"...

* fix tests

* fix permission const for "activity read"

* fix activity subuser permission
2025-01-17 23:04:22 +01:00
Boy132
61bdf0dcd7 Alert banner improvements: auto-refresh, fixes & "closeable" (#924)
* fix websocket error always displaying

* use livewire component with polling for alert banner container

* add id to alert banner

* cleanup blade file and add "closeable" property
2025-01-17 23:03:34 +01:00
Charles
cbacc18e56 get value of suspended (#922) 2025-01-16 21:18:00 -05:00
Lance Pioch
ad1a9cd33f Update phpstan to latest (#804)
* Fix these

* Update phpstan

* Transform these into their identifiers instead

* Fix custom rule

* License is wrong

* Update these

* Pint fixes

* Fix this

* Consolidate these

* Never supported PHP 7

* Better evaluation

* Fixes

* Don’t need ignore

* Replace trait with service

* Subusers are simply the many to many relationship between Servers and Users

* Adjust to remove ignores

* Use new query builder instead!

* wip

* Update composer

* Quick fixes

* Use realtime facade

* Small fixes

* Convert to static to avoid new

* Update to statics

* Don’t modify protected properties directly

* Run pint

* Change to correct method

* Give up and use the facade

* Make sure this route is available

* Filament hasn’t been loaded yet

* This can be readonly

* Typehint

* These are no longer used

* Quick fixes

* Need doc block help

* Always true

* We use caddy with docker

* Pint

* Fix phpstan issues

* Remove unused import

---------

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-16 14:53:50 -05:00
Quinten
02c4eb19f0 ci: move ARM Docker builds to native ARM runner (#920) 2025-01-16 20:26:31 +02:00
MartinOscar
3a25d0f976 Actually use nodeUpdateService not only for keys (#914)
* Actually use nodeUpdateService not only for keys

* Add behind proxy & ignore panel config updates

* Don't Halt

* Prevent double notification

* Revert "Add behind proxy & ignore panel config updates"

This reverts commit 0147888c6c.
2025-01-16 11:50:08 +01:00
Scai
634b8dec55 Merge pull request #918 from QuintenQVD0/speedup-docker
feat(docker): copy PHP extensions from builder stage to speedup the b…
2025-01-16 10:23:33 +02:00
Quinten
43d0b78742 feat(docker): copy PHP extensions from builder stage to speedup the build
- Reuse compiled PHP extensions from composer stage instead of building them twice
2025-01-16 09:20:54 +01:00
Scai
6b77e69e43 Merge pull request #917 from QuintenQVD0/docker
Fix the docker build
2025-01-16 09:45:36 +02:00
Quinten
efbf4df2a2 Fix the docker build 2025-01-16 08:24:58 +01:00
Boy132
4ec9171017 OAuth improvements (#903)
* rework oauth provider creation & lodaing

* add separate setup form

* use wizard for setup

* add provider class for discord

* cleanup and fixes

* don't throw exception when creating duplicate provider

* update profile and login pages

* did not mean to remove the whole else, oops

* use import
2025-01-15 18:29:06 +01:00
Boy132
885e03ee06 Alert banners (#892)
* add alert banner

* replace old server conflict banner with alert banner

* improve color and icon size

* add alert for websocket errors

* update file loading error to alert banner

* remove old events

* add back `console-status` event

* move @php block under @isset

* remove phpstan ignore

so I'm not getting force choked
2025-01-15 18:23:09 +01:00
MartinOscar
7c6b3a03db Fix Suspendall & Server Condition (#913) 2025-01-15 17:46:27 +01:00
MartinOscar
fe43539ea7 Use temp config for mail testing (#912)
* Use temp config

* Change port when changing encryption

* Pint

* Use finally

* Pint please do your job next time

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

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2025-01-15 16:07:12 +01:00
Charles
e145fcdc56 Use Filament labels. (#906)
* Use Filament labels.

* use `trans`

* Show more files

No reason for this to be its own pr...
2025-01-13 09:31:37 -05:00
Charles
8078f2ca4e Edit Node Listing, Enable Storage Graph (#905)
* Remove limits in listing

* Enable Storage Graph

* Wings gives us bytes, use helper function

* Use Node Model

* Remove `?? 0`

* Re-Add `?? 0` remove local

* Add Locale on chart

* We should convert these too...

convert_bytes_to_readable follows the prefix config, so we should do it here too.

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-13 09:31:31 -05:00
MartinOscar
d1007ad2fe Make sure variables are unique per egg (#902)
* Add unique validation

* Also make their name unique

* Custom message
2025-01-10 22:22:47 +01:00
Boy132
7f3b1fd758 Fix server reinstall action (#901)
* fix server reinstall action

* use reinstall service
2025-01-09 23:25:36 +01:00
Josh
d088e79e5e Fix deleting database host when it has assigned nodes (#899)
* Cascade delete from database_host_node when the database host is deleted

* Update database/migrations/2025_01_09_143607_database_host_node_foreign_delete_cascade.php

Remove migration rollback

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

* Update 2025_01_09_143607_database_host_node_foreign_delete_cascade.php

Fix brace position

---------

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-09 20:21:44 +01:00
Boy132
9cfd87090f Update health page with tailwind classes (#893)
* update health page with tailwind classes

* Move php from Blade to Page

---------

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-01-09 08:25:10 +01:00
MartinOscar
a7a7c5ba4d Fix Latest version error (#890)
* Retry if it fails

* Pint
2025-01-08 13:48:36 +01:00
Charles
b14e8fd724 Update colors (#891) 2025-01-07 21:11:05 -05:00
Boy132
c93a836ad8 Remove DaemonConnectionException (#885)
* remove DaemonConnectionException

* update tests
2025-01-07 22:58:04 +01:00
Boy132
6fcf4173d3 Strip http/ https from steam oauth allowed_host (#889)
* strip http/ https from steam oauth allowed_host

* fix param order
2025-01-07 22:47:23 +01:00
Boy132
7449b82f41 adjust path for server panel (#884) 2025-01-07 09:34:13 +01:00
Boy132
af4ac1db92 Update admin area navigation (#881) 2025-01-07 08:24:43 +01:00
Scai
6707d1ccf6 Merge pull request #880 from pelican-dev/feature/vite
Remove old client area and switch to vite
2025-01-07 02:06:27 +02:00
Lance Pioch
b197e73173 Use route instead 2025-01-06 17:35:05 -05:00
Scai
e5418491c8 chore: lint files 2025-01-06 20:08:32 +02:00
Scai
98ebc75965 fix: wrong class used on auth 2025-01-06 20:06:17 +02:00
Scai
121ebe6017 refactor: move assets to service provider 2025-01-06 20:03:10 +02:00
Scai
fc27b24783 fix: remove path on panel default 2025-01-06 20:01:41 +02:00
Scai
8049ef462e refactor: revert oauth routes 2025-01-06 20:01:29 +02:00
Scai
17bb23b5b8 refactor: route redirect links 2025-01-06 19:58:32 +02:00
MartinOscar
8926f9712f Add back denylist (#872) 2025-01-06 16:54:19 +01:00
Scai
e4849d89d7 refactor: replace old index with new filament app 2025-01-06 17:33:32 +02:00
Scai
af11888b82 chore: lint files 2025-01-06 17:15:53 +02:00
Scai
1845f2955f fix: job workflows for releasing 2025-01-06 17:15:44 +02:00
Scai
a2b315ba74 fix: build workflows #1 try 2025-01-06 17:13:06 +02:00
Scai
76c3632d14 chore: update git workflows 2025-01-06 17:06:58 +02:00
Scai
4facaecea0 feat: register assets js/css 2025-01-06 17:04:43 +02:00
Scai
a55a2cce6e feat: impl vite tailwindcss 2025-01-06 17:04:33 +02:00
Boy132
448fe41e78 Add role permission for health page (#878) 2025-01-06 15:43:29 +01:00
Boy132
7f37b3b099 Fix namespace for role permission icons (#877) 2025-01-06 15:42:47 +01:00
Scai
ef54d52866 refactor: remove old provider 2025-01-06 15:49:45 +02:00
Scai
7bd66c3d85 refactor: unused files 2025-01-06 15:48:50 +02:00
Scai
74efc6e8c1 refactor: redirect to new login page 2025-01-06 15:47:16 +02:00
Scai
a7b767ae78 chore: delete old assets 2025-01-06 15:46:54 +02:00
Scai
a3ecf3994b feat: set filament main client ui 2025-01-06 15:46:43 +02:00
Scai
158fa24fff feat: add logo to filament 2025-01-06 15:46:26 +02:00
Scai
e5069e754d chore: unused files & code related to old auth 2025-01-06 15:42:49 +02:00
Scai
cdd46de274 chore: clean base routes 2025-01-06 15:38:44 +02:00
Scai
ff5812e87b chore: remove old auth 2025-01-06 15:38:04 +02:00
Scai
20ce0ca8e6 chore: purge old configs 2025-01-06 15:22:41 +02:00
Scai
66ec86694f chore: delete old client ui 2025-01-06 15:20:20 +02:00
Boy132
295134fb6c Add client_id to steam oauth config (#875) 2025-01-06 12:32:35 +01:00
MartinOscar
ae445840f7 Discard ipAddresses cache if wings is offline + Switch to Select (#862)
* Change TextInputColumn to SelectColumn

* Discard cache if wings is offline

* Return 0.0.0.0 instead of an empty array

* Adjustment & remove dns resolve
2025-01-06 03:37:39 +01:00
MartinOscar
77fd54fdc2 Fix/suspend server offline node (#871)
* Use handle instead of toggle & use const isnstead of string

* Avoid rollback if node is unreachable

* Use Enum & remove default action

* Remove useless test
2025-01-06 03:07:06 +01:00
MartinOscar
18fe4f1123 Show suspended servers (#870) 2025-01-06 01:48:04 +01:00
Charles
2525af8f02 Revert "Listen to more framework webhook events (#728)" (#866)
This reverts commit 7a4c4ce02a.
2025-01-05 19:07:01 -05:00
1361 changed files with 14169 additions and 57162 deletions

View File

@@ -1,10 +1,29 @@
**.DS_Store
.env
.devcontainer
.dockerignore
.editorconfig
.git
node_modules
vendor
.github
**.gitignore
.php-cs-fixer.dist.php
.prettierrc.json
.vscode
Dockerfile
bounties.md
compose.yml
contributing.md
contributor_license_agreement.md
database/database.sqlite
docker/README.md
node_modules
phpstan.neon
phpunit.xml
readme.md
storage/debugbar/*.json
storage/logs/*.log
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/testing
storage/framework/views/*.php
storage/logs/*.log
vendor

View File

@@ -1,6 +0,0 @@
public
node_modules
resources/views
babel.config.js
tailwind.config.js
webpack.config.js

View File

@@ -1,52 +0,0 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 6,
ecmaFeatures: {
jsx: true,
},
project: './tsconfig.json',
tsconfigRootDir: './',
},
settings: {
react: {
pragma: 'React',
version: 'detect',
},
linkComponents: [
{ name: 'Link', linkAttribute: 'to' },
{ name: 'NavLink', linkAttribute: 'to' },
],
},
env: {
browser: true,
es6: true,
},
plugins: ['react', 'react-hooks', 'prettier', '@typescript-eslint'],
extends: [
// 'standard',
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:jest-dom/recommended',
],
rules: {
eqeqeq: 'error',
'prettier/prettier': ['error', {}, { usePrettierrc: true }],
// TypeScript can infer this significantly better than eslint ever can.
'react/prop-types': 0,
'react/display-name': 0,
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/no-non-null-assertion': 0,
// 'react/no-unknown-property': ['error', { ignore: ['css'] }],
// This setup is required to avoid a spam of errors when running eslint about React being
// used before it is defined.
//
// @see https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
'no-use-before-define': 0,
'@typescript-eslint/no-use-before-define': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-expect-error': 'allow-with-description' }],
},
};

15
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,15 @@
# 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

@@ -1,75 +0,0 @@
# If using Ubuntu this file should be placed in:
# /etc/nginx/sites-available/
#
# If using CentOS this file should be placed in:
# /etc/nginx/conf.d/
#
# The MIT License (MIT)
#
# Pterodactyl®
# Copyright © Dane Everitt <dane@daneeveritt.com> and contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
server {
listen 80;
server_name _;
root /app/public;
index index.html index.htm index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
access_log off;
error_log /var/log/nginx/panel.app-error.log error;
# allow larger file uploads and longer script runtimes
client_max_body_size 100m;
client_body_timeout 120s;
sendfile off;
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# the fastcgi_pass path needs to be changed accordingly when using CentOS
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param PHP_VALUE "upload_max_filesize = 100M \n post_max_size=100M";
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";
fastcgi_intercept_errors off;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
}
location ~ /\.ht {
deny all;
}
}

View File

@@ -1,70 +0,0 @@
# If using Ubuntu this file should be placed in:
# /etc/nginx/sites-available/
#
server {
listen 80;
server_name <domain>;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name <domain>;
root /app/public;
index index.php;
access_log /var/log/nginx/panel.app-access.log;
error_log /var/log/nginx/panel.app-error.log error;
# allow larger file uploads and longer script runtimes
client_max_body_size 100m;
client_body_timeout 120s;
sendfile off;
# strengthen ssl security
ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
# See the link below for more SSL information:
# https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
#
# ssl_dhparam /etc/ssl/certs/dhparam.pem;
# Add headers to serve security related headers
add_header Strict-Transport-Security "max-age=15768000; preload;";
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header X-Robots-Tag none;
add_header Content-Security-Policy "frame-ancestors 'self'";
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param PHP_VALUE "upload_max_filesize = 100M \n post_max_size=100M";
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY "";
fastcgi_intercept_errors off;
fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k;
fastcgi_connect_timeout 300;
fastcgi_send_timeout 300;
fastcgi_read_timeout 300;
include /etc/nginx/fastcgi_params;
}
location ~ /\.ht {
deny all;
}
}

View File

@@ -1,16 +0,0 @@
[www]
user = nginx
group = nginx
listen = 127.0.0.1:9000
listen.owner = nginx
listen.group = nginx
listen.mode = 0750
pm = ondemand
pm.max_children = 9
pm.process_idle_timeout = 10s
pm.max_requests = 200
clear_env = no

View File

@@ -3,10 +3,8 @@ name: Build
on:
push:
branches:
- '**'
- main
pull_request:
branches:
- '**'
jobs:
ui:
@@ -20,14 +18,25 @@ jobs:
- name: Code Checkout
uses: actions/checkout@v4
- 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 PHP dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-autoloader --no-scripts --no-dev
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
- name: Install JS dependencies
run: yarn install --frozen-lockfile
- name: Build
run: yarn build:production
run: yarn build

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3]
php: [8.2, 8.3, 8.4]
database: ["mysql:8"]
services:
database:
@@ -66,16 +66,16 @@ jobs:
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --prefer-dist
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Unit tests
run: vendor/bin/phpunit tests/Unit
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/phpunit tests/Integration
run: vendor/bin/pest tests/Integration
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
@@ -86,7 +86,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3]
php: [8.2, 8.3, 8.4]
database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"]
services:
database:
@@ -139,16 +139,16 @@ jobs:
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --prefer-dist
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Unit tests
run: vendor/bin/phpunit tests/Unit
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/phpunit tests/Integration
run: vendor/bin/pest tests/Integration
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
@@ -159,7 +159,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3]
php: [8.2, 8.3, 8.4]
env:
APP_ENV: testing
APP_DEBUG: "false"
@@ -200,16 +200,16 @@ jobs:
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --prefer-dist
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Create SQLite file
run: touch database/testing.sqlite
- name: Unit tests
run: vendor/bin/phpunit tests/Unit
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/phpunit tests/Integration
run: vendor/bin/pest tests/Integration

View File

@@ -1,6 +1,5 @@
name: Docker
on:
push:
branches:
@@ -14,18 +13,73 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
build-php-base:
name: Build PHP base image on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
arch: amd64
platform: linux/amd64
- os: ubuntu-24.04-arm
arch: arm64
platform: linux/arm64
steps:
- name: Code checkout
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Build the base PHP image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.base
push: false
load: true
platforms: ${{ matrix.platform }}
tags: base-php:${{ matrix.arch }}
cache-from: type=gha,scope=base-php${{ matrix.arch }}
cache-to: type=gha,scope=base-php${{ matrix.arch }}
- name: Export image to file
run: docker save -o base-php-${{ matrix.arch }}.tar base-php:${{ matrix.arch }}
- name: Push the docker build to the artifacts
uses: actions/upload-artifact@v4
with:
name: base-php-${{ matrix.arch }}.tar
path: base-php-${{ matrix.arch }}.tar
retention-days: 7
build-and-push:
name: Build and Push ubuntu-24.04
runs-on: ubuntu-24.04
needs: build-php-base
permissions:
contents: read
packages: write
strategy:
fail-fast: false
# Start a temp local registry because workflow can not pull from localy loaded images
services:
registry:
image: registry:2
ports:
- 5000:5000
# Always run against a tag, even if the commit into the tag has [docker skip] within the commit message.
if: "!contains(github.ref, 'main') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))"
steps:
- name: Code checkout
uses: actions/checkout@v4
- name: Docker metadata
id: docker_meta
uses: docker/metadata-action@v5
@@ -38,11 +92,14 @@ jobs:
type=ref,event=tag
type=ref,event=branch
- name: Setup QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# We Need to start it in host mode else it can't acces the local registry on port 5000
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -57,30 +114,52 @@ jobs:
echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
# Download the base PHP image AMD64
- uses: actions/download-artifact@v4
with:
name: base-php-amd64.tar
# Download the base PHP image ARM64
- uses: actions/download-artifact@v4
with:
name: base-php-arm64.tar
- name: Load base images into local registry
run: |
docker load -i base-php-amd64.tar
docker load -i base-php-arm64.tar
docker tag base-php:amd64 localhost:5000/base-php:amd64
docker tag base-php:arm64 localhost:5000/base-php:arm64
docker push localhost:5000/base-php:amd64
docker push localhost:5000/base-php:arm64
rm base-php-arm64.tar base-php-amd64.tar
- name: Build and Push (tag)
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
if: "github.event_name == 'release' && github.event.action == 'published'"
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
platforms: 'linux/amd64,linux/arm64'
build-args: |
VERSION=${{ steps.build_info.outputs.version_tag }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha,scope=tagged${{ matrix.os }}
cache-to: type=gha,scope=tagged${{ matrix.os }},mode=max
- name: Build and Push (main)
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
if: "github.event_name == 'push' && contains(github.ref, 'main')"
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
platforms: 'linux/amd64,linux/arm64'
build-args: |
VERSION=dev-${{ steps.build_info.outputs.short_sha }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.os }}
cache-to: type=gha,scope=${{ matrix.os }},mode=max

View File

@@ -25,21 +25,38 @@ jobs:
run: cp .env.example .env
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
run: composer install --no-interaction --no-suggest --no-progress --no-autoloader --no-scripts
- name: Pint
run: vendor/bin/pint --test
phpstan:
name: PHPStan
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
steps:
- name: Code Checkout
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-${{ matrix.php }}-
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
php-version: ${{ matrix.php }}
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
@@ -48,7 +65,7 @@ jobs:
run: cp .env.example .env
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
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

View File

@@ -11,22 +11,33 @@ jobs:
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Code checkout
uses: actions/checkout@v4
- 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 PHP dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-autoloader --no-scripts --no-dev
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
- name: Install dependencies
- name: Install JS dependencies
run: yarn install --frozen-lockfile
- name: Build
run: yarn build:production
run: yarn build
- name: Create release branch and bump version
env:
@@ -44,8 +55,8 @@ jobs:
- name: Create release archive
run: |
rm -rf node_modules tests CODE_OF_CONDUCT.md CONTRIBUTING.md flake.lock flake.nix phpunit.xml shell.nix
tar -czf panel.tar.gz * .editorconfig .env.example .eslintignore .eslintrc.js .gitignore .prettierrc.json
rm -rf node_modules vendor tests CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.xml shell.nix
tar -czf panel.tar.gz * .env.example
- name: Create checksum
run: |

3
.gitignore vendored
View File

@@ -4,6 +4,7 @@
/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/clockwork/*
/vendor
*.DS_Store*
@@ -19,10 +20,12 @@ npm-debug.log
yarn-error.log
/.fleet
/.idea
/.nova
/.vscode
public/assets/manifest.json
/database/*.sqlite
/database/*.sqlite-journal
filament-monaco-editor/
_ide_helper*
/.phpstorm.meta.php

View File

@@ -1,52 +0,0 @@
<?php
use PhpCsFixer\Config;
use PhpCsFixer\Finder;
$finder = (new Finder())
->in(__DIR__)
->exclude([
'vendor',
'node_modules',
'storage',
'bootstrap/cache',
])
->notName(['_ide_helper*']);
return (new Config())
->setRiskyAllowed(true)
->setFinder($finder)
->setRules([
'@Symfony' => true,
'@PSR1' => true,
'@PSR2' => true,
'@PSR12' => true,
'align_multiline_comment' => ['comment_type' => 'phpdocs_like'],
'combine_consecutive_unsets' => true,
'concat_space' => ['spacing' => 'one'],
'heredoc_to_nowdoc' => true,
'no_alias_functions' => true,
'no_unreachable_default_argument_value' => true,
'no_useless_return' => true,
'ordered_imports' => [
'sort_algorithm' => 'length',
],
'phpdoc_align' => [
'align' => 'left',
'tags' => [
'param',
'property',
'return',
'throws',
'type',
'var',
],
],
'random_api_migration' => true,
'ternary_to_null_coalescing' => true,
'yoda_style' => [
'equal' => false,
'identical' => false,
'less_and_greater' => false,
],
]);

View File

@@ -1,52 +1,103 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Production Dockerfile
FROM node:20-alpine AS yarn
#FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
# 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
# ================================
# Stage 1-1: Composer Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH localhost:5000/base-php:$TARGETARCH AS composer
WORKDIR /build
COPY . ./
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 \
&& yarn run build:production
&& yarn install --frozen-lockfile
FROM php:8.3-fpm-alpine
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
# ================================
# Stage 2-1: Composer Optimize
# ================================
FROM --platform=$TARGETOS/$TARGETARCH composer AS composerbuild
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# 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 localhost:5000/base-php:$TARGETARCH AS final
WORKDIR /var/www/html
# Install dependencies
# Install additional required libraries
RUN apk update && apk add --no-cache \
libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev icu-dev \
zip unzip curl \
caddy ca-certificates supervisor \
&& docker-php-ext-install bcmath gd intl zip opcache pcntl posix pdo_mysql
caddy ca-certificates supervisor supercronic
# Copy the Caddyfile to the container
COPY Caddyfile /etc/caddy/Caddyfile
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
# Copy the application code to the container
COPY . .
# 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 {} \; \
# Symlink to env/database path, as www-data won't be able to write to webroot
&& 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 \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord
COPY --from=yarn /build/public/assets ./public/assets
# 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
RUN touch .env
RUN composer install --no-dev --optimize-autoloader
# Set file permissions
RUN chmod -R 755 storage bootstrap/cache \
&& chown -R www-data:www-data ./
# Add scheduler to cron
RUN echo "* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1" | crontab -u www-data -
## supervisord config and log dir
RUN cp .github/docker/supervisord.conf /etc/supervisord.conf && \
mkdir /var/log/supervisord/
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
@@ -55,5 +106,7 @@ EXPOSE 80 443
VOLUME /pelican-data
ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ]
USER www-data
ENTRYPOINT [ "/bin/ash", "docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

10
Dockerfile.base Normal file
View File

@@ -0,0 +1,10 @@
# ================================
# Stage 0: Build PHP Base Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-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 rm /usr/local/bin/install-php-extensions

58
app/Checks/CacheCheck.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace App\Checks;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
class CacheCheck extends Check
{
protected ?string $driver = null;
public function driver(string $driver): self
{
$this->driver = $driver;
return $this;
}
public function run(): Result
{
$driver = $this->driver ?? $this->defaultDriver();
$result = Result::make()->meta([
'driver' => $driver,
]);
try {
return $this->canWriteValuesToCache($driver)
? $result->ok(trans('admin/health.results.cache.ok'))
: $result->failed(trans('admin/health.results.cache.failed_retrieve'));
} catch (Exception $exception) {
return $result->failed(trans('admin/health.results.cache.failed', ['error' => $exception->getMessage()]));
}
}
protected function defaultDriver(): ?string
{
return config('cache.default', 'file');
}
protected function canWriteValuesToCache(?string $driver): bool
{
$expectedValue = Str::random(5);
$cacheName = "laravel-health:check-{$expectedValue}";
Cache::driver($driver)->put($cacheName, $expectedValue, 10);
$actualValue = Cache::driver($driver)->get($cacheName);
Cache::driver($driver)->forget($cacheName);
return $actualValue === $expectedValue;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Checks;
use Exception;
use Illuminate\Support\Facades\DB;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
class DatabaseCheck extends Check
{
protected ?string $connectionName = null;
public function connectionName(string $connectionName): self
{
$this->connectionName = $connectionName;
return $this;
}
public function run(): Result
{
$connectionName = $this->connectionName ?? $this->getDefaultConnectionName();
$result = Result::make()->meta([
'connection_name' => $connectionName,
]);
try {
DB::connection($connectionName)->getPdo();
return $result->ok(trans('admin/health.results.database.ok'));
} catch (Exception $exception) {
return $result->failed(trans('admin/health.results.database.failed', ['error' => $exception->getMessage()]));
}
}
protected function getDefaultConnectionName(): string
{
return config('database.default');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Checks;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
use function config;
class DebugModeCheck extends Check
{
protected bool $expected = false;
public function expectedToBe(bool $bool): self
{
$this->expected = $bool;
return $this;
}
public function run(): Result
{
$actual = config('app.debug');
$result = Result::make()
->meta([
'actual' => $actual,
'expected' => $this->expected,
])
->shortSummary($this->convertToWord($actual));
return $this->expected === $actual
? $result->ok()
: $result->failed(trans('admin/health.results.debugmode.failed', [
'actual' => $this->convertToWord($actual),
'expected' => $this->convertToWord($this->expected),
]));
}
protected function convertToWord(bool $boolean): string
{
return $boolean ? 'true' : 'false';
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Checks;
use Illuminate\Support\Facades\App;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
class EnvironmentCheck extends Check
{
protected string $expectedEnvironment = 'production';
public function expectEnvironment(string $expectedEnvironment): self
{
$this->expectedEnvironment = $expectedEnvironment;
return $this;
}
public function run(): Result
{
$actualEnvironment = (string) App::environment();
$result = Result::make()
->meta([
'actual' => $actualEnvironment,
'expected' => $this->expectedEnvironment,
])
->shortSummary($actualEnvironment);
return $this->expectedEnvironment === $actualEnvironment
? $result->ok(trans('admin/health.results.environment.ok'))
: $result->failed(trans('admin/health.results.environment.failed', [
'actual' => $actualEnvironment,
'expected' => $this->expectedEnvironment,
]));
}
}

View File

@@ -17,7 +17,9 @@ class NodeVersionsCheck extends Check
$all = Node::query()->count();
if ($all === 0) {
$result = Result::make()->notificationMessage('No Nodes created')->shortSummary('No Nodes');
$result = Result::make()
->notificationMessage(trans('admin/health.results.nodeversions.no_nodes_created'))
->shortSummary(trans('admin/health.results.nodeversions.no_nodes'));
$result->status = Status::skipped();
return $result;
@@ -34,10 +36,10 @@ class NodeVersionsCheck extends Check
'all' => $all,
'outdated' => $outdated,
])
->shortSummary($outdated === 0 ? 'All up-to-date' : "{$outdated}/{$all} outdated");
->shortSummary($outdated === 0 ? trans('admin/health.results.nodeversions.all_up_to_date') : trans('admin/health.results.nodeversions.outdated', ['outdated' => $outdated, 'all' => $all]));
return $outdated === 0
? $result->ok('All Nodes are up-to-date.')
: $result->failed(':outdated/:all Nodes are outdated.');
? $result->ok(trans('admin/health.results.nodeversions.ok'))
: $result->failed(trans('admin/health.results.nodeversions.failed', ['outdated' => $outdated, 'all' => $all]));
}
}

View File

@@ -22,10 +22,13 @@ class PanelVersionCheck extends Check
'currentVersion' => $currentVersion,
'latestVersion' => $latestVersion,
])
->shortSummary($isLatest ? 'up-to-date' : 'outdated');
->shortSummary($isLatest ? trans('admin/health.results.panelversion.up_to_date') : trans('admin/health.results.panelversion.outdated'));
return $isLatest
? $result->ok('Panel is up-to-date.')
: $result->failed('Installed version is `:currentVersion` but latest is `:latestVersion`.');
? $result->ok(trans('admin/health.results.panelversion.ok'))
: $result->failed(trans('admin/health.results.panelversion.failed', [
'currentVersion' => $currentVersion,
'latestVersion' => $latestVersion,
]));
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Checks;
use Carbon\Carbon;
use Composer\InstalledVersions;
use Spatie\Health\Checks\Checks\ScheduleCheck as BaseCheck;
use Spatie\Health\Checks\Result;
class ScheduleCheck extends BaseCheck
{
public function run(): Result
{
$result = Result::make()->ok(trans('admin/health.results.schedule.ok'));
$lastHeartbeatTimestamp = cache()->store($this->cacheStoreName)->get($this->cacheKey);
if (!$lastHeartbeatTimestamp) {
return $result->failed(trans('admin/health.results.schedule.failed_not_ran'));
}
$latestHeartbeatAt = Carbon::createFromTimestamp($lastHeartbeatTimestamp);
$carbonVersion = InstalledVersions::getVersion('nesbot/carbon');
$minutesAgo = $latestHeartbeatAt->diffInMinutes();
if (version_compare($carbonVersion,
'3.0.0', '<')) {
$minutesAgo += 1;
}
if ($minutesAgo > $this->heartbeatMaxAgeInMinutes) {
return $result->failed(trans('admin/health.results.schedule.failed_last_ran', [
'time' => $minutesAgo,
]));
}
return $result;
}
}

View File

@@ -16,28 +16,37 @@ class CheckEggUpdatesCommand extends Command
$eggs = Egg::all();
foreach ($eggs as $egg) {
try {
if (is_null($egg->update_url)) {
$this->comment("{$egg->name}: Skipping (no update url set)");
continue;
}
$currentJson = json_decode($exporterService->handle($egg->id));
unset($currentJson->exported_at);
$updatedJson = json_decode(file_get_contents($egg->update_url));
unset($updatedJson->exported_at);
if (md5(json_encode($currentJson)) === md5(json_encode($updatedJson))) {
$this->info("{$egg->name}: Up-to-date");
cache()->put("eggs.{$egg->uuid}.update", false, now()->addHour());
} else {
$this->warn("{$egg->name}: Found update");
cache()->put("eggs.{$egg->uuid}.update", true, now()->addHour());
}
$this->check($egg, $exporterService);
} catch (Exception $exception) {
$this->error("{$egg->name}: Error ({$exception->getMessage()})");
}
}
}
private function check(Egg $egg, EggExporterService $exporterService): void
{
if (is_null($egg->update_url)) {
$this->comment("$egg->name: Skipping (no update url set)");
return;
}
$currentJson = json_decode($exporterService->handle($egg->id));
unset($currentJson->exported_at);
$updatedEgg = file_get_contents($egg->update_url);
assert($updatedEgg !== false);
$updatedJson = json_decode($updatedEgg);
unset($updatedJson->exported_at);
if (md5(json_encode($currentJson, JSON_THROW_ON_ERROR)) === md5(json_encode($updatedJson, JSON_THROW_ON_ERROR))) {
$this->info("$egg->name: Up-to-date");
cache()->put("eggs.$egg->uuid.update", false, now()->addHour());
return;
}
$this->warn("$egg->name: Found update");
cache()->put("eggs.$egg->uuid.update", true, now()->addHour());
}
}

View File

@@ -27,8 +27,6 @@ class CacheSettingsCommand extends Command
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* CacheSettingsCommand constructor.
*/

View File

@@ -27,6 +27,7 @@ class DatabaseSettingsCommand extends Command
{--username= : Username to use when connecting to the MySQL/ MariaDB server.}
{--password= : Password to use for the MySQL/ MariaDB database.}';
/** @var array<array-key, mixed> */
protected array $variables = [];
/**
@@ -57,7 +58,7 @@ class DatabaseSettingsCommand extends Command
);
if ($this->variables['DB_CONNECTION'] === 'mysql') {
$this->output->note(__('commands.database_settings.DB_HOST_note'));
$this->output->note(trans('commands.database_settings.DB_HOST_note'));
$this->variables['DB_HOST'] = $this->option('host') ?? $this->ask(
'Database Host',
config('database.connections.mysql.host', '127.0.0.1')
@@ -73,7 +74,7 @@ class DatabaseSettingsCommand extends Command
config('database.connections.mysql.database', 'panel')
);
$this->output->note(__('commands.database_settings.DB_USERNAME_note'));
$this->output->note(trans('commands.database_settings.DB_USERNAME_note'));
$this->variables['DB_USERNAME'] = $this->option('username') ?? $this->ask(
'Database Username',
config('database.connections.mysql.username', 'pelican')
@@ -82,7 +83,7 @@ class DatabaseSettingsCommand extends Command
$askForMySQLPassword = true;
if (!empty(config('database.connections.mysql.password')) && $this->input->isInteractive()) {
$this->variables['DB_PASSWORD'] = config('database.connections.mysql.password');
$askForMySQLPassword = $this->confirm(__('commands.database_settings.DB_PASSWORD_note'));
$askForMySQLPassword = $this->confirm(trans('commands.database_settings.DB_PASSWORD_note'));
}
if ($askForMySQLPassword) {
@@ -106,9 +107,9 @@ class DatabaseSettingsCommand extends Command
$this->database->connection('_panel_command_test')->getPdo();
} catch (\PDOException $exception) {
$this->output->error(sprintf('Unable to connect to the MySQL server using the provided credentials. The error returned was "%s".', $exception->getMessage()));
$this->output->error(__('commands.database_settings.DB_error_2'));
$this->output->error(trans('commands.database_settings.DB_error_2'));
if ($this->confirm(__('commands.database_settings.go_back'))) {
if ($this->confirm(trans('commands.database_settings.go_back'))) {
$this->database->disconnect('_panel_command_test');
return $this->handle();
@@ -117,7 +118,7 @@ class DatabaseSettingsCommand extends Command
return 1;
}
} elseif ($this->variables['DB_CONNECTION'] === 'mariadb') {
$this->output->note(__('commands.database_settings.DB_HOST_note'));
$this->output->note(trans('commands.database_settings.DB_HOST_note'));
$this->variables['DB_HOST'] = $this->option('host') ?? $this->ask(
'Database Host',
config('database.connections.mariadb.host', '127.0.0.1')
@@ -133,7 +134,7 @@ class DatabaseSettingsCommand extends Command
config('database.connections.mariadb.database', 'panel')
);
$this->output->note(__('commands.database_settings.DB_USERNAME_note'));
$this->output->note(trans('commands.database_settings.DB_USERNAME_note'));
$this->variables['DB_USERNAME'] = $this->option('username') ?? $this->ask(
'Database Username',
config('database.connections.mariadb.username', 'pelican')
@@ -142,7 +143,7 @@ class DatabaseSettingsCommand extends Command
$askForMariaDBPassword = true;
if (!empty(config('database.connections.mariadb.password')) && $this->input->isInteractive()) {
$this->variables['DB_PASSWORD'] = config('database.connections.mariadb.password');
$askForMariaDBPassword = $this->confirm(__('commands.database_settings.DB_PASSWORD_note'));
$askForMariaDBPassword = $this->confirm(trans('commands.database_settings.DB_PASSWORD_note'));
}
if ($askForMariaDBPassword) {
@@ -166,9 +167,9 @@ class DatabaseSettingsCommand extends Command
$this->database->connection('_panel_command_test')->getPdo();
} catch (\PDOException $exception) {
$this->output->error(sprintf('Unable to connect to the MariaDB server using the provided credentials. The error returned was "%s".', $exception->getMessage()));
$this->output->error(__('commands.database_settings.DB_error_2'));
$this->output->error(trans('commands.database_settings.DB_error_2'));
if ($this->confirm(__('commands.database_settings.go_back'))) {
if ($this->confirm(trans('commands.database_settings.go_back'))) {
$this->database->disconnect('_panel_command_test');
return $this->handle();
@@ -179,7 +180,7 @@ class DatabaseSettingsCommand extends Command
} elseif ($this->variables['DB_CONNECTION'] === 'sqlite') {
$this->variables['DB_DATABASE'] = $this->option('database') ?? $this->ask(
'Database Path',
env('DB_DATABASE', 'database.sqlite')
(string) env('DB_DATABASE', 'database.sqlite')
);
}

View File

@@ -22,6 +22,7 @@ class EmailSettingsCommand extends Command
{--username=}
{--password=}';
/** @var array<array-key, mixed> */
protected array $variables = [];
/**
@@ -91,7 +92,7 @@ class EmailSettingsCommand extends Command
trans('command/messages.environment.mail.ask_smtp_password')
);
$this->variables['MAIL_ENCRYPTION'] = $this->option('encryption') ?? $this->choice(
$this->variables['MAIL_SCHEME'] = $this->option('encryption') ?? $this->choice(
trans('command/messages.environment.mail.ask_encryption'),
['tls' => 'TLS', 'ssl' => 'SSL', '' => 'None'],
config('mail.mailers.smtp.encryption', 'tls')

View File

@@ -27,8 +27,6 @@ class QueueSettingsCommand extends Command
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* QueueSettingsCommand constructor.
*/

View File

@@ -21,7 +21,7 @@ class QueueWorkerServiceCommand extends Command
$serviceName = $this->option('service-name') ?? $this->ask('Queue worker service name', 'pelican-queue');
$path = '/etc/systemd/system/' . $serviceName . '.service';
$fileExists = file_exists($path);
$fileExists = @file_exists($path);
if ($fileExists && !$this->option('overwrite') && !$this->confirm('The service file already exists. Do you want to overwrite it?')) {
$this->line('Creation of queue worker service file aborted because service file already exists.');

View File

@@ -20,8 +20,6 @@ class RedisSetupCommand extends Command
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* RedisSetupCommand constructor.
*/

View File

@@ -28,8 +28,6 @@ class SessionSettingsCommand extends Command
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* SessionSettingsCommand constructor.
*/

View File

@@ -45,31 +45,31 @@ class MakeNodeCommand extends Command
*/
public function handle(): void
{
$data['name'] = $this->option('name') ?? $this->ask(__('commands.make_node.name'));
$data['description'] = $this->option('description') ?? $this->ask(__('commands.make_node.description'));
$data['name'] = $this->option('name') ?? $this->ask(trans('commands.make_node.name'));
$data['description'] = $this->option('description') ?? $this->ask(trans('commands.make_node.description'));
$data['scheme'] = $this->option('scheme') ?? $this->anticipate(
__('commands.make_node.scheme'),
trans('commands.make_node.scheme'),
['https', 'http'],
'https'
);
$data['fqdn'] = $this->option('fqdn') ?? $this->ask(__('commands.make_node.fqdn'));
$data['public'] = $this->option('public') ?? $this->confirm(__('commands.make_node.public'), true);
$data['behind_proxy'] = $this->option('proxy') ?? $this->confirm(__('commands.make_node.behind_proxy'));
$data['maintenance_mode'] = $this->option('maintenance') ?? $this->confirm(__('commands.make_node.maintenance_mode'));
$data['memory'] = $this->option('maxMemory') ?? $this->ask(__('commands.make_node.memory'), '0');
$data['memory_overallocate'] = $this->option('overallocateMemory') ?? $this->ask(__('commands.make_node.memory_overallocate'), '-1');
$data['disk'] = $this->option('maxDisk') ?? $this->ask(__('commands.make_node.disk'), '0');
$data['disk_overallocate'] = $this->option('overallocateDisk') ?? $this->ask(__('commands.make_node.disk_overallocate'), '-1');
$data['cpu'] = $this->option('maxCpu') ?? $this->ask(__('commands.make_node.cpu'), '0');
$data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(__('commands.make_node.cpu_overallocate'), '-1');
$data['upload_size'] = $this->option('uploadSize') ?? $this->ask(__('commands.make_node.upload_size'), '256');
$data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(__('commands.make_node.daemonListen'), '8080');
$data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(__('commands.make_node.daemonSFTP'), '2022');
$data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(__('commands.make_node.daemonSFTPAlias'), '');
$data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(__('commands.make_node.daemonBase'), '/var/lib/pelican/volumes');
$data['fqdn'] = $this->option('fqdn') ?? $this->ask(trans('commands.make_node.fqdn'));
$data['public'] = $this->option('public') ?? $this->confirm(trans('commands.make_node.public'), true);
$data['behind_proxy'] = $this->option('proxy') ?? $this->confirm(trans('commands.make_node.behind_proxy'));
$data['maintenance_mode'] = $this->option('maintenance') ?? $this->confirm(trans('commands.make_node.maintenance_mode'));
$data['memory'] = $this->option('maxMemory') ?? $this->ask(trans('commands.make_node.memory'), '0');
$data['memory_overallocate'] = $this->option('overallocateMemory') ?? $this->ask(trans('commands.make_node.memory_overallocate'), '-1');
$data['disk'] = $this->option('maxDisk') ?? $this->ask(trans('commands.make_node.disk'), '0');
$data['disk_overallocate'] = $this->option('overallocateDisk') ?? $this->ask(trans('commands.make_node.disk_overallocate'), '-1');
$data['cpu'] = $this->option('maxCpu') ?? $this->ask(trans('commands.make_node.cpu'), '0');
$data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(trans('commands.make_node.cpu_overallocate'), '-1');
$data['upload_size'] = $this->option('uploadSize') ?? $this->ask(trans('commands.make_node.upload_size'), '256');
$data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(trans('commands.make_node.daemonListen'), '8080');
$data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(trans('commands.make_node.daemonSFTP'), '2022');
$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);
$this->line(__('commands.make_node.success', ['name' => $data['name'], 'id' => $node->id]));
$this->line(trans('commands.make_node.success', ['name' => $data['name'], 'id' => $node->id]));
}
}

View File

@@ -19,14 +19,14 @@ class NodeConfigurationCommand extends Command
/** @var \App\Models\Node $node */
$node = Node::query()->where($column, $this->argument('node'))->firstOr(function () {
$this->error(__('commands.node_config.error_not_exist'));
$this->error(trans('commands.node_config.error_not_exist'));
exit(1);
});
$format = $this->option('format');
if (!in_array($format, ['yaml', 'yml', 'json'])) {
$this->error(__('commands.node_config.error_invalid_format'));
$this->error(trans('commands.node_config.error_invalid_format'));
return 1;
}

View File

@@ -13,12 +13,12 @@ class KeyGenerateCommand extends BaseKeyGenerateCommand
public function handle(): void
{
if (!empty(config('app.key')) && $this->input->isInteractive()) {
$this->output->warning(__('commands.key_generate.error_already_exist'));
if (!$this->confirm(__('commands.key_generate.understand'))) {
$this->output->warning(trans('commands.key_generate.error_already_exist'));
if (!$this->confirm(trans('commands.key_generate.understand'))) {
return;
}
if (!$this->confirm(__('commands.key_generate.continue'))) {
if (!$this->confirm(trans('commands.key_generate.continue'))) {
return;
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Console\Command;
use App\Models\Schedule;
use Illuminate\Database\Eloquent\Builder;
use App\Services\Schedules\ProcessScheduleService;
use Throwable;
class ProcessRunnableCommand extends Command
{
@@ -13,10 +14,7 @@ class ProcessRunnableCommand extends Command
protected $description = 'Process schedules in the database and determine which are ready to run.';
/**
* Handle command execution.
*/
public function handle(): int
public function handle(ProcessScheduleService $processScheduleService): int
{
$schedules = Schedule::query()
->with('tasks')
@@ -27,7 +25,7 @@ class ProcessRunnableCommand extends Command
->get();
if ($schedules->count() < 1) {
$this->line(__('commands.schedule.process.no_tasks'));
$this->line(trans('commands.schedule.process.no_tasks'));
return 0;
}
@@ -35,7 +33,7 @@ class ProcessRunnableCommand extends Command
$bar = $this->output->createProgressBar(count($schedules));
foreach ($schedules as $schedule) {
$bar->clear();
$this->processSchedule($schedule);
$this->processSchedule($processScheduleService, $schedule);
$bar->advance();
$bar->display();
}
@@ -50,23 +48,23 @@ class ProcessRunnableCommand extends Command
* never throw an exception out, otherwise you'll end up killing the entire run group causing
* any other schedules to not process correctly.
*/
protected function processSchedule(Schedule $schedule): void
protected function processSchedule(ProcessScheduleService $processScheduleService, Schedule $schedule): void
{
if ($schedule->tasks->isEmpty()) {
return;
}
try {
$this->getLaravel()->make(ProcessScheduleService::class)->handle($schedule);
$processScheduleService->handle($schedule);
$this->line(trans('command/messages.schedule.output_line', [
'schedule' => $schedule->name,
'id' => $schedule->id,
]));
} catch (\Throwable|\Exception $exception) {
} catch (Throwable $exception) {
logger()->error($exception, ['schedule_id' => $schedule->id]);
$this->error(__('commands.schedule.process.no_tasks') . " #$schedule->id: " . $exception->getMessage());
$this->error(trans('commands.schedule.process.no_tasks') . " #$schedule->id: " . $exception->getMessage());
}
}
}

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Factory as ValidatorFactory;
use App\Repositories\Daemon\DaemonPowerRepository;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use Exception;
class BulkPowerActionCommand extends Command
{
@@ -19,26 +19,13 @@ class BulkPowerActionCommand extends Command
protected $description = 'Perform bulk power management on large groupings of servers or nodes at once.';
/**
* BulkPowerActionCommand constructor.
*/
public function __construct(private DaemonPowerRepository $powerRepository, private ValidatorFactory $validator)
{
parent::__construct();
}
/**
* Handle the bulk power request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function handle(): void
public function handle(DaemonPowerRepository $powerRepository, ValidatorFactory $validator): void
{
$action = $this->argument('action');
$nodes = empty($this->option('nodes')) ? [] : explode(',', $this->option('nodes'));
$servers = empty($this->option('servers')) ? [] : explode(',', $this->option('servers'));
$validator = $this->validator->make([
$validator = $validator->make([
'action' => $action,
'nodes' => $nodes,
'servers' => $servers,
@@ -64,14 +51,17 @@ class BulkPowerActionCommand extends Command
}
$bar = $this->output->createProgressBar($count);
$powerRepository = $this->powerRepository;
// @phpstan-ignore-next-line
$this->getQueryBuilder($servers, $nodes)->each(function (Server $server) use ($action, $powerRepository, &$bar) {
$this->getQueryBuilder($servers, $nodes)->get()->each(function ($server, int $index) use ($action, $powerRepository, &$bar): mixed {
$bar->clear();
if (!$server instanceof Server) {
return null;
}
try {
$powerRepository->setServer($server)->send($action);
} catch (DaemonConnectionException $exception) {
} catch (Exception $exception) {
$this->output->error(trans('command/messages.server.power.action_failed', [
'name' => $server->name,
'id' => $server->id,
@@ -82,6 +72,8 @@ class BulkPowerActionCommand extends Command
$bar->advance();
$bar->display();
return null;
});
$this->line('');
@@ -89,6 +81,9 @@ class BulkPowerActionCommand extends Command
/**
* Returns the query builder instance that will return the servers that should be affected.
*
* @param string[]|int[] $servers
* @param string[]|int[] $nodes
*/
protected function getQueryBuilder(array $servers, array $nodes): Builder
{

View File

@@ -34,30 +34,26 @@ class UpgradeCommand extends Command
{
$skipDownload = $this->option('skip-download');
if (!$skipDownload) {
$this->output->warning(__('commands.upgrade.integrity'));
$this->output->comment(__('commands.upgrade.source_url'));
$this->output->warning(trans('commands.upgrade.integrity'));
$this->output->comment(trans('commands.upgrade.source_url'));
$this->line($this->getUrl());
}
if (version_compare(PHP_VERSION, '7.4.0') < 0) {
$this->error(__('commands.upgrade.php_version') . ' [' . PHP_VERSION . '].');
}
$user = 'www-data';
$group = 'www-data';
if ($this->input->isInteractive()) {
if (!$skipDownload) {
$skipDownload = !$this->confirm(__('commands.upgrade.skipDownload'), true);
$skipDownload = !$this->confirm(trans('commands.upgrade.skipDownload'), true);
}
if (is_null($this->option('user'))) {
$userDetails = function_exists('posix_getpwuid') ? posix_getpwuid(fileowner('public')) : [];
$user = $userDetails['name'] ?? 'www-data';
$message = __('commands.upgrade.webserver_user', ['user' => $user]);
$message = trans('commands.upgrade.webserver_user', ['user' => $user]);
if (!$this->confirm($message, true)) {
$user = $this->anticipate(
__('commands.upgrade.name_webserver'),
trans('commands.upgrade.name_webserver'),
[
'www-data',
'nginx',
@@ -71,10 +67,10 @@ class UpgradeCommand extends Command
$groupDetails = function_exists('posix_getgrgid') ? posix_getgrgid(filegroup('public')) : [];
$group = $groupDetails['name'] ?? 'www-data';
$message = __('commands.upgrade.group_webserver', ['group' => $user]);
$message = trans('commands.upgrade.group_webserver', ['group' => $user]);
if (!$this->confirm($message, true)) {
$group = $this->anticipate(
__('commands.upgrade.group_webserver_question'),
trans('commands.upgrade.group_webserver_question'),
[
'www-data',
'nginx',
@@ -84,8 +80,8 @@ class UpgradeCommand extends Command
}
}
if (!$this->confirm(__('commands.upgrade.are_your_sure'))) {
$this->warn(__('commands.upgrade.terminated'));
if (!$this->confirm(trans('commands.upgrade.are_your_sure'))) {
$this->warn(trans('commands.upgrade.terminated'));
return;
}
@@ -175,7 +171,7 @@ class UpgradeCommand extends Command
});
$this->newLine(2);
$this->info(__('commands.upgrade.success'));
$this->info(trans('commands.upgrade.success'));
}
protected function withProgress(ProgressBar $bar, \Closure $callback): void

View File

@@ -19,7 +19,7 @@ class DisableTwoFactorCommand extends Command
public function handle(): void
{
if ($this->input->isInteractive()) {
$this->output->warning(trans('command/messages.user.2fa_help_text'));
$this->output->warning(trans('command/messages.user.2fa_help_text.0') . trans('command/messages.user.2fa_help_text.1'));
}
$email = $this->option('email') ?? $this->ask(trans('command/messages.user.ask_email'));

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Contracts;
use Illuminate\Validation\Validator;
interface Validatable
{
public function getValidator(): Validator;
/**
* @return array<string, mixed>
*/
public static function getRules(): array;
/**
* @return array<string, array<string, mixed>>
*/
public static function getRulesForField(string $field): array;
public function validate(): void;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Eloquent;
use Illuminate\Database\Eloquent\Builder;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
*
* @extends Builder<TModel>
*/
class BackupQueryBuilder extends Builder
{
public function nonFailed(): self
{
$this->where(function (Builder $query) {
$query
->whereNull('completed_at')
->orWhere('is_successful', true);
});
return $this;
}
}

View File

@@ -2,7 +2,11 @@
namespace App\Enums;
enum ContainerStatus: string
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
{
// Docker Based
case Created = 'created';
@@ -19,7 +23,7 @@ enum ContainerStatus: string
// HTTP Based
case Missing = 'missing';
public function icon(): string
public function getIcon(): string
{
return match ($this) {
@@ -36,8 +40,17 @@ enum ContainerStatus: string
};
}
public function color(): string
public function getColor(bool $hex = false): string
{
if ($hex) {
return match ($this) {
self::Created, self::Restarting => '#2563EB',
self::Starting, self::Paused, self::Removing, self::Stopping => '#D97706',
self::Running => '#22C55E',
self::Exited, self::Missing, self::Dead, self::Offline => '#EF4444',
};
}
return match ($this) {
self::Created => 'primary',
self::Starting => 'warning',
@@ -53,14 +66,19 @@ enum ContainerStatus: string
};
}
public function colorHex(): string
public function getLabel(): string
{
return match ($this) {
self::Created, self::Restarting => '#2563EB',
self::Starting, self::Paused, self::Removing, self::Stopping => '#D97706',
self::Running => '#22C55E',
self::Exited, self::Missing, self::Dead, self::Offline => '#EF4444',
};
return str($this->value)->title();
}
public function isOffline(): bool
{
return in_array($this, [ContainerStatus::Offline, ContainerStatus::Missing]);
}
public function isStartingOrRunning(): bool
{
return in_array($this, [ContainerStatus::Starting, ContainerStatus::Running]);
}
public function isStartingOrStopping(): bool

View File

@@ -36,6 +36,7 @@ enum EditorLanguages: string implements HasLabel
case java = 'java';
case javascript = 'javascript';
case julia = 'julia';
case json = 'json';
case kotlin = 'kotlin';
case less = 'less';
case lexon = 'lexon';
@@ -89,9 +90,51 @@ enum EditorLanguages: string implements HasLabel
case wgsl = 'wgsl';
case xml = 'xml';
case yaml = 'yaml';
case json = 'json';
public function getLabel(): ?string
public static function fromWithAlias(string $match): self
{
return match ($match) {
'h' => self::c,
'cc', 'hpp' => self::cpp,
'cs' => self::csharp,
'class' => self::java,
'htm' => self::html,
'js', 'mjs', 'cjs' => self::javascript,
'kt', 'kts' => self::kotlin,
'md' => self::markdown,
'm' => self::objectivec,
'pl', 'pm' => self::perl,
'php3', 'php4', 'php5', 'phtml' => self::php,
'py', 'pyc', 'pyo', 'pyi' => self::python,
'rdata', 'rds' => self::r,
'rb', 'erb' => self::ruby,
'sc' => self::scala,
'sh', 'zsh' => self::shell,
'ts', 'tsx' => self::typescript,
'yml' => self::yaml,
default => self::tryFrom($match) ?? self::plaintext,
};
}
public function getLabel(): string
{
return $this->name;
}

View File

@@ -2,7 +2,11 @@
namespace App\Enums;
enum ServerState: string
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum ServerState: string implements HasColor, HasIcon, HasLabel
{
case Normal = 'normal';
case Installing = 'installing';
@@ -11,7 +15,7 @@ enum ServerState: string
case Suspended = 'suspended';
case RestoringBackup = 'restoring_backup';
public function icon(): string
public function getIcon(): string
{
return match ($this) {
self::Normal => 'tabler-heart',
@@ -23,7 +27,7 @@ enum ServerState: string
};
}
public function color(): string
public function getColor(): string
{
return match ($this) {
self::Normal => 'primary',
@@ -34,4 +38,9 @@ enum ServerState: string
self::RestoringBackup => 'primary',
};
}
public function getLabel(): string
{
return str($this->value)->headline();
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum SuspendAction: string
{
case Suspend = 'suspend';
case Unsuspend = 'unsuspend';
}

View File

@@ -12,6 +12,9 @@ use Illuminate\Http\Response;
use Illuminate\Container\Container;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/**
* @deprecated
*/
class DisplayException extends PanelException implements HttpExceptionInterface
{
public const LEVEL_DEBUG = 'debug';
@@ -40,6 +43,9 @@ class DisplayException extends PanelException implements HttpExceptionInterface
return Response::HTTP_BAD_REQUEST;
}
/**
* @return array<string, string>
*/
public function getHeaders(): array
{
return [];

View File

@@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Mailer\Exception\TransportException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;
class Handler extends ExceptionHandler
{
@@ -45,6 +46,8 @@ class Handler extends ExceptionHandler
/**
* Maps exceptions to a specific response code. This handles special exception
* types that don't have a defined response code.
*
* @var array<class-string, int>
*/
protected static array $exceptionResponseCodes = [
AuthenticationException::class => 401,
@@ -180,9 +183,16 @@ class Handler extends ExceptionHandler
}
/**
* Return the exception as a JSONAPI representation for use on API requests.
* @param array<string, mixed> $override
* @return array{errors: array{
* code: string,
* status: string,
* detail: string,
* source?: array{line: int, file: string},
* meta?: array{trace: string[], previous: string[]}
* }}|array{errors: array{non-empty-array<string, mixed>}}
*/
protected function convertExceptionToArray(\Throwable $e, array $override = []): array
public static function exceptionToArray(Throwable $e, array $override = []): array
{
$match = self::$exceptionResponseCodes[get_class($e)] ?? null;
@@ -214,7 +224,7 @@ class Handler extends ExceptionHandler
'trace' => Collection::make($e->getTrace())
->map(fn ($trace) => Arr::except($trace, ['args']))
->all(),
'previous' => Collection::make($this->extractPrevious($e))
'previous' => Collection::make(self::extractPrevious($e))
->map(fn ($exception) => $exception->getTrace())
->map(fn ($trace) => Arr::except($trace, ['args']))
->all(),
@@ -225,6 +235,17 @@ class Handler extends ExceptionHandler
return ['errors' => [array_merge($error, $override)]];
}
/**
* Return the exception as a JSONAPI representation for use on API requests.
*
* @param array{detail?: mixed, source?: mixed, meta?: mixed} $override
* @return array{errors?: array<mixed>}
*/
protected function convertExceptionToArray(Throwable $e, array $override = []): array
{
return self::exceptionToArray($e, $override);
}
/**
* Return an array of exceptions that should not be reported.
*/
@@ -244,22 +265,19 @@ class Handler extends ExceptionHandler
return new JsonResponse($this->convertExceptionToArray($exception), JsonResponse::HTTP_UNAUTHORIZED);
}
return redirect()->guest('/auth/login');
return redirect()->guest(route('filament.app.auth.login'));
}
/**
* Extracts all the previous exceptions that lead to the one passed into this
* function being thrown.
*
* @return \Throwable[]
* @return Throwable[]
*/
protected function extractPrevious(\Throwable $e): array
public static function extractPrevious(Throwable $e): array
{
$previous = [];
while ($value = $e->getPrevious()) {
if (!$value instanceof \Throwable) {
break;
}
$previous[] = $value;
$e = $value;
}
@@ -270,10 +288,11 @@ class Handler extends ExceptionHandler
/**
* Helper method to allow reaching into the handler to convert an exception
* into the expected array response type.
*
* @return array<mixed>
*/
public static function toArray(\Throwable $e): array
{
// @phpstan-ignore-next-line
return (new self(app()))->convertExceptionToArray($e);
return self::exceptionToArray($e);
}
}

View File

@@ -1,73 +0,0 @@
<?php
namespace App\Exceptions\Http\Connection;
use Exception;
use Illuminate\Http\Response;
use App\Exceptions\DisplayException;
use Illuminate\Support\Facades\Context;
class DaemonConnectionException extends DisplayException
{
private int $statusCode = Response::HTTP_GATEWAY_TIMEOUT;
/**
* Every request to the daemon instance will return a unique X-Request-Id header
* which allows for all errors to be efficiently tied to a specific request that
* triggered them, and gives users a more direct method of informing hosts when
* something goes wrong.
*/
private ?string $requestId;
/**
* Throw a displayable exception caused by a daemon connection error.
*/
public function __construct(?Exception $previous, bool $useStatusCode = true)
{
/** @var \GuzzleHttp\Psr7\Response|null $response */
$response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
$this->requestId = $response?->getHeaderLine('X-Request-Id');
Context::add('request_id', $this->requestId);
if ($useStatusCode) {
$this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode();
// There are rare conditions where daemon encounters a panic condition and crashes the
// request being made after content has already been sent over the wire. In these cases
// you can end up with a "successful" response code that is actual an error.
//
// Handle those better here since we shouldn't ever end up in this exception state and
// be returning a 2XX level response.
if ($this->statusCode < 400) {
$this->statusCode = Response::HTTP_BAD_GATEWAY;
}
}
if (is_null($response)) {
$message = 'Could not establish a connection to the machine running this server. Please try again.';
} else {
$message = sprintf('There was an error while communicating with the machine running this server. This error has been logged, please try again. (code: %s) (request_id: %s)', $response->getStatusCode(), $this->requestId ?? '<nil>');
}
// Attempt to pull the actual error message off the response and return that if it is not
// a 500 level error.
if ($this->statusCode < 500 && !is_null($response)) {
$body = json_decode($response->getBody()->__toString(), true);
$message = sprintf('An error occurred on the remote host: %s. (request id: %s)', $body['error'] ?? $message, $this->requestId ?? '<nil>');
}
$level = $this->statusCode >= 500 && $this->statusCode !== 504
? DisplayException::LEVEL_ERROR
: DisplayException::LEVEL_WARNING;
parent::__construct($message, $previous, $level);
}
/**
* Return the HTTP status code for this exception.
*/
public function getStatusCode(): int
{
return $this->statusCode;
}
}

View File

@@ -42,6 +42,9 @@ class DataValidationException extends PanelException implements HttpExceptionInt
return 500;
}
/**
* @return array<string, string>
*/
public function getHeaders(): array
{
return [];

View File

@@ -16,17 +16,18 @@ class BackupManager
{
/**
* The array of resolved backup drivers.
*
* @var array<string, FilesystemAdapter>
*/
protected array $adapters = [];
/**
* The registered custom driver creators.
*
* @var array<string, callable>
*/
protected array $customCreators;
/**
* BackupManager constructor.
*/
public function __construct(protected Application $app) {}
/**
@@ -86,6 +87,8 @@ class BackupManager
/**
* Calls a custom creator for a given adapter type.
*
* @param array{adapter: string} $config
*/
protected function callCustomCreator(array $config): mixed
{
@@ -94,6 +97,8 @@ class BackupManager
/**
* Creates a new daemon adapter.
*
* @param array<string, string> $config
*/
public function createWingsAdapter(array $config): FilesystemAdapter
{
@@ -102,6 +107,8 @@ class BackupManager
/**
* Creates a new S3 adapter.
*
* @param array<string, string> $config
*/
public function createS3Adapter(array $config): FilesystemAdapter
{
@@ -118,6 +125,8 @@ class BackupManager
/**
* Returns the configuration associated with a given backup type.
*
* @return array<mixed>
*/
protected function getConfig(string $name): array
{
@@ -147,8 +156,9 @@ class BackupManager
*/
public function forget(array|string $adapter): self
{
$adapters = &$this->adapters;
foreach ((array) $adapter as $adapterName) {
unset($this->adapters[$adapterName]);
unset($adapters[$adapterName]);
}
return $this;

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Extensions\Captcha\Providers;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput;
use Illuminate\Foundation\Application;
use Illuminate\Support\Str;
abstract class CaptchaProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
/**
* @return self|static[]
*/
public static function get(?string $id = null): array|self
{
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 Captcha provider with id '{$this->getId()}'");
}
return;
}
config()->set('captcha.' . Str::lower($this->getId()), $this->getConfig());
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
abstract public function getComponent(): Component;
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array
{
$id = Str::upper($this->getId());
return [
'site_key' => env("CAPTCHA_{$id}_SITE_KEY"),
'secret_key' => env("CAPTCHA_{$id}_SECRET_KEY"),
];
}
/**
* @return Component[]
*/
public function getSettingsForm(): array
{
$id = Str::upper($this->getId());
return [
TextInput::make("CAPTCHA_{$id}_SITE_KEY")
->label('Site Key')
->placeholder('Site Key')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("CAPTCHA_{$id}_SITE_KEY")),
TextInput::make("CAPTCHA_{$id}_SECRET_KEY")
->label('Secret Key')
->placeholder('Secret Key')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("CAPTCHA_{$id}_SECRET_KEY")),
];
}
public function getName(): string
{
return Str::title($this->getId());
}
public function getIcon(): ?string
{
return null;
}
public function isEnabled(): bool
{
$id = Str::upper($this->getId());
return env("CAPTCHA_{$id}_ENABLED", false);
}
/**
* @return array<string, string|bool>
*/
public function validateResponse(?string $captchaResponse = null): array
{
return [
'success' => false,
'message' => 'validateResponse not defined',
];
}
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool
{
return true;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Extensions\Captcha\Providers;
use App\Filament\Components\Forms\Fields\TurnstileCaptcha;
use Exception;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Toggle;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString;
class TurnstileProvider extends CaptchaProvider
{
public function getId(): string
{
return 'turnstile';
}
public function getComponent(): Component
{
return TurnstileCaptcha::make('turnstile');
}
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array
{
return array_merge(parent::getConfig(), [
'verify_domain' => env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN'),
]);
}
/**
* @return Component[]
*/
public function getSettingsForm(): array
{
return array_merge(parent::getSettingsForm(), [
Toggle::make('CAPTCHA_TURNSTILE_VERIFY_DOMAIN')
->label(trans('admin/setting.captcha.verify'))
->columnSpan(2)
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->default(env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)),
Placeholder::make('info')
->label(trans('admin/setting.captcha.info_label'))
->columnSpan(2)
->content(new HtmlString(trans('admin/setting.captcha.info'))),
]);
}
public function getIcon(): string
{
return 'tabler-brand-cloudflare';
}
public static function register(Application $app): self
{
return new self($app);
}
/**
* @return array<string, string|bool>
*/
public function validateResponse(?string $captchaResponse = null): array
{
$captchaResponse ??= request()->get('cf-turnstile-response');
if (!$secret = env('CAPTCHA_TURNSTILE_SECRET_KEY')) {
throw new Exception('Turnstile secret key is not defined.');
}
$response = Http::asJson()
->timeout(15)
->connectTimeout(5)
->throw()
->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => $secret,
'response' => $captchaResponse,
]);
return count($response->json()) ? $response->json() : [
'success' => false,
'message' => 'Unknown error occurred, please try again',
];
}
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool
{
if (!env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)) {
return true;
}
$requestUrl ??= request()->url;
$requestUrl = parse_url($requestUrl);
return $hostname === array_get($requestUrl, 'host');
}
}

View File

@@ -1,28 +1,5 @@
<?php
/* The MIT License (MIT)
Pterodactyl®
Copyright © Dane Everitt <dane@daneeveritt.com> and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. */
namespace App\Extensions\Filesystem;
use Aws\S3\S3ClientInterface;
@@ -30,6 +7,9 @@ use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
class S3Filesystem extends AwsS3V3Adapter
{
/**
* @param array<mixed> $options
*/
public function __construct(
private S3ClientInterface $client,
private string $bucket,

View File

@@ -8,6 +8,9 @@ class PanelSerializer extends ArraySerializer
{
/**
* Serialize an item.
*
* @param array<mixed> $data
* @return array{object: ?string, attributes: array<mixed>}
*/
public function item(?string $resourceKey, array $data): array
{
@@ -19,6 +22,9 @@ class PanelSerializer extends ArraySerializer
/**
* Serialize a collection.
*
* @param array<mixed> $data
* @return array{object: 'list', data: array<mixed>}
*/
public function collection(?string $resourceKey, array $data): array
{
@@ -35,6 +41,8 @@ class PanelSerializer extends ArraySerializer
/**
* Serialize a null resource.
*
* @return ?array{object: ?string, attributes: null}
*/
public function null(): ?array
{
@@ -46,6 +54,10 @@ class PanelSerializer extends ArraySerializer
/**
* Merge the included resources with the parent resource being serialized.
*
* @param array{relationships: array{string, mixed}} $transformedData
* @param array{string, mixed} $includedData
* @return array{relationships: array{string, mixed}}
*/
public function mergeIncludes(array $transformedData, array $includedData): array
{

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\TextInput;
use Illuminate\Foundation\Application;
use SocialiteProviders\Authentik\Provider;
final class AuthentikProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'authentik';
}
public function getProviderClass(): string
{
return Provider::class;
}
public function getServiceConfig(): array
{
return [
'base_url' => env('OAUTH_AUTHENTIK_BASE_URL'),
'client_id' => env('OAUTH_AUTHENTIK_CLIENT_ID'),
'client_secret' => env('OAUTH_AUTHENTIK_CLIENT_SECRET'),
];
}
public function getSettingsForm(): array
{
return array_merge(parent::getSettingsForm(), [
TextInput::make('OAUTH_AUTHENTIK_BASE_URL')
->label('Base URL')
->placeholder('Base URL')
->columnSpan(2)
->required()
->url()
->autocomplete(false)
->default(env('OAUTH_AUTHENTIK_BASE_URL')),
TextInput::make('OAUTH_AUTHENTIK_DISPLAY_NAME')
->label('Display Name')
->placeholder('Display Name')
->autocomplete(false)
->default(env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik')),
ColorPicker::make('OAUTH_AUTHENTIK_DISPLAY_COLOR')
->label('Display Color')
->placeholder('#fd4b2d')
->default(env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d'))
->hex(),
]);
}
public function getName(): string
{
return env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik');
}
public function getHexColor(): string
{
return env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Illuminate\Foundation\Application;
final class CommonProvider extends OAuthProvider
{
protected function __construct(protected Application $app, private string $id, private ?string $providerClass, private ?string $icon, private ?string $hexColor)
{
parent::__construct($app);
}
public function getId(): string
{
return $this->id;
}
public function getProviderClass(): ?string
{
return $this->providerClass;
}
public function getIcon(): ?string
{
return $this->icon;
}
public function getHexColor(): ?string
{
return $this->hexColor;
}
public static function register(Application $app, string $id, ?string $providerClass = null, ?string $icon = null, ?string $hexColor = null): static
{
return new self($app, $id, $providerClass, $icon, $hexColor);
}
}

View File

@@ -0,0 +1,64 @@
<?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\HtmlString;
use Illuminate\Support\Str;
use SocialiteProviders\Discord\Provider;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class DiscordProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'discord';
}
public function getProviderClass(): string
{
return Provider::class;
}
public function getSetupSteps(): array
{
return array_merge([
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>')),
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'),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-discord-f';
}
public function getHexColor(): string
{
return '#5865F2';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -0,0 +1,63 @@
<?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\HtmlString;
use Illuminate\Support\Str;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class GithubProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'github';
}
public function getSetupSteps(): array
{
return array_merge([
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>')),
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'),
Placeholder::make('')
->content(new HtmlString('<p>When you filled all fields click on <b>Register application</b>.</p>')),
]),
Step::make('Create Client Secret')
->schema([
Placeholder::make('')
->content(new HtmlString('<p>Once you registered your app, generate a new <b>Client Secret</b>.</p><p>You will also need the <b>Client ID</b>.</p>')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-github-f';
}
public function getHexColor(): string
{
return '#4078c0';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use SocialiteProviders\Manager\SocialiteWasCalled;
abstract class OAuthProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
/**
* @return self|static[]
*/
public static function get(?string $id = null): array|self
{
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 OAuth provider with id '{$this->getId()}'");
}
return;
}
config()->set('services.' . $this->getId(), array_merge($this->getServiceConfig(), ['redirect' => '/auth/oauth/callback/' . $this->getId()]));
if ($this->getProviderClass()) {
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite($this->getId(), $this->getProviderClass());
});
}
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
public function getProviderClass(): ?string
{
return null;
}
/**
* @return array<string, string|string[]|bool|null>
*/
public function getServiceConfig(): array
{
$id = Str::upper($this->getId());
return [
'client_id' => env("OAUTH_{$id}_CLIENT_ID"),
'client_secret' => env("OAUTH_{$id}_CLIENT_SECRET"),
];
}
/**
* @return Component[]
*/
public function getSettingsForm(): array
{
$id = Str::upper($this->getId());
return [
TextInput::make("OAUTH_{$id}_CLIENT_ID")
->label('Client ID')
->placeholder('Client ID')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("OAUTH_{$id}_CLIENT_ID")),
TextInput::make("OAUTH_{$id}_CLIENT_SECRET")
->label('Client Secret')
->placeholder('Client Secret')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("OAUTH_{$id}_CLIENT_SECRET")),
];
}
/**
* @return Step[]
*/
public function getSetupSteps(): array
{
return [
Step::make('OAuth Config')
->columns(4)
->schema($this->getSettingsForm()),
];
}
public function getName(): string
{
return Str::title($this->getId());
}
public function getIcon(): ?string
{
return null;
}
public function getHexColor(): ?string
{
return null;
}
public function isEnabled(): bool
{
$id = Str::upper($this->getId());
return env("OAUTH_{$id}_ENABLED", false);
}
}

View File

@@ -0,0 +1,80 @@
<?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\HtmlString;
use SocialiteProviders\Steam\Provider;
final class SteamProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'steam';
}
public function getProviderClass(): string
{
return Provider::class;
}
public function getServiceConfig(): array
{
return [
'client_id' => null,
'client_secret' => env('OAUTH_STEAM_CLIENT_SECRET'),
'allowed_hosts' => [
str_replace(['http://', 'https://'], '', env('APP_URL')),
],
];
}
public function getSettingsForm(): array
{
return [
TextInput::make('OAUTH_STEAM_CLIENT_SECRET')
->label('Web API Key')
->placeholder('Web API Key')
->columnSpan(4)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env('OAUTH_STEAM_CLIENT_SECRET')),
];
}
public function getSetupSteps(): array
{
return array_merge([
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.')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-steam-f';
}
public function getHexColor(): string
{
return '#00adee';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@@ -22,13 +22,16 @@ class Dashboard extends Page
public function getTitle(): string
{
return trans('strings.dashboard');
return trans('admin/dashboard.title');
}
public static function getNavigationLabel(): string
{
return trans('admin/dashboard.title');
}
protected static ?string $slug = '/';
public string $activeTab = 'nodes';
private SoftwareVersionService $softwareVersionService;
public function mount(SoftwareVersionService $softwareVersionService): void
@@ -51,33 +54,33 @@ class Dashboard extends Page
'devActions' => [
CreateAction::make()
->label('Bugs & Features')
->label(trans('admin/dashboard.sections.intro-developers.button_issues'))
->icon('tabler-brand-github')
->url('https://github.com/pelican-dev/panel/discussions', true),
->url('https://github.com/pelican-dev/panel/issues', true),
],
'updateActions' => [
CreateAction::make()
->label('Read Documentation')
->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('dashboard/index.sections.intro-first-node.button_label'))
->label(trans('admin/dashboard.sections.intro-first-node.button_label'))
->icon('tabler-server-2')
->url(CreateNode::getUrl()),
],
'supportActions' => [
CreateAction::make()
->label(trans('dashboard/index.sections.intro-support.button_donate'))
->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('dashboard/index.sections.intro-help.button_docs'))
->label(trans('admin/dashboard.sections.intro-help.button_docs'))
->icon('tabler-speedboat')
->url('https://pelican.dev/docs', true),
],

View File

@@ -8,25 +8,45 @@ use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\Enums\Status;
use Spatie\Health\ResultStores\ResultStore;
class Health extends Page
{
protected static ?string $navigationIcon = 'tabler-heart';
protected static ?string $navigationGroup = 'Advanced';
protected static string $view = 'filament.pages.health';
// @phpstan-ignore-next-line
/** @var array<string, string> */
protected $listeners = [
'refresh-component' => '$refresh',
];
public function getTitle(): string
{
return trans('admin/health.title');
}
public static function getNavigationLabel(): string
{
return trans('admin/health.title');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
public static function canAccess(): bool
{
return auth()->user()->can('view health');
}
protected function getActions(): array
{
return [
Action::make('refresh')
->label(trans('admin/health.refresh'))
->button()
->action('refresh'),
];
@@ -34,7 +54,7 @@ class Health extends Page
protected function getViewData(): array
{
// @phpstan-ignore-next-line
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$checkResults = app(ResultStore::class)->latestResults();
if ($checkResults === null) {
@@ -56,14 +76,14 @@ class Health extends Page
$this->dispatch('refresh-component');
Notification::make()
->title('Health check results refreshed')
->title(trans('admin/health.results_refreshed'))
->success()
->send();
}
public static function getNavigationBadge(): ?string
{
// @phpstan-ignore-next-line
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
@@ -86,7 +106,7 @@ class Health extends Page
public static function getNavigationBadgeTooltip(): ?string
{
// @phpstan-ignore-next-line
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
@@ -103,12 +123,12 @@ class Health extends Page
return $carry;
}, []);
return 'Failed: ' . implode(', ', $failedNames);
return trans('admin/health.checks.failed') . implode(', ', $failedNames);
}
public static function getNavigationIcon(): string
{
// @phpstan-ignore-next-line
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$results = app(ResultStore::class)->latestResults();
if ($results === null) {
@@ -117,4 +137,37 @@ class Health extends Page
return $results->containsFailingCheck() ? 'tabler-heart-exclamation' : 'tabler-heart-check';
}
public function backgroundColor(string $str): string
{
return match ($str) {
Status::ok()->value => 'bg-success-100 dark:bg-success-200',
Status::warning()->value => 'bg-warning-100 dark:bg-warning-200',
Status::skipped()->value => 'bg-info-100 dark:bg-info-200',
Status::failed()->value, Status::crashed()->value => 'bg-danger-100 dark:bg-danger-200',
default => 'bg-gray-100 dark:bg-gray-200'
};
}
public function iconColor(string $str): string
{
return match ($str) {
Status::ok()->value => 'text-success-500 dark:text-success-600',
Status::warning()->value => 'text-warning-500 dark:text-warning-600',
Status::skipped()->value => 'text-info-500 dark:text-info-600',
Status::failed()->value, Status::crashed()->value => 'text-danger-500 dark:text-danger-600',
default => 'text-gray-500 dark:text-gray-600'
};
}
public function icon(string $str): string
{
return match ($str) {
Status::ok()->value => 'tabler-circle-check',
Status::warning()->value => 'tabler-exclamation-circle',
Status::skipped()->value => 'tabler-circle-chevron-right',
Status::failed()->value, Status::crashed()->value => 'tabler-circle-x',
default => 'tabler-help-circle'
};
}
}

View File

@@ -2,13 +2,18 @@
namespace App\Filament\Admin\Pages;
use App\Extensions\Captcha\Providers\CaptchaProvider;
use App\Extensions\OAuth\Providers\OAuthProvider;
use App\Models\Backup;
use App\Notifications\MailTested;
use App\Traits\EnvironmentWriterTrait;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action as FormAction;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs;
@@ -28,9 +33,8 @@ use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
/**
* @property Form $form
@@ -43,10 +47,9 @@ class Settings extends Page implements HasForms
protected static ?string $navigationIcon = 'tabler-settings';
protected static ?string $navigationGroup = 'Advanced';
protected static string $view = 'filament.pages.settings';
/** @var array<mixed>|null */
public ?array $data = [];
public function mount(): void
@@ -59,6 +62,16 @@ class Settings extends Page implements HasForms
return auth()->user()->can('view settings');
}
public function getTitle(): string
{
return trans('admin/setting.title');
}
public static function getNavigationLabel(): string
{
return trans('admin/setting.title');
}
protected function getFormSchema(): array
{
return [
@@ -68,49 +81,50 @@ class Settings extends Page implements HasForms
->disabled(fn () => !auth()->user()->can('update settings'))
->tabs([
Tab::make('general')
->label('General')
->label(trans('admin/setting.navigation.general'))
->icon('tabler-home')
->schema($this->generalSettings()),
Tab::make('captcha')
->label('Captcha')
->label(trans('admin/setting.navigation.captcha'))
->icon('tabler-shield')
->schema($this->captchaSettings())
->columns(3),
Tab::make('mail')
->label('Mail')
->label(trans('admin/setting.navigation.mail'))
->icon('tabler-mail')
->schema($this->mailSettings()),
Tab::make('backup')
->label('Backup')
->label(trans('admin/setting.navigation.backup'))
->icon('tabler-box')
->schema($this->backupSettings()),
Tab::make('OAuth')
->label('OAuth')
->label(trans('admin/setting.navigation.oauth'))
->icon('tabler-brand-oauth')
->schema($this->oauthSettings()),
Tab::make('misc')
->label('Misc')
->label(trans('admin/setting.navigation.misc'))
->icon('tabler-tool')
->schema($this->miscSettings()),
]),
];
}
/** @return Component[] */
private function generalSettings(): array
{
return [
TextInput::make('APP_NAME')
->label('App Name')
->label(trans('admin/setting.general.app_name'))
->required()
->default(env('APP_NAME', 'Pelican')),
TextInput::make('APP_FAVICON')
->label('App Favicon')
->label(trans('admin/setting.general.app_favicon'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('Favicons should be placed in the public folder, located in the root panel directory.')
->hintIconTooltip(trans('admin/setting.general.app_favicon_help'))
->required()
->default(env('APP_FAVICON', '/pelican.ico')),
Toggle::make('APP_DEBUG')
->label('Enable Debug Mode?')
->label(trans('admin/setting.general.debug_mode'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
@@ -120,52 +134,52 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state))
->default(env('APP_DEBUG', config('app.debug'))),
ToggleButtons::make('FILAMENT_TOP_NAVIGATION')
->label('Navigation')
->label(trans('admin/setting.general.navigation'))
->inline()
->options([
false => 'Sidebar',
true => 'Topbar',
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'))),
ToggleButtons::make('PANEL_USE_BINARY_PREFIX')
->label('Unit prefix')
->label(trans('admin/setting.general.unit_prefix'))
->inline()
->options([
false => 'Decimal Prefix (MB/ GB)',
true => 'Binary Prefix (MiB/ GiB)',
false => trans('admin/setting.general.decimal_prefix'),
true => trans('admin/setting.general.binary_prefix'),
])
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_USE_BINARY_PREFIX', (bool) $state))
->default(env('PANEL_USE_BINARY_PREFIX', config('panel.use_binary_prefix'))),
ToggleButtons::make('APP_2FA_REQUIRED')
->label('2FA Requirement')
->label(trans('admin/setting.general.2fa_requirement'))
->inline()
->options([
0 => 'Not required',
1 => 'Required for only Admins',
2 => 'Required for all Users',
0 => trans('admin/setting.general.not_required'),
1 => trans('admin/setting.general.admins_only'),
2 => trans('admin/setting.general.all_users'),
])
->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'))),
TagsInput::make('TRUSTED_PROXIES')
->label('Trusted Proxies')
->label(trans('admin/setting.general.trusted_proxies'))
->separator()
->splitKeys(['Tab', ' '])
->placeholder('New IP or IP Range')
->placeholder(trans('admin/setting.general.trusted_proxies_help'))
->default(env('TRUSTED_PROXIES', implode(',', config('trustedproxy.proxies'))))
->hintActions([
FormAction::make('clear')
->label('Clear')
->label(trans('admin/setting.general.clear'))
->color('danger')
->icon('tabler-trash')
->requiresConfirmation()
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])),
FormAction::make('cloudflare')
->label('Set to Cloudflare IPs')
->label(trans('admin/setting.general.set_to_cf'))
->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings'))
->action(function (Factory $client, Set $set) {
@@ -191,62 +205,72 @@ class Settings extends Page implements HasForms
}),
]),
Select::make('FILAMENT_WIDTH')
->label('Display Width')
->label(trans('admin/setting.general.display_width'))
->native(false)
->options(MaxWidth::class)
->selectablePlaceholder(false)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
];
}
/**
* @return Component[]
*/
private function captchaSettings(): array
{
return [
Toggle::make('TURNSTILE_ENABLED')
->label('Enable Turnstile Captcha?')
->inline(false)
->columnSpan(1)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('TURNSTILE_ENABLED', (bool) $state))
->default(env('TURNSTILE_ENABLED', config('turnstile.turnstile_enabled'))),
Placeholder::make('info')
->columnSpan(2)
->content(new HtmlString('<p>You can generate the keys on your <u><a href="https://developers.cloudflare.com/turnstile/get-started/#get-a-sitekey-and-secret-key" target="_blank">Cloudflare Dashboard</a></u>. A Cloudflare account is required.</p>')),
TextInput::make('TURNSTILE_SITE_KEY')
->label('Site Key')
->required()
->visible(fn (Get $get) => $get('TURNSTILE_ENABLED'))
->default(env('TURNSTILE_SITE_KEY', config('turnstile.turnstile_site_key')))
->placeholder('1x00000000000000000000AA'),
TextInput::make('TURNSTILE_SECRET_KEY')
->label('Secret Key')
->required()
->visible(fn (Get $get) => $get('TURNSTILE_ENABLED'))
->default(env('TURNSTILE_SECRET_KEY', config('turnstile.secret_key')))
->placeholder('1x0000000000000000000000000000000AA'),
Toggle::make('TURNSTILE_VERIFY_DOMAIN')
->label('Verify domain?')
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->visible(fn (Get $get) => $get('TURNSTILE_ENABLED'))
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('TURNSTILE_VERIFY_DOMAIN', (bool) $state))
->default(env('TURNSTILE_VERIFY_DOMAIN', config('turnstile.turnstile_verify_domain'))),
];
$formFields = [];
$captchaProviders = CaptchaProvider::get();
foreach ($captchaProviders as $captchaProvider) {
$id = Str::upper($captchaProvider->getId());
$name = Str::title($captchaProvider->getId());
$formFields[] = Section::make($name)
->columns(5)
->icon($captchaProvider->getIcon() ?? 'tabler-shield')
->collapsed(fn () => !env("CAPTCHA_{$id}_ENABLED", false))
->collapsible()
->schema([
Hidden::make("CAPTCHA_{$id}_ENABLED")
->live()
->default(env("CAPTCHA_{$id}_ENABLED")),
Actions::make([
FormAction::make("disable_captcha_$id")
->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED"))
->label(trans('admin/setting.captcha.disable'))
->color('danger')
->action(function (Set $set) use ($id) {
$set("CAPTCHA_{$id}_ENABLED", false);
}),
FormAction::make("enable_captcha_$id")
->visible(fn (Get $get) => !$get("CAPTCHA_{$id}_ENABLED"))
->label(trans('admin/setting.captcha.enable'))
->color('success')
->action(function (Set $set) use ($id, $captchaProviders) {
foreach ($captchaProviders as $captchaProvider) {
$loopId = Str::upper($captchaProvider->getId());
$set("CAPTCHA_{$loopId}_ENABLED", $loopId === $id);
}
}),
])->columnSpan(1),
Group::make($captchaProvider->getSettingsForm())
->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED"))
->columns(4)
->columnSpan(4),
]);
}
return $formFields;
}
/**
* @return Component[]
*/
private function mailSettings(): array
{
return [
ToggleButtons::make('MAIL_MAILER')
->label('Mail Driver')
->label(trans('admin/setting.mail.mail_driver'))
->columnSpanFull()
->inline()
->options([
@@ -261,96 +285,144 @@ class Settings extends Page implements HasForms
->default(env('MAIL_MAILER', config('mail.default')))
->hintAction(
FormAction::make('test')
->label('Send Test Mail')
->label(trans('admin/setting.mail.test_mail'))
->icon('tabler-send')
->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log')
->authorize(fn () => auth()->user()->can('update settings'))
->action(function () {
->action(function (Get $get) {
// Store original mail configuration
$originalConfig = [
'mail.default' => config('mail.default'),
'mail.mailers.smtp.host' => config('mail.mailers.smtp.host'),
'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.from.address' => config('mail.from.address'),
'mail.from.name' => config('mail.from.name'),
'services.mailgun.domain' => config('services.mailgun.domain'),
'services.mailgun.secret' => config('services.mailgun.secret'),
'services.mailgun.endpoint' => config('services.mailgun.endpoint'),
];
try {
// Update mail configuration dynamically
config([
'mail.default' => $get('MAIL_MAILER'),
'mail.mailers.smtp.host' => $get('MAIL_HOST'),
'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.from.address' => $get('MAIL_FROM_ADDRESS'),
'mail.from.name' => $get('MAIL_FROM_NAME'),
'services.mailgun.domain' => $get('MAILGUN_DOMAIN'),
'services.mailgun.secret' => $get('MAILGUN_SECRET'),
'services.mailgun.endpoint' => $get('MAILGUN_ENDPOINT'),
]);
MailNotification::route('mail', auth()->user()->email)
->notify(new MailTested(auth()->user()));
Notification::make()
->title('Test Mail sent')
->title(trans('admin/setting.mail.test_mail_sent'))
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Test Mail failed')
->title(trans('admin/setting.mail.test_mail_failed'))
->body($exception->getMessage())
->danger()
->send();
} finally {
config($originalConfig);
}
})
),
Section::make('"From" Settings')
->description('Set the Address and Name used as "From" in mails.')
Section::make(trans('admin/setting.mail.from_settings'))
->description(trans('admin/setting.mail.from_settings_help'))
->columns()
->schema([
TextInput::make('MAIL_FROM_ADDRESS')
->label('From Address')
->label(trans('admin/setting.mail.from_address'))
->required()
->email()
->default(env('MAIL_FROM_ADDRESS', config('mail.from.address'))),
TextInput::make('MAIL_FROM_NAME')
->label('From Name')
->label(trans('admin/setting.mail.from_name'))
->required()
->default(env('MAIL_FROM_NAME', config('mail.from.name'))),
]),
Section::make('SMTP Configuration')
Section::make(trans('admin/setting.mail.smtp.smtp_title'))
->columns()
->visible(fn (Get $get) => $get('MAIL_MAILER') === 'smtp')
->schema([
TextInput::make('MAIL_HOST')
->label('Host')
->label(trans('admin/setting.mail.smtp.host'))
->required()
->default(env('MAIL_HOST', config('mail.mailers.smtp.host'))),
TextInput::make('MAIL_PORT')
->label('Port')
->label(trans('admin/setting.mail.smtp.port'))
->required()
->numeric()
->minValue(1)
->maxValue(65535)
->default(env('MAIL_PORT', config('mail.mailers.smtp.port'))),
TextInput::make('MAIL_USERNAME')
->label('Username')
->label(trans('admin/setting.mail.smtp.username'))
->default(env('MAIL_USERNAME', config('mail.mailers.smtp.username'))),
TextInput::make('MAIL_PASSWORD')
->label('Password')
->label(trans('admin/setting.mail.smtp.password'))
->password()
->revealable()
->default(env('MAIL_PASSWORD')),
ToggleButtons::make('MAIL_ENCRYPTION')
->label('Encryption')
ToggleButtons::make('MAIL_SCHEME')
->label(trans('admin/setting.mail.smtp.encryption'))
->inline()
->options(['tls' => 'TLS', 'ssl' => 'SSL', '' => 'None'])
->default(env('MAIL_ENCRYPTION', config('mail.mailers.smtp.encryption', 'tls'))),
->options([
'tls' => trans('admin/setting.mail.smtp.tls'),
'ssl' => trans('admin/setting.mail.smtp.ssl'),
'' => trans('admin/setting.mail.smtp.none'),
])
->default(env('MAIL_SCHEME', config('mail.mailers.smtp.encryption', 'tls')))
->live()
->afterStateUpdated(function ($state, Set $set) {
$port = match ($state) {
'tls' => 587,
'ssl' => 465,
default => 25,
};
$set('MAIL_PORT', $port);
}),
]),
Section::make('Mailgun Configuration')
Section::make(trans('admin/setting.mail.mailgun.mailgun_title'))
->columns()
->visible(fn (Get $get) => $get('MAIL_MAILER') === 'mailgun')
->schema([
TextInput::make('MAILGUN_DOMAIN')
->label('Domain')
->label(trans('admin/setting.mail.mailgun.domain'))
->required()
->default(env('MAILGUN_DOMAIN', config('services.mailgun.domain'))),
TextInput::make('MAILGUN_SECRET')
->label('Secret')
->label(trans('admin/setting.mail.mailgun.secret'))
->required()
->default(env('MAILGUN_SECRET', config('services.mailgun.secret'))),
TextInput::make('MAILGUN_ENDPOINT')
->label('Endpoint')
->label(trans('admin/setting.mail.mailgun.endpoint'))
->required()
->default(env('MAILGUN_ENDPOINT', config('services.mailgun.endpoint'))),
]),
];
}
/**
* @return Component[]
*/
private function backupSettings(): array
{
return [
ToggleButtons::make('APP_BACKUP_DRIVER')
->label('Backup Driver')
->label(trans('admin/setting.backup.backup_driver'))
->columnSpanFull()
->inline()
->options([
@@ -359,50 +431,50 @@ class Settings extends Page implements HasForms
])
->live()
->default(env('APP_BACKUP_DRIVER', config('backups.default'))),
Section::make('Throttles')
->description('Configure how many backups can be created in a period. Set period to 0 to disable this throttle.')
Section::make(trans('admin/setting.backup.throttle'))
->description(trans('admin/setting.backup.throttle_help'))
->columns()
->schema([
TextInput::make('BACKUP_THROTTLE_LIMIT')
->label('Limit')
->label(trans('admin/setting.backup.limit'))
->required()
->numeric()
->minValue(1)
->default(config('backups.throttles.limit')),
TextInput::make('BACKUP_THROTTLE_PERIOD')
->label('Period')
->label(trans('admin/setting.backup.period'))
->required()
->numeric()
->minValue(0)
->suffix('Seconds')
->default(config('backups.throttles.period')),
]),
Section::make('S3 Configuration')
Section::make(trans('admin/setting.backup.s3.s3_title'))
->columns()
->visible(fn (Get $get) => $get('APP_BACKUP_DRIVER') === Backup::ADAPTER_AWS_S3)
->schema([
TextInput::make('AWS_DEFAULT_REGION')
->label('Default Region')
->label(trans('admin/setting.backup.s3.default_region'))
->required()
->default(config('backups.disks.s3.region')),
TextInput::make('AWS_ACCESS_KEY_ID')
->label('Access Key ID')
->label(trans('admin/setting.backup.s3.access_key'))
->required()
->default(config('backups.disks.s3.key')),
TextInput::make('AWS_SECRET_ACCESS_KEY')
->label('Secret Access Key')
->label(trans('admin/setting.backup.s3.secret_key'))
->required()
->default(config('backups.disks.s3.secret')),
TextInput::make('AWS_BACKUPS_BUCKET')
->label('Bucket')
->label(trans('admin/setting.backup.s3.bucket'))
->required()
->default(config('backups.disks.s3.bucket')),
TextInput::make('AWS_ENDPOINT')
->label('Endpoint')
->label(trans('admin/setting.backup.s3.endpoint'))
->required()
->default(config('backups.disks.s3.endpoint')),
Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT')
->label('Use path style endpoint?')
->label(trans('admin/setting.backup.s3.use_path_style_endpoint'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
@@ -416,85 +488,77 @@ class Settings extends Page implements HasForms
];
}
/**
* @return Component[]
*/
private function oauthSettings(): array
{
$oauthProviders = Config::get('auth.oauth');
$formFields = [];
foreach ($oauthProviders as $providerName => $providerConfig) {
$providerEnvPrefix = strtoupper($providerName);
$oauthProviders = OAuthProvider::get();
foreach ($oauthProviders as $oauthProvider) {
$id = Str::upper($oauthProvider->getId());
$name = Str::title($oauthProvider->getId());
$fields = [
Toggle::make("OAUTH_{$providerEnvPrefix}_ENABLED")
->onColor('success')
->offColor('danger')
->onIcon('tabler-check')
->offIcon('tabler-x')
->live()
->columnSpan(1)
->label('Enabled')
->default(env("OAUTH_{$providerEnvPrefix}_ENABLED", false)),
];
if (array_key_exists('client_id', $providerConfig['service'] ?? [])) {
$fields[] = TextInput::make("OAUTH_{$providerEnvPrefix}_CLIENT_ID")
->label('Client ID')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->hidden(fn (Get $get) => !$get("OAUTH_{$providerEnvPrefix}_ENABLED"))
->default(env("OAUTH_{$providerEnvPrefix}_CLIENT_ID", $providerConfig['service']['client_id'] ?? ''))
->placeholder('Client ID');
}
if (array_key_exists('client_secret', $providerConfig['service'] ?? [])) {
$fields[] = TextInput::make("OAUTH_{$providerEnvPrefix}_CLIENT_SECRET")
->label('Client Secret')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->hidden(fn (Get $get) => !$get("OAUTH_{$providerEnvPrefix}_ENABLED"))
->default(env("OAUTH_{$providerEnvPrefix}_CLIENT_SECRET", $providerConfig['service']['client_secret'] ?? ''))
->placeholder('Client Secret');
}
if (array_key_exists('base_url', $providerConfig['service'] ?? [])) {
$fields[] = TextInput::make("OAUTH_{$providerEnvPrefix}_BASE_URL")
->label('Base URL')
->columnSpanFull()
->autocomplete(false)
->hidden(fn (Get $get) => !$get("OAUTH_{$providerEnvPrefix}_ENABLED"))
->default(env("OAUTH_{$providerEnvPrefix}_BASE_URL", ''))
->placeholder('Base URL');
}
$formFields[] = Section::make(ucfirst($providerName))
$formFields[] = Section::make($name)
->columns(5)
->icon($providerConfig['icon'] ?? 'tabler-brand-oauth')
->collapsed(fn () => !env("OAUTH_{$providerEnvPrefix}_ENABLED", false))
->icon($oauthProvider->getIcon() ?? 'tabler-brand-oauth')
->collapsed(fn () => !env("OAUTH_{$id}_ENABLED", false))
->collapsible()
->schema($fields);
->schema([
Hidden::make("OAUTH_{$id}_ENABLED")
->live()
->default(env("OAUTH_{$id}_ENABLED")),
Actions::make([
FormAction::make("disable_oauth_$id")
->visible(fn (Get $get) => $get("OAUTH_{$id}_ENABLED"))
->label(trans('admin/setting.oauth.disable'))
->color('danger')
->action(function (Set $set) use ($id) {
$set("OAUTH_{$id}_ENABLED", false);
}),
FormAction::make("enable_oauth_$id")
->visible(fn (Get $get) => !$get("OAUTH_{$id}_ENABLED"))
->label(trans('admin/setting.oauth.enable'))
->color('success')
->steps($oauthProvider->getSetupSteps())
->modalHeading(trans('admin/setting.oauth.enable') . ' ' . $name)
->modalSubmitActionLabel(trans('admin/setting.oauth.enable'))
->modalCancelAction(false)
->action(function ($data, Set $set) use ($id) {
$data = array_merge([
"OAUTH_{$id}_ENABLED" => 'true',
], $data);
foreach ($data as $key => $value) {
$set($key, $value);
}
}),
])->columnSpan(1),
Group::make($oauthProvider->getSettingsForm())
->visible(fn (Get $get) => $get("OAUTH_{$id}_ENABLED"))
->columns(4)
->columnSpan(4),
]);
}
return $formFields;
}
/**
* @return Component[]
*/
private function miscSettings(): array
{
return [
Section::make('Automatic Allocation Creation')
->description('Toggle if Users can create allocations via the client area.')
Section::make(trans('admin/setting.misc.auto_allocation.title'))
->description(trans('admin/setting.misc.auto_allocation.helper'))
->columns()
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_CLIENT_ALLOCATIONS_ENABLED')
->label('Allow Users to create allocations?')
->label(trans('admin/setting.misc.auto_allocation.question'))
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
@@ -505,7 +569,7 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_CLIENT_ALLOCATIONS_ENABLED', (bool) $state))
->default(env('PANEL_CLIENT_ALLOCATIONS_ENABLED', config('panel.client_features.allocations.enabled'))),
TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_START')
->label('Starting Port')
->label(trans('admin/setting.misc.auto_allocation.start'))
->required()
->numeric()
->minValue(1024)
@@ -513,7 +577,7 @@ class Settings extends Page implements HasForms
->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED'))
->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_START')),
TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_END')
->label('Ending Port')
->label(trans('admin/setting.misc.auto_allocation.end'))
->required()
->numeric()
->minValue(1024)
@@ -521,14 +585,14 @@ class Settings extends Page implements HasForms
->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED'))
->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_END')),
]),
Section::make('Mail Notifications')
->description('Toggle which mail notifications should be sent to Users.')
Section::make(trans('admin/setting.misc.mail_notifications.title'))
->description(trans('admin/setting.misc.mail_notifications.helper'))
->columns()
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_SEND_INSTALL_NOTIFICATION')
->label('Server Installed')
->label(trans('admin/setting.misc.mail_notifications.server_installed'))
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
@@ -539,7 +603,7 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_INSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_INSTALL_NOTIFICATION', config('panel.email.send_install_notification'))),
Toggle::make('PANEL_SEND_REINSTALL_NOTIFICATION')
->label('Server Reinstalled')
->label(trans('admin/setting.misc.mail_notifications.server_reinstalled'))
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
@@ -550,45 +614,45 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))),
]),
Section::make('Connections')
->description('Timeouts used when making requests.')
Section::make(trans('admin/setting.misc.connections.title'))
->description(trans('admin/setting.misc.connections.helper'))
->columns()
->collapsible()
->collapsed()
->schema([
TextInput::make('GUZZLE_TIMEOUT')
->label('Request Timeout')
->label(trans('admin/setting.misc.connections.request_timeout'))
->required()
->numeric()
->minValue(15)
->maxValue(60)
->suffix('Seconds')
->suffix(trans('admin/setting.misc.connections.seconds'))
->default(env('GUZZLE_TIMEOUT', config('panel.guzzle.timeout'))),
TextInput::make('GUZZLE_CONNECT_TIMEOUT')
->label('Connect Timeout')
->label(trans('admin/setting.misc.connections.connection_timeout'))
->required()
->numeric()
->minValue(5)
->maxValue(60)
->suffix('Seconds')
->suffix(trans('admin/setting.misc.connections.seconds'))
->default(env('GUZZLE_CONNECT_TIMEOUT', config('panel.guzzle.connect_timeout'))),
]),
Section::make('Activity Logs')
->description('Configure how often old activity logs should be pruned and whether admin activities should be logged.')
Section::make(trans('admin/setting.misc.activity_log.title'))
->description(trans('admin/setting.misc.activity_log.helper'))
->columns()
->collapsible()
->collapsed()
->schema([
TextInput::make('APP_ACTIVITY_PRUNE_DAYS')
->label('Prune age')
->label(trans('admin/setting.misc.activity_log.prune_age'))
->required()
->numeric()
->minValue(1)
->maxValue(365)
->suffix('Days')
->suffix(trans('admin/setting.misc.activity_log.days'))
->default(env('APP_ACTIVITY_PRUNE_DAYS', config('activity.prune_days'))),
Toggle::make('APP_ACTIVITY_HIDE_ADMIN')
->label('Hide admin activities?')
->label(trans('admin/setting.misc.activity_log.log_admin'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
@@ -599,35 +663,35 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('APP_ACTIVITY_HIDE_ADMIN', (bool) $state))
->default(env('APP_ACTIVITY_HIDE_ADMIN', config('activity.hide_admin_activity'))),
]),
Section::make('API')
->description('Defines the rate limit for the number of requests per minute that can be executed.')
Section::make(trans('admin/setting.misc.api.title'))
->description(trans('admin/setting.misc.api.helper'))
->columns()
->collapsible()
->collapsed()
->schema([
TextInput::make('APP_API_CLIENT_RATELIMIT')
->label('Client API Rate Limit')
->label(trans('admin/setting.misc.api.client_rate'))
->required()
->numeric()
->minValue(1)
->suffix('Requests Per Minute')
->suffix(trans('admin/setting.misc.api.rpm'))
->default(env('APP_API_CLIENT_RATELIMIT', config('http.rate_limit.client'))),
TextInput::make('APP_API_APPLICATION_RATELIMIT')
->label('Application API Rate Limit')
->label(trans('admin/setting.misc.api.app_rate'))
->required()
->numeric()
->minValue(1)
->suffix('Requests Per Minute')
->suffix(trans('admin/setting.misc.api.rpm'))
->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))),
]),
Section::make('Server')
->description('Settings for Servers.')
Section::make(trans('admin/setting.misc.server.title'))
->description(trans('admin/setting.misc.server.helper'))
->columns()
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_EDITABLE_SERVER_DESCRIPTIONS')
->label('Allow Users to edit Server Descriptions?')
->label(trans('admin/setting.misc.server.edit_server_desc'))
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
@@ -638,19 +702,19 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state))
->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))),
]),
Section::make('Webhook')
->description('Configure how often old webhook logs should be pruned.')
Section::make(trans('admin/setting.misc.webhook.title'))
->description(trans('admin/setting.misc.webhook.helper'))
->columns()
->collapsible()
->collapsed()
->schema([
TextInput::make('APP_WEBHOOK_PRUNE_DAYS')
->label('Prune age')
->label(trans('admin/setting.misc.webhook.prune_age'))
->required()
->numeric()
->minValue(1)
->maxValue(365)
->suffix('Days')
->suffix(trans('admin/setting.misc.webhook.days'))
->default(env('APP_WEBHOOK_PRUNE_DAYS', config('panel.webhook.prune_days'))),
]),
];
@@ -677,12 +741,12 @@ class Settings extends Page implements HasForms
$this->redirect($this->getUrl());
Notification::make()
->title('Settings saved')
->title(trans('admin/setting.save_success'))
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Save failed')
->title(trans('admin/setting.save_failed'))
->body($exception->getMessage())
->danger()
->send();

View File

@@ -3,26 +3,143 @@
namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\ApiKeyResource\Pages;
use App\Filament\Admin\Resources\UserResource\Pages\EditUser;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\ApiKey;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class ApiKeyResource extends Resource
{
protected static ?string $model = ApiKey::class;
protected static ?string $modelLabel = 'Application API Key';
protected static ?string $pluralModelLabel = 'Application API Keys';
protected static ?string $navigationLabel = 'API Keys';
protected static ?string $navigationIcon = 'tabler-key';
protected static ?string $navigationGroup = 'Advanced';
public static function getNavigationLabel(): string
{
return trans('admin/apikey.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/apikey.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/apikey.model_label_plural');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('key_type', ApiKey::TYPE_APPLICATION)->count() ?: null;
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->where('key_type', ApiKey::TYPE_APPLICATION);
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('key')
->label(trans('admin/apikey.table.key'))
->icon('tabler-clipboard-text')
->state(fn (ApiKey $key) => $key->identifier . $key->token)
->copyable(),
TextColumn::make('memo')
->label(trans('admin/apikey.table.description'))
->wrap()
->limit(50),
DateTimeColumn::make('last_used_at')
->label(trans('admin/apikey.table.last_used'))
->placeholder(trans('admin/apikey.table.never_used'))
->sortable(),
DateTimeColumn::make('created_at')
->label(trans('admin/apikey.table.created'))
->sortable(),
TextColumn::make('user.username')
->label(trans('admin/apikey.table.created_by'))
->icon('tabler-user')
->url(fn (ApiKey $apiKey) => auth()->user()->can('update user', $apiKey->user) ? EditUser::getUrl(['record' => $apiKey->user]) : null),
])
->actions([
DeleteAction::make(),
])
->emptyStateIcon('tabler-key')
->emptyStateDescription('')
->emptyStateHeading(trans('admin/apikey.empty_table'))
->emptyStateActions([
CreateAction::make(),
]);
}
public static function form(Form $form): Form
{
return $form
->schema([
Fieldset::make('Permissions')
->columns([
'default' => 1,
'sm' => 1,
'md' => 2,
])
->schema(
collect(ApiKey::getPermissionList())->map(fn ($resource) => ToggleButtons::make('permissions_' . $resource)
->label(str($resource)->replace('_', ' ')->title())->inline()
->options([
0 => trans('admin/apikey.permissions.none'),
1 => trans('admin/apikey.permissions.read'),
3 => trans('admin/apikey.permissions.read_write'),
])
->icons([
0 => 'tabler-book-off',
1 => 'tabler-book',
3 => 'tabler-writing',
])
->colors([
0 => 'success',
1 => 'warning',
3 => 'danger',
])
->required()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
])
->default(0),
)->all(),
),
TagsInput::make('allowed_ips')
->placeholder(trans('admin/apikey.whitelist_placeholder'))
->label(trans('admin/apikey.whitelist'))
->helperText(trans('admin/apikey.whitelist_help'))
->columnSpanFull(),
Textarea::make('memo')
->required()
->label(trans('admin/apikey.description'))
->helperText(trans('admin/apikey.description_help'))
->columnSpanFull(),
]);
}
public static function getPages(): array

View File

@@ -4,12 +4,6 @@ namespace App\Filament\Admin\Resources\ApiKeyResource\Pages;
use App\Filament\Admin\Resources\ApiKeyResource;
use App\Models\ApiKey;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
@@ -31,78 +25,13 @@ class CreateApiKey extends CreateRecord
return [];
}
public function form(Form $form): Form
{
return $form
->schema([
Hidden::make('identifier')->default(ApiKey::generateTokenIdentifier(ApiKey::TYPE_APPLICATION)),
Hidden::make('token')->default(str_random(ApiKey::KEY_LENGTH)),
Hidden::make('user_id')
->default(auth()->user()->id)
->required(),
Hidden::make('key_type')
->inlineLabel()
->default(ApiKey::TYPE_APPLICATION)
->required(),
Fieldset::make('Permissions')
->columns([
'default' => 1,
'sm' => 1,
'md' => 2,
])
->schema(
collect(ApiKey::getPermissionList())->map(fn ($resource) => ToggleButtons::make('permissions_' . $resource)
->label(str($resource)->replace('_', ' ')->title())->inline()
->options([
0 => 'None',
1 => 'Read',
// 2 => 'Write',
3 => 'Read & Write',
])
->icons([
0 => 'tabler-book-off',
1 => 'tabler-book',
2 => 'tabler-writing',
3 => 'tabler-writing',
])
->colors([
0 => 'success',
1 => 'warning',
2 => 'danger',
3 => 'danger',
])
->required()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
])
->default(0),
)->all(),
),
TagsInput::make('allowed_ips')
->placeholder('Example: 127.0.0.1 or 192.168.1.1')
->label('Whitelisted IPv4 Addresses')
->helperText('Press enter to add a new IP address or leave blank to allow any IP address')
->columnSpanFull(),
Textarea::make('memo')
->required()
->label('Description')
->helperText('
Once you have assigned permissions and created this set of credentials you will be unable to come back and edit it.
If you need to make changes down the road you will need to create a new set of credentials.
')
->columnSpanFull(),
]);
}
protected function handleRecordCreation(array $data): Model
{
$data['identifier'] = ApiKey::generateTokenIdentifier(ApiKey::TYPE_APPLICATION);
$data['token'] = str_random(ApiKey::KEY_LENGTH);
$data['user_id'] = auth()->user()->id;
$data['key_type'] = ApiKey::TYPE_APPLICATION;
$permissions = [];
foreach (ApiKey::getPermissionList() as $permission) {

View File

@@ -3,70 +3,18 @@
namespace App\Filament\Admin\Resources\ApiKeyResource\Pages;
use App\Filament\Admin\Resources\ApiKeyResource;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\ApiKey;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListApiKeys extends ListRecords
{
protected static string $resource = ApiKeyResource::class;
public function table(Table $table): Table
{
return $table
->searchable(false)
->modifyQueryUsing(fn ($query) => $query->where('key_type', ApiKey::TYPE_APPLICATION))
->columns([
TextColumn::make('key')
->copyable()
->icon('tabler-clipboard-text')
->state(fn (ApiKey $key) => $key->identifier . $key->token),
TextColumn::make('memo')
->label('Description')
->wrap()
->limit(50),
TextColumn::make('identifier')
->hidden()
->searchable(),
DateTimeColumn::make('last_used_at')
->label('Last Used')
->placeholder('Not Used')
->sortable(),
DateTimeColumn::make('created_at')
->label('Created')
->sortable(),
TextColumn::make('user.username')
->label('Created By')
->url(fn (ApiKey $apiKey): string => route('filament.admin.resources.users.edit', ['record' => $apiKey->user])),
])
->actions([
DeleteAction::make(),
])
->emptyStateIcon('tabler-key')
->emptyStateDescription('')
->emptyStateHeading('No API Keys')
->emptyStateActions([
CreateAction::make('create')
->label('Create API Key')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('Create API Key')
CreateAction::make()
->hidden(fn () => ApiKey::where('key_type', ApiKey::TYPE_APPLICATION)->count() <= 0),
];
}

View File

@@ -4,7 +4,18 @@ namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Models\DatabaseHost;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class DatabaseHostResource extends Resource
{
@@ -12,8 +23,6 @@ class DatabaseHostResource extends Resource
protected static ?string $navigationIcon = 'tabler-database';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
@@ -21,11 +30,131 @@ class DatabaseHostResource extends Resource
return static::getModel()::count() ?: null;
}
public static function getNavigationLabel(): string
{
return trans('admin/databasehost.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/databasehost.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/databasehost.model_label_plural');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(trans('admin/databasehost.table.name')),
TextColumn::make('host')
->label(trans('admin/databasehost.table.host')),
TextColumn::make('port')
->label(trans('admin/databasehost.table.port')),
TextColumn::make('username')
->label(trans('admin/databasehost.table.username')),
TextColumn::make('databases_count')
->counts('databases')
->icon('tabler-database')
->label(trans('admin/databasehost.databases')),
TextColumn::make('nodes.name')
->icon('tabler-server-2')
->badge()
->placeholder(trans('admin/databasehost.no_nodes')),
])
->checkIfRecordIsSelectableUsing(fn (DatabaseHost $databaseHost) => !$databaseHost->databases_count)
->actions([
ViewAction::make()
->hidden(fn ($record) => static::canEdit($record)),
EditAction::make(),
])
->groupedBulkActions([
DeleteBulkAction::make(),
])
->emptyStateIcon('tabler-database')
->emptyStateDescription('')
->emptyStateHeading(trans('admin/databasehost.no_database_hosts'))
->emptyStateActions([
CreateAction::make(),
]);
}
public static function form(Form $form): Form
{
return $form
->schema([
Section::make()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->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')
->columnSpan(1)
->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'))
->numeric(),
TextInput::make('name')
->label(trans('admin/databasehost.display_name'))
->helperText(trans('admin/databasehost.display_name_help'))
->required()
->maxLength(60),
TextInput::make('username')
->label(trans('admin/databasehost.username'))
->helperText(trans('admin/databasehost.username_help'))
->required()
->maxLength(255),
TextInput::make('password')
->label(trans('admin/databasehost.password'))
->helperText(trans('admin/databasehost.password_help'))
->password()
->revealable()
->maxLength(255)
->required(fn ($operation) => $operation === 'create'),
Select::make('node_ids')
->multiple()
->searchable()
->preload()
->helperText(trans('admin/databasehost.linked_nodes_help'))
->label(trans('admin/databasehost.linked_nodes'))
->relationship('nodes', 'name'),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListDatabaseHosts::route('/'),
'create' => Pages\CreateDatabaseHost::route('/create'),
'view' => Pages\ViewDatabaseHost::route('/{record}'),
'edit' => Pages\EditDatabaseHost::route('/{record}/edit'),
];
}

View File

@@ -4,11 +4,6 @@ namespace App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Services\Databases\Hosts\HostCreationService;
use Filament\Forms;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Exceptions\Halt;
@@ -28,63 +23,6 @@ class CreateDatabaseHost extends CreateRecord
$this->service = $service;
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
TextInput::make('host')
->columnSpan(2)
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(255),
TextInput::make('port')
->columnSpan(1)
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->default(3306)
->minValue(0)
->maxValue(65535),
TextInput::make('max_databases')
->label('Max databases')
->helpertext('Blank is unlimited.')
->numeric(),
TextInput::make('name')
->label('Display Name')
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->required()
->maxLength(60),
TextInput::make('username')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->required()
->maxLength(255),
TextInput::make('password')
->helperText('The password for the database user.')
->password()
->revealable()
->maxLength(255)
->required(),
Select::make('node_ids')
->multiple()
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Nodes')
->relationship('nodes', 'name'),
]),
]);
}
protected function getHeaderActions(): array
{
return [
@@ -103,7 +41,7 @@ class CreateDatabaseHost extends CreateRecord
return $this->service->handle($data);
} catch (PDOException $exception) {
Notification::make()
->title('Error connecting to database host')
->title(trans('admin/databasehost.error'))
->body($exception->getMessage())
->color('danger')
->icon('tabler-database')

View File

@@ -6,12 +6,7 @@ use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Filament\Admin\Resources\DatabaseHostResource\RelationManagers\DatabasesRelationManager;
use App\Models\DatabaseHost;
use App\Services\Databases\Hosts\HostUpdateService;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Exceptions\Halt;
@@ -29,66 +24,11 @@ class EditDatabaseHost extends EditRecord
$this->hostUpdateService = $hostUpdateService;
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
TextInput::make('host')
->columnSpan(2)
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(255),
TextInput::make('port')
->columnSpan(1)
->helperText('The port that MySQL is running on for this host.')
->required()
->numeric()
->minValue(0)
->maxValue(65535),
TextInput::make('max_databases')
->label('Max databases')
->helpertext('Blank is unlimited.')
->numeric(),
TextInput::make('name')
->label('Display Name')
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->required()
->maxLength(60),
TextInput::make('username')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->required()
->maxLength(255),
TextInput::make('password')
->helperText('The password for the database user.')
->password()
->revealable()
->maxLength(255),
Select::make('nodes')
->multiple()
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Nodes')
->relationship('nodes', 'name'),
]),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->label(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0 ? 'Database Host Has Databases' : 'Delete')
DeleteAction::make()
->label(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0 ? trans('admin/databasehost.delete_help') : trans('filament-actions::delete.single.modal.actions.delete.label'))
->disabled(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0),
$this->getSaveFormAction()->formId('form'),
];
@@ -120,7 +60,7 @@ class EditDatabaseHost extends EditRecord
return $this->hostUpdateService->handle($record, $data);
} catch (PDOException $exception) {
Notification::make()
->title('Error connecting to database host')
->title(trans('admin/databasehost.error'))
->body($exception->getMessage())
->color('danger')
->icon('tabler-database')

View File

@@ -4,69 +4,17 @@ namespace App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Models\DatabaseHost;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListDatabaseHosts extends ListRecords
{
protected static string $resource = DatabaseHostResource::class;
protected ?string $heading = 'Database Hosts';
public function table(Table $table): Table
{
return $table
->searchable(false)
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('host')
->searchable(),
TextColumn::make('port')
->sortable(),
TextColumn::make('username')
->searchable(),
TextColumn::make('databases_count')
->counts('databases')
->icon('tabler-database')
->label('Databases'),
TextColumn::make('nodes.name')
->icon('tabler-server-2')
->badge()
->placeholder('No Nodes')
->sortable(),
])
->checkIfRecordIsSelectableUsing(fn (DatabaseHost $databaseHost) => !$databaseHost->databases_count)
->actions([
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete databasehost')),
]),
])
->emptyStateIcon('tabler-database')
->emptyStateDescription('')
->emptyStateHeading('No Database Hosts')
->emptyStateActions([
CreateAction::make('create')
->label('Create Database Host')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make('create')
->label('Create Database Host')
CreateAction::make()
->hidden(fn () => DatabaseHost::count() <= 0),
];
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Filament\Admin\Resources\DatabaseHostResource\RelationManagers\DatabasesRelationManager;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewDatabaseHost extends ViewRecord
{
protected static string $resource = DatabaseHostResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
public function getRelationManagers(): array
{
if (DatabasesRelationManager::canViewForRecord($this->getRecord(), static::class)) {
return [
DatabasesRelationManager::class,
];
}
return [];
}
}

View File

@@ -23,19 +23,22 @@ class DatabasesRelationManager extends RelationManager
->schema([
TextInput::make('database')
->columnSpanFull(),
TextInput::make('username'),
TextInput::make('username')
->label(trans('admin/databasehost.table.username')),
TextInput::make('password')
->label(trans('admin/databasehost.table.password'))
->password()
->revealable()
->hintAction(RotateDatabasePasswordAction::make())
->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote')
->label('Connections From')
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
->label(trans('admin/databasehost.table.remote'))
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote),
TextInput::make('max_connections')
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections),
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections),
TextInput::make('jdbc')
->label('JDBC Connection String')
->label(trans('admin/databasehost.table.connection_string'))
->columnSpanFull()
->password()
->revealable()
@@ -47,19 +50,24 @@ class DatabasesRelationManager extends RelationManager
{
return $table
->recordTitleAttribute('servers')
->heading('')
->columns([
TextColumn::make('database')
->icon('tabler-database'),
TextColumn::make('username')
->label(trans('admin/databasehost.table.username'))
->icon('tabler-user'),
TextColumn::make('remote')
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
->label(trans('admin/databasehost.table.remote'))
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote),
TextColumn::make('server.name')
->icon('tabler-brand-docker')
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
TextColumn::make('max_connections')
->formatStateUsing(fn ($record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections),
DateTimeColumn::make('created_at'),
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn ($record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections),
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
])
->actions([
DeleteAction::make()

View File

@@ -19,6 +19,26 @@ class EggResource extends Resource
return static::getModel()::count() ?: null;
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.server');
}
public static function getNavigationLabel(): string
{
return trans('admin/egg.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/egg.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/egg.model_label_plural');
}
public static function getGloballySearchableAttributes(): array
{
return ['name', 'tags', 'uuid', 'id'];

View File

@@ -4,6 +4,8 @@ namespace App\Filament\Admin\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Admin\Resources\EggResource;
use App\Filament\Components\Forms\Fields\CopyFrom;
use App\Models\EggVariable;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Hidden;
@@ -17,10 +19,12 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Unique;
class CreateEgg extends CreateRecord
{
@@ -45,97 +49,101 @@ class CreateEgg extends CreateRecord
return $form
->schema([
Tabs::make()->tabs([
Tab::make('Configuration')
Tab::make(trans('admin/egg.tabs.configuration'))
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->required()
->maxLength(255)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
->helperText(trans('admin/egg.name_help')),
TextInput::make('author')
->label(trans('admin/egg.author'))
->maxLength(255)
->required()
->email()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('The author of this version of the Egg.'),
->helperText(trans('admin/egg.author_help')),
Textarea::make('description')
->rows(3)
->label(trans('admin/egg.description'))
->rows(2)
->columnSpanFull()
->helperText('A description of this Egg that will be displayed throughout the Panel as needed.'),
->helperText(trans('admin/egg.description_help')),
Textarea::make('startup')
->label(trans('admin/egg.startup'))
->rows(3)
->columnSpanFull()
->required()
->placeholder(implode("\n", [
'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}',
]))
->helperText('The default startup command that should be used for new servers using this Egg.'),
TagsInput::make('features')
->placeholder('Add Feature')
->helperText('')
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->label(trans('admin/egg.file_denylist'))
->placeholder('denied-file.txt')
->helperText(trans('admin/egg.file_denylist_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TagsInput::make('features')
->label(trans('admin/egg.features'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1]),
Toggle::make('force_outgoing_ip')
->label(trans('admin/egg.force_ip'))
->hintIcon('tabler-question-mark')
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's primary allocation IP.
Required for certain games to work properly when the Node has multiple public IP addresses.
Enabling this option will disable internal networking for any servers using this egg, causing them to be unable to internally access other servers on the same node."),
->hintIconTooltip(trans('admin/egg.force_ip_help')),
Hidden::make('script_is_privileged')
->default(1),
TagsInput::make('tags')
->placeholder('Add Tags')
->helperText('')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TextInput::make('update_url')
->label(trans('admin/egg.update_url'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('URLs must point directly to the raw .json file.')
->hintIconTooltip(trans('admin/egg.update_url_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->url(),
KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live()
->columnSpanFull()
->required()
->addActionLabel('Add Image')
->keyLabel('Name')
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->keyPlaceholder('Java 21')
->valueLabel('Image URI')
->valueLabel(trans('admin/egg.docker_uri'))
->valuePlaceholder('ghcr.io/parkervcp/yolks:java_21')
->helperText('The docker images available to servers using this egg.'),
->helperText(trans('admin/egg.docker_help')),
]),
Tab::make('Process Management')
Tab::make(trans('admin/egg.tabs.process_management'))
->columns()
->schema([
Hidden::make('config_from')
->default(null)
->label('Copy Settings From')
// ->placeholder('None')
// ->relationship('configFrom', 'name', ignoreRecord: true)
->helperText('If you would like to default to settings from another Egg select it from the menu above.'),
CopyFrom::make('copy_process_from')
->process(),
TextInput::make('config_stop')
->label(trans('admin/egg.stop_command'))
->required()
->maxLength(255)
->label('Stop Command')
->helperText('The command that should be sent to server processes to stop them gracefully. If you need to send a SIGINT you should enter ^C here.'),
->helperText(trans('admin/egg.stop_command_help')),
Textarea::make('config_startup')->rows(10)->json()
->label('Start Configuration')
->label(trans('admin/egg.start_config'))
->default('{}')
->helperText('List of values the daemon should be looking for when booting a server to determine completion.'),
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label('Configuration Files')
->label(trans('admin/egg.config_files'))
->default('{}')
->helperText('This should be a JSON representation of configuration files to modify and what parts should be changed.'),
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label('Log Configuration')
->label(trans('admin/egg.log_config'))
->default('{}')
->helperText('This should be a JSON representation of where log files are stored, and whether or not the daemon should be creating custom logs.'),
->helperText(trans('admin/egg.log_config_help')),
]),
Tab::make('Egg Variables')
Tab::make(trans('admin/egg.tabs.egg_variables'))
->columnSpanFull()
->schema([
Repeater::make('variables')
->label('')
->addActionLabel('Add New Egg Variable')
->addActionLabel(trans('admin/egg.add_new_variable'))
->grid()
->relationship('variables')
->name('name')
@@ -164,31 +172,42 @@ class CreateEgg extends CreateRecord
})
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->live()
->debounce(750)
->maxLength(255)
->columnSpanFull()
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
)
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()))
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')), ignoreRecord: true)
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
])
->required(),
Textarea::make('description')->columnSpanFull(),
Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(),
TextInput::make('env_variable')
->label('Environment Variable')
->label(trans('admin/egg.environment_variable'))
->maxLength(255)
->prefix('{{')
->suffix('}}')
->hintIcon('tabler-code')
->hintIconTooltip(fn ($state) => "{{{$state}}}")
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')), ignoreRecord: true)
->rules(EggVariable::getRulesForField('env_variable'))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
'required' => trans('admin/egg.error_required'),
'*' => trans('admin/egg.error_reserved'),
])
->required(),
TextInput::make('default_value')->maxLength(255),
Fieldset::make('User Permissions')
TextInput::make('default_value')->label(trans('admin/egg.default_value'))->maxLength(255),
Fieldset::make(trans('admin/egg.user_permissions'))
->schema([
Checkbox::make('user_viewable')->label('Viewable'),
Checkbox::make('user_editable')->label('Editable'),
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),
Checkbox::make('user_editable')->label(trans('admin/egg.editable')),
]),
TagsInput::make('rules')
->label(trans('admin/egg.rules'))
->columnSpanFull()
->placeholder('Add Rule')
->reorderable()
->suggestions([
'required',
@@ -212,26 +231,24 @@ class CreateEgg extends CreateRecord
]),
]),
]),
Tab::make('Install Script')
Tab::make(trans('admin/egg.tabs.install_script'))
->columns(3)
->schema([
Hidden::make('copy_script_from'),
//->placeholder('None')
//->relationship('scriptFrom', 'name', ignoreRecord: true),
CopyFrom::make('copy_script_from')
->script(),
TextInput::make('script_container')
->label(trans('admin/egg.script_container'))
->required()
->maxLength(255)
->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->selectablePlaceholder(false)
->default('bash')
->options(['bash', 'ash', '/bin/bash'])
->required(),
MonacoEditor::make('script_install')
->label(trans('admin/egg.script_install'))
->columnSpanFull()
->fontSize('16px')
->language('shell')

View File

@@ -7,7 +7,9 @@ use App\Filament\Admin\Resources\EggResource;
use App\Filament\Admin\Resources\EggResource\RelationManagers\ServersRelationManager;
use App\Filament\Components\Actions\ExportEggAction;
use App\Filament\Components\Actions\ImportEggAction;
use App\Filament\Components\Forms\Fields\CopyFrom;
use App\Models\Egg;
use App\Models\EggVariable;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Fieldset;
@@ -22,8 +24,10 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Validation\Rules\Unique;
class EditEgg extends EditRecord
{
@@ -34,99 +38,98 @@ class EditEgg extends EditRecord
return $form
->schema([
Tabs::make()->tabs([
Tab::make('Configuration')
Tab::make(trans('admin/egg.tabs.configuration'))
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
->icon('tabler-egg')
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->required()
->maxLength(255)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1])
->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
->helperText(trans('admin/egg.name_help')),
TextInput::make('uuid')
->label('Egg UUID')
->label(trans('admin/egg.egg_uuid'))
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->helperText('This is the globally unique identifier for this Egg which Wings uses as an identifier.'),
->helperText(trans('admin/egg.uuid_help')),
TextInput::make('id')
->label('Egg ID')
->label(trans('admin/egg.egg_id'))
->disabled(),
Textarea::make('description')
->label(trans('admin/egg.description'))
->rows(3)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('A description of this Egg that will be displayed throughout the Panel as needed.'),
->helperText(trans('admin/egg.description_help')),
TextInput::make('author')
->label(trans('admin/egg.author'))
->required()
->maxLength(255)
->email()
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('The author of this version of the Egg. Uploading a new Egg configuration from a different author will change this.'),
->helperText(trans('admin/egg.author_help_edit')),
Textarea::make('startup')
->rows(2)
->label(trans('admin/egg.startup'))
->rows(3)
->columnSpanFull()
->required()
->helperText('The default startup command that should be used for new servers using this Egg.'),
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->hidden() // latest wings breaks it.
->label(trans('admin/egg.file_denylist'))
->placeholder('denied-file.txt')
->helperText('A list of files that the end user is not allowed to edit.')
->helperText(trans('admin/egg.file_denylist_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TagsInput::make('features')
->placeholder('Add Feature')
->helperText('')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
->label(trans('admin/egg.features'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1]),
Toggle::make('force_outgoing_ip')
->inline(false)
->label(trans('admin/egg.force_ip'))
->hintIcon('tabler-question-mark')
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's primary allocation IP.
Required for certain games to work properly when the Node has multiple public IP addresses.
Enabling this option will disable internal networking for any servers using this egg, causing them to be unable to internally access other servers on the same node."),
->hintIconTooltip(trans('admin/egg.force_ip_help')),
Hidden::make('script_is_privileged')
->helperText('The docker images available to servers using this egg.'),
TagsInput::make('tags')
->placeholder('Add Tags')
->helperText('')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TextInput::make('update_url')
->label('Update URL')
->label(trans('admin/egg.update_url'))
->url()
->hintIcon('tabler-question-mark')
->hintIconTooltip('URLs must point directly to the raw .json file.')
->hintIconTooltip(trans('admin/egg.update_url_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live()
->columnSpanFull()
->required()
->addActionLabel('Add Image')
->keyLabel('Name')
->valueLabel('Image URI')
->helperText('The docker images available to servers using this egg.'),
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->valueLabel(trans('admin/egg.docker_uri'))
->helperText(trans('admin/egg.docker_help')),
]),
Tab::make('Process Management')
Tab::make(trans('admin/egg.tabs.process_management'))
->columns()
->icon('tabler-server-cog')
->schema([
Select::make('config_from')
->label('Copy Settings From')
->placeholder('None')
->relationship('configFrom', 'name', ignoreRecord: true)
->helperText('If you would like to default to settings from another Egg select it from the menu above.'),
CopyFrom::make('copy_process_from')
->process(),
TextInput::make('config_stop')
->label(trans('admin/egg.stop_command'))
->maxLength(255)
->label('Stop Command')
->helperText('The command that should be sent to server processes to stop them gracefully. If you need to send a SIGINT you should enter ^C here.'),
->helperText(trans('admin/egg.stop_command_help')),
Textarea::make('config_startup')->rows(10)->json()
->label('Start Configuration')
->helperText('List of values the daemon should be looking for when booting a server to determine completion.'),
->label(trans('admin/egg.start_config'))
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label('Configuration Files')
->helperText('This should be a JSON representation of configuration files to modify and what parts should be changed.'),
->label(trans('admin/egg.config_files'))
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label('Log Configuration')
->helperText('This should be a JSON representation of where log files are stored, and whether or not the daemon should be creating custom logs.'),
->label(trans('admin/egg.log_config'))
->helperText(trans('admin/egg.log_config_help')),
]),
Tab::make('Egg Variables')
Tab::make(trans('admin/egg.tabs.egg_variables'))
->columnSpanFull()
->icon('tabler-variable')
->schema([
@@ -138,7 +141,7 @@ class EditEgg extends EditRecord
->reorderable()
->collapsible()->collapsed()
->orderColumn()
->addActionLabel('New Variable')
->addActionLabel(trans('admin/egg.add_new_variable'))
->itemLabel(fn (array $state) => $state['name'])
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['default_value'] ??= '';
@@ -160,31 +163,42 @@ class EditEgg extends EditRecord
})
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->live()
->debounce(750)
->maxLength(255)
->columnSpanFull()
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
)
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()))
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')), ignoreRecord: true)
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
])
->required(),
Textarea::make('description')->columnSpanFull(),
Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(),
TextInput::make('env_variable')
->label('Environment Variable')
->label(trans('admin/egg.environment_variable'))
->maxLength(255)
->prefix('{{')
->suffix('}}')
->hintIcon('tabler-code')
->hintIconTooltip(fn ($state) => "{{{$state}}}")
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')), ignoreRecord: true)
->rules(EggVariable::getRulesForField('env_variable'))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
'required' => trans('admin/egg.error_required'),
'*' => trans('admin/egg.error_reserved'),
])
->required(),
TextInput::make('default_value')->maxLength(255),
Fieldset::make('User Permissions')
TextInput::make('default_value')->label(trans('admin/egg.default_value'))->maxLength(255),
Fieldset::make(trans('admin/egg.user_permissions'))
->schema([
Checkbox::make('user_viewable')->label('Viewable'),
Checkbox::make('user_editable')->label('Editable'),
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),
Checkbox::make('user_editable')->label(trans('admin/egg.editable')),
]),
TagsInput::make('rules')
->label(trans('admin/egg.rules'))
->columnSpanFull()
->placeholder('Add Rule')
->reorderable()
->suggestions([
'required',
@@ -208,23 +222,24 @@ class EditEgg extends EditRecord
]),
]),
]),
Tab::make('Install Script')
Tab::make(trans('admin/egg.tabs.install_script'))
->columns(3)
->icon('tabler-file-download')
->schema([
Select::make('copy_script_from')
->placeholder('None')
->relationship('scriptFrom', 'name', ignoreRecord: true),
CopyFrom::make('copy_script_from')
->script(),
TextInput::make('script_container')
->label(trans('admin/egg.script_container'))
->required()
->maxLength(255)
->default('alpine:3.4'),
TextInput::make('script_entry')
->required()
->maxLength(255)
->default('ash'),
->placeholder('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->selectablePlaceholder(false)
->options(['bash', 'ash', '/bin/bash'])
->required(),
MonacoEditor::make('script_install')
->label('Install Script')
->label(trans('admin/egg.script_install'))
->placeholderText('')
->columnSpanFull()
->fontSize('16px')
@@ -240,9 +255,10 @@ class EditEgg extends EditRecord
return [
DeleteAction::make()
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'),
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use')),
ExportEggAction::make(),
ImportEggAction::make(),
ImportEggAction::make()
->multiple(false),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@@ -10,12 +10,13 @@ use App\Filament\Components\Tables\Actions\UpdateEggAction;
use App\Models\Egg;
use Filament\Actions\CreateAction as CreateHeaderAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ReplicateAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class ListEggs extends ListRecords
{
@@ -32,6 +33,7 @@ class ListEggs extends ListRecords
->label('Id')
->hidden(),
TextColumn::make('name')
->label(trans('admin/egg.name'))
->icon('tabler-egg')
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
->wrap()
@@ -40,35 +42,50 @@ class ListEggs extends ListRecords
TextColumn::make('servers_count')
->counts('servers')
->icon('tabler-server')
->label('Servers'),
->label(trans('admin/egg.servers')),
])
->actions([
EditAction::make(),
ExportEggAction::make(),
UpdateEggAction::make(),
EditAction::make()
->iconButton()
->tooltip(trans('filament-actions::edit.single.label')),
ExportEggAction::make()
->iconButton()
->tooltip(trans('filament-actions::export.modal.actions.export.label')),
UpdateEggAction::make()
->iconButton()
->tooltip(trans('admin/egg.update')),
ReplicateAction::make()
->iconButton()
->tooltip(trans('filament-actions::replicate.single.label'))
->modal(false)
->excludeAttributes(['author', 'uuid', 'update_url', 'servers_count', 'created_at', 'updated_at'])
->beforeReplicaSaved(function (Egg $replica) {
$replica->author = auth()->user()->email;
$replica->name .= ' Copy';
$replica->uuid = Str::uuid()->toString();
})
->after(fn (Egg $record, Egg $replica) => $record->variables->each(fn ($variable) => $variable->replicate()->fill(['egg_id' => $replica->id])->save()))
->successRedirectUrl(fn (Egg $replica) => EditEgg::getUrl(['record' => $replica])),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete egg')),
]),
->groupedBulkActions([
DeleteBulkAction::make(),
])
->emptyStateIcon('tabler-eggs')
->emptyStateDescription('')
->emptyStateHeading('No Eggs')
->emptyStateHeading(trans('admin/egg.no_eggs'))
->emptyStateActions([
CreateAction::make()
->label('Create Egg'),
ImportEggAction::make(),
CreateAction::make(),
ImportEggAction::make()
->multiple(),
]);
}
protected function getHeaderActions(): array
{
return [
ImportEggHeaderAction::make(),
CreateHeaderAction::make()
->label('Create Egg'),
ImportEggHeaderAction::make()
->multiple(),
CreateHeaderAction::make(),
];
}
}

View File

@@ -16,8 +16,10 @@ class ServersRelationManager extends RelationManager
{
return $table
->recordTitleAttribute('servers')
->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned to this Egg.')
->emptyStateDescription(trans('admin/egg.no_servers'))
->emptyStateHeading(trans('admin/egg.no_servers_help'))
->searchable(false)
->heading(trans('admin/egg.servers'))
->columns([
TextColumn::make('user.username')
->label('Owner')
@@ -25,6 +27,7 @@ class ServersRelationManager extends RelationManager
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->sortable(),
TextColumn::make('name')
->label(trans('admin/server.name'))
->icon('tabler-brand-docker')
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->sortable(),
@@ -32,9 +35,9 @@ class ServersRelationManager extends RelationManager
->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])),
TextColumn::make('image')
->label('Docker Image'),
->label(trans('admin/server.docker_image')),
SelectColumn::make('allocation.id')
->label('Primary Allocation')
->label(trans('admin/server.primary_allocation'))
->options(fn (Server $server) => [$server->allocation->id => $server->allocation->address])
->selectablePlaceholder(false)
->sortable(),

View File

@@ -4,7 +4,20 @@ namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\MountResource\Pages;
use App\Models\Mount;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class MountResource extends Resource
{
@@ -12,20 +25,148 @@ class MountResource extends Resource
protected static ?string $navigationIcon = 'tabler-layers-linked';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return trans('admin/mount.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/mount.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/mount.model_label_plural');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(trans('admin/mount.table.name'))
->description(fn (Mount $mount) => "$mount->source -> $mount->target")
->sortable(),
TextColumn::make('eggs.name')
->icon('tabler-eggs')
->label(trans('admin/mount.eggs'))
->badge()
->placeholder(trans('admin/mount.table.all_eggs')),
TextColumn::make('nodes.name')
->icon('tabler-server-2')
->label(trans('admin/mount.nodes'))
->badge()
->placeholder(trans('admin/mount.table.all_nodes')),
TextColumn::make('read_only')
->label(trans('admin/mount.table.read_only'))
->badge()
->icon(fn ($state) => $state ? 'tabler-writing-off' : 'tabler-writing')
->color(fn ($state) => $state ? 'success' : 'warning')
->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.read_only') : trans('admin/mount.toggles.writeable')),
])
->actions([
ViewAction::make()
->hidden(fn ($record) => static::canEdit($record)),
EditAction::make(),
])
->groupedBulkActions([
DeleteBulkAction::make(),
])
->emptyStateIcon('tabler-layers-linked')
->emptyStateDescription('')
->emptyStateHeading(trans('admin/mount.no_mounts'))
->emptyStateActions([
CreateAction::make(),
]);
}
public static function form(Form $form): Form
{
return $form
->schema([
Section::make()->schema([
TextInput::make('name')
->label(trans('admin/mount.name'))
->required()
->helperText(trans('admin/mount.name_help'))
->maxLength(64),
ToggleButtons::make('read_only')
->label(trans('admin/mount.read_only'))
->helperText(trans('admin/mount.read_only_help'))
->options([
false => trans('admin/mount.toggles.writable'),
true => trans('admin/mount.toggles.read_only'),
])
->icons([
false => 'tabler-writing',
true => 'tabler-writing-off',
])
->colors([
false => 'warning',
true => 'success',
])
->inline()
->default(false)
->required(),
TextInput::make('source')
->label(trans('admin/mount.source'))
->required()
->helperText(trans('admin/mount.source_help'))
->maxLength(255),
TextInput::make('target')
->label(trans('admin/mount.target'))
->required()
->helperText(trans('admin/mount.target_help'))
->maxLength(255),
Textarea::make('description')
->label(trans('admin/mount.description'))
->helperText(trans('admin/mount.description_help'))
->columnSpanFull(),
])->columnSpan(1)->columns([
'default' => 1,
'lg' => 2,
]),
Group::make()->schema([
Section::make()->schema([
Select::make('eggs')->multiple()
->label(trans('admin/mount.eggs'))
->relationship('eggs', 'name')
->preload(),
Select::make('nodes')->multiple()
->label(trans('admin/mount.nodes'))
->relationship('nodes', 'name')
->searchable(['name', 'fqdn'])
->preload(),
]),
])->columns([
'default' => 1,
'lg' => 2,
]),
])->columns([
'default' => 1,
'lg' => 2,
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListMounts::route('/'),
'create' => Pages\CreateMount::route('/create'),
'view' => Pages\ViewMount::route('/{record}'),
'edit' => Pages\EditMount::route('/{record}/edit'),
];
}

View File

@@ -3,14 +3,6 @@
namespace App\Filament\Admin\Resources\MountResource\Pages;
use App\Filament\Admin\Resources\MountResource;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
@@ -33,90 +25,10 @@ class CreateMount extends CreateRecord
return [];
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make()->schema([
TextInput::make('name')
->required()
->helperText('Unique name used to separate this mount from another.')
->maxLength(64),
ToggleButtons::make('read_only')
->label('Read only?')
->helperText('Is the mount read only inside the container?')
->options([
false => 'Writeable',
true => 'Read only',
])
->icons([
false => 'tabler-writing',
true => 'tabler-writing-off',
])
->colors([
false => 'warning',
true => 'success',
])
->inline()
->default(false)
->required(),
TextInput::make('source')
->required()
->helperText('File path on the host system to mount to a container.')
->maxLength(255),
TextInput::make('target')
->required()
->helperText('Where the mount will be accessible inside a container.')
->maxLength(255),
ToggleButtons::make('user_mountable')
->hidden()
->label('User mountable?')
->options([
false => 'No',
true => 'Yes',
])
->icons([
false => 'tabler-user-cancel',
true => 'tabler-user-bolt',
])
->colors([
false => 'success',
true => 'warning',
])
->default(false)
->inline()
->required(),
Textarea::make('description')
->helperText('A longer description for this mount.')
->columnSpanFull(),
Hidden::make('user_mountable')->default(1),
])->columnSpan(1)->columns([
'default' => 1,
'lg' => 2,
]),
Group::make()->schema([
Section::make()->schema([
Select::make('eggs')->multiple()
->relationship('eggs', 'name')
->preload(),
Select::make('nodes')->multiple()
->relationship('nodes', 'name')
->searchable(['name', 'fqdn'])
->preload(),
]),
])->columns([
'default' => 1,
'lg' => 2,
]),
])->columns([
'default' => 1,
'lg' => 2,
]);
}
protected function handleRecordCreation(array $data): Model
{
$data['uuid'] ??= Str::uuid()->toString();
$data['user_mountable'] = 1;
return parent::handleRecordCreation($data);
}

View File

@@ -3,104 +3,17 @@
namespace App\Filament\Admin\Resources\MountResource\Pages;
use App\Filament\Admin\Resources\MountResource;
use Filament\Actions;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditMount extends EditRecord
{
protected static string $resource = MountResource::class;
public function form(Form $form): Form
{
return $form
->schema([
Section::make()->schema([
TextInput::make('name')
->required()
->helperText('Unique name used to separate this mount from another.')
->maxLength(64),
ToggleButtons::make('read_only')
->label('Read only?')
->helperText('Is the mount read only inside the container?')
->options([
false => 'Writeable',
true => 'Read only',
])
->icons([
false => 'tabler-writing',
true => 'tabler-writing-off',
])
->colors([
false => 'warning',
true => 'success',
])
->inline()
->default(false)
->required(),
TextInput::make('source')
->required()
->helperText('File path on the host system to mount to a container.')
->maxLength(255),
TextInput::make('target')
->required()
->helperText('Where the mount will be accessible inside a container.')
->maxLength(255),
ToggleButtons::make('user_mountable')
->hidden()
->label('User mountable?')
->options([
false => 'No',
true => 'Yes',
])
->icons([
false => 'tabler-user-cancel',
true => 'tabler-user-bolt',
])
->colors([
false => 'success',
true => 'warning',
])
->default(false)
->inline()
->required(),
Textarea::make('description')
->helperText('A longer description for this mount.')
->columnSpanFull(),
])->columnSpan(1)->columns([
'default' => 1,
'lg' => 2,
]),
Group::make()->schema([
Section::make()->schema([
Select::make('eggs')->multiple()
->relationship('eggs', 'name')
->preload(),
Select::make('nodes')->multiple()
->relationship('nodes', 'name')
->searchable(['name', 'fqdn'])
->preload(),
]),
])->columns([
'default' => 1,
'lg' => 2,
]),
])->columns([
'default' => 1,
'lg' => 2,
]);
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
DeleteAction::make(),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@@ -4,65 +4,17 @@ namespace App\Filament\Admin\Resources\MountResource\Pages;
use App\Filament\Admin\Resources\MountResource;
use App\Models\Mount;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListMounts extends ListRecords
{
protected static string $resource = MountResource::class;
public function table(Table $table): Table
{
return $table
->searchable(false)
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('source')
->searchable(),
TextColumn::make('target')
->searchable(),
IconColumn::make('read_only')
->icon(fn (bool $state) => $state ? 'tabler-circle-check-filled' : 'tabler-circle-x-filled')
->color(fn (bool $state) => $state ? 'success' : 'danger')
->sortable(),
IconColumn::make('user_mountable')
->hidden()
->icon(fn (bool $state) => $state ? 'tabler-circle-check-filled' : 'tabler-circle-x-filled')
->color(fn (bool $state) => $state ? 'success' : 'danger')
->sortable(),
])
->actions([
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete mount')),
]),
])
->emptyStateIcon('tabler-layers-linked')
->emptyStateDescription('')
->emptyStateHeading('No Mounts')
->emptyStateActions([
CreateAction::make('create')
->label('Create Mount')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->label('Create Mount')
CreateAction::make()
->hidden(fn () => Mount::count() <= 0),
];
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Admin\Resources\MountResource\Pages;
use App\Filament\Admin\Resources\MountResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewMount extends ViewRecord
{
protected static string $resource = MountResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -15,6 +15,26 @@ class NodeResource extends Resource
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return trans('admin/node.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/node.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/node.model_label_plural');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;

View File

@@ -29,7 +29,7 @@ class CreateNode extends CreateRecord
->schema([
Wizard::make([
Step::make('basic')
->label('Basic Settings')
->label(trans('admin/node.tabs.basic_settings'))
->icon('tabler-server')
->columnSpanFull()
->columns([
@@ -45,29 +45,23 @@ class CreateNode extends CreateRecord
->autofocus()
->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
->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) {
if (is_ip($state)) {
if (request()->isSecure()) {
return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses
';
return trans('admin/node.fqdn_help');
}
return '';
}
return "
This is the domain name that points to your node's IP Address.
If you've already set up this, you can verify it by checking the next field!
";
return trans('admin/node.error');
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL';
return trans('admin/node.ssl_ip');
}
return '';
@@ -105,16 +99,16 @@ class CreateNode extends CreateRecord
->hidden(),
ToggleButtons::make('dns')
->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
->label(trans('admin/node.dns'))
->helperText(trans('admin/node.dns_help'))
->disabled()
->inline()
->default(null)
->hint(fn (Get $get) => $get('ip'))
->hintColor('success')
->options([
true => 'Valid',
false => 'Invalid',
true => trans('admin/node.valid'),
false => trans('admin/node.invalid'),
])
->colors([
true => 'success',
@@ -134,8 +128,8 @@ class CreateNode extends CreateRecord
'md' => 1,
'lg' => 1,
])
->label(trans('strings.port'))
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
->label(trans('admin/node.port'))
->helperText(trans('admin/node.port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
@@ -143,7 +137,7 @@ class CreateNode extends CreateRecord
->integer(),
TextInput::make('name')
->label('Display Name')
->label(trans('admin/node.display_name'))
->columnSpan([
'default' => 1,
'sm' => 1,
@@ -151,11 +145,10 @@ class CreateNode extends CreateRecord
'lg' => 2,
])
->required()
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
ToggleButtons::make('scheme')
->label('Communicate over SSL')
->label(trans('admin/node.ssl'))
->columnSpan([
'default' => 1,
'sm' => 1,
@@ -165,11 +158,11 @@ class CreateNode extends CreateRecord
->inline()
->helperText(function (Get $get) {
if (request()->isSecure()) {
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
return new HtmlString(trans('admin/node.panel_on_ssl'));
}
if (is_ip($get('fqdn'))) {
return 'An IP address cannot use SSL.';
return trans('admin/node.ssl_help');
}
return '';
@@ -190,7 +183,7 @@ class CreateNode extends CreateRecord
->default(fn () => request()->isSecure() ? 'https' : 'http'),
]),
Step::make('advanced')
->label('Advanced Settings')
->label(trans('admin/node.tabs.advanced_settings'))
->icon('tabler-server-cog')
->columnSpanFull()
->columns([
@@ -201,14 +194,14 @@ class CreateNode extends CreateRecord
])
->schema([
ToggleButtons::make('maintenance_mode')
->label('Maintenance Mode')->inline()
->label(trans('admin/node.maintenance_mode'))->inline()
->columnSpan(1)
->default(false)
->hinticon('tabler-question-mark')
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
->hintIconTooltip(trans('admin/node.maintenance_mode_help'))
->options([
true => 'Enable',
false => 'Disable',
true => trans('admin/node.enabled'),
false => trans('admin/node.disabled'),
])
->colors([
true => 'danger',
@@ -217,21 +210,23 @@ class CreateNode extends CreateRecord
ToggleButtons::make('public')
->default(true)
->columnSpan(1)
->label('Use Node for deployment?')->inline()
->label(trans('admin/node.use_for_deploy'))->inline()
->options([
true => 'Yes',
false => 'No',
true => trans('admin/node.yes'),
false => trans('admin/node.no'),
])
->colors([
true => 'success',
false => 'danger',
]),
TagsInput::make('tags')
->placeholder('Add Tags')
->label(trans('admin/node.tags'))
->columnSpan(2),
TextInput::make('upload_size')
->label('Upload Limit')
->helperText('Enter the maximum size of files that can be uploaded through the web-based file manager.')
->label(trans('admin/node.upload_limit'))
->helperText(trans('admin/node.upload_limit_help.0'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/node.upload_limit_help.1'))
->columnSpan(1)
->numeric()->required()
->default(256)
@@ -240,7 +235,7 @@ class CreateNode extends CreateRecord
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_sftp')
->columnSpan(1)
->label('SFTP Port')
->label(trans('admin/node.sftp_port'))
->minValue(1)
->maxValue(65535)
->default(2022)
@@ -248,21 +243,22 @@ class CreateNode extends CreateRecord
->integer(),
TextInput::make('daemon_sftp_alias')
->columnSpan(2)
->label('SFTP Alias')
->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'),
->label(trans('admin/node.sftp_alias'))
->helperText(trans('admin/node.sftp_alias_help')),
Grid::make()
->columns(6)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/node.memory'))->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('memory') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
@@ -272,7 +268,7 @@ class CreateNode extends CreateRecord
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->label(trans('admin/node.memory_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
@@ -281,10 +277,8 @@ class CreateNode extends CreateRecord
->required(),
TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label('Overallocate')->inlineLabel()
->label(trans('admin/node.overallocate'))->inlineLabel()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->numeric()
->minValue(-1)
@@ -298,14 +292,15 @@ class CreateNode extends CreateRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_disk')
->label('Disk')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/node.disk'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('disk') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
@@ -315,7 +310,7 @@ class CreateNode extends CreateRecord
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Limit')->inlineLabel()
->label(trans('admin/node.disk_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
@@ -325,9 +320,7 @@ class CreateNode extends CreateRecord
TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan(2)
->numeric()
->minValue(-1)
@@ -341,14 +334,15 @@ class CreateNode extends CreateRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/node.cpu'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
@@ -358,7 +352,7 @@ class CreateNode extends CreateRecord
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->label(trans('admin/node.cpu_limit'))->inlineLabel()
->suffix('%')
->columnSpan(2)
->numeric()
@@ -368,9 +362,7 @@ class CreateNode extends CreateRecord
TextInput::make('cpu_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan(2)
->numeric()
->default(0)
@@ -381,7 +373,7 @@ class CreateNode extends CreateRecord
]),
]),
])->columnSpanFull()
->nextAction(fn (Action $action) => $action->label('Next Step'))
->nextAction(fn (Action $action) => $action->label(trans('admin/node.next_step')))
->submitAction(new HtmlString(Blade::render(<<<'BLADE'
<x-filament::button
type="submit"

View File

@@ -7,6 +7,7 @@ use App\Models\Node;
use App\Services\Helpers\SoftwareVersionService;
use App\Services\Nodes\NodeAutoDeployService;
use App\Services\Nodes\NodeUpdateService;
use Exception;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions;
@@ -25,6 +26,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\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@@ -32,6 +34,15 @@ class EditNode extends EditRecord
{
protected static string $resource = NodeResource::class;
private bool $errored = false;
private NodeUpdateService $nodeUpdateService;
public function boot(NodeUpdateService $nodeUpdateService): void
{
$this->nodeUpdateService = $nodeUpdateService;
}
public function form(Forms\Form $form): Forms\Form
{
return $form->schema([
@@ -46,7 +57,7 @@ class EditNode extends EditRecord
->columnSpanFull()
->tabs([
Tab::make('')
->label('Overview')
->label(trans('admin/node.tabs.overview'))
->icon('tabler-chart-area-line-filled')
->columns([
'default' => 4,
@@ -56,21 +67,21 @@ class EditNode extends EditRecord
])
->schema([
Fieldset::make()
->label('Node Information')
->label(trans('admin/node.node_info'))
->columns(4)
->schema([
Placeholder::make('')
->label('Wings Version')
->content(fn (Node $node, SoftwareVersionService $versionService) => ($node->systemInformation()['version'] ?? 'Unknown') . ' (Latest: ' . $versionService->latestWingsVersion() . ')'),
->label(trans('admin/node.wings_version'))
->content(fn (Node $node, SoftwareVersionService $versionService) => ($node->systemInformation()['version'] ?? trans('admin/node.unknown')) . ' (' . trans('admin/node.latest') . ': ' . $versionService->latestWingsVersion() . ')'),
Placeholder::make('')
->label('CPU Threads')
->label(trans('admin/node.cpu_threads'))
->content(fn (Node $node) => $node->systemInformation()['cpu_count'] ?? 0),
Placeholder::make('')
->label('Architecture')
->content(fn (Node $node) => $node->systemInformation()['architecture'] ?? 'Unknown'),
->label(trans('admin/node.architecture'))
->content(fn (Node $node) => $node->systemInformation()['architecture'] ?? trans('admin/node.unknown')),
Placeholder::make('')
->label('Kernel')
->content(fn (Node $node) => $node->systemInformation()['kernel_version'] ?? 'Unknown'),
->label(trans('admin/node.kernel'))
->content(fn (Node $node) => $node->systemInformation()['kernel_version'] ?? trans('admin/node.unknown')),
]),
View::make('filament.components.node-cpu-chart')
->columnSpan([
@@ -86,9 +97,10 @@ class EditNode extends EditRecord
'md' => 2,
'lg' => 2,
]),
// TODO: Make purdy View::make('filament.components.node-storage-chart')->columnSpan(3),
View::make('filament.components.node-storage-chart')
->columnSpanFull(),
]),
Tab::make('Basic Settings')
Tab::make(trans('admin/node.tabs.basic_settings'))
->icon('tabler-server')
->schema([
TextInput::make('fqdn')
@@ -97,29 +109,23 @@ class EditNode extends EditRecord
->autofocus()
->live(debounce: 1500)
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
->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) {
if (is_ip($state)) {
if (request()->isSecure()) {
return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses.
';
return trans('admin/node.fqdn_help');
}
return '';
}
return "
This is the domain name that points to your node's IP Address.
If you've already set up this, you can verify it by checking the next field!
";
return trans('admin/node.error');
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL!';
return trans('admin/node.ssl_ip');
}
return '';
@@ -155,16 +161,16 @@ class EditNode extends EditRecord
->disabled()
->hidden(),
ToggleButtons::make('dns')
->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
->label(trans('admin/node.dns'))
->helperText(trans('admin/node.dns_help'))
->disabled()
->inline()
->default(null)
->hint(fn (Get $get) => $get('ip'))
->hintColor('success')
->options([
true => 'Valid',
false => 'Invalid',
true => trans('admin/node.valid'),
false => trans('admin/node.invalid'),
])
->colors([
true => 'success',
@@ -173,15 +179,15 @@ class EditNode extends EditRecord
->columnSpan(1),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('strings.port'))
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
->label(trans('admin/node.port'))
->helperText(trans('admin/node.port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer(),
TextInput::make('name')
->label('Display Name')
->label(trans('admin/node.display_name'))
->columnSpan([
'default' => 1,
'sm' => 1,
@@ -189,19 +195,18 @@ class EditNode extends EditRecord
'lg' => 2,
])
->required()
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
ToggleButtons::make('scheme')
->label('Communicate over SSL')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->inline()
->helperText(function (Get $get) {
if (request()->isSecure()) {
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
return new HtmlString(trans('admin/node.panel_on_ssl'));
}
if (is_ip($get('fqdn'))) {
return 'An IP address cannot use SSL.';
return trans('admin/node.ssl_help');
}
return '';
@@ -220,7 +225,8 @@ class EditNode extends EditRecord
'https' => 'tabler-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]),
Tab::make('Advanced Settings')
Tab::make('adv')
->label(trans('admin/node.tabs.advanced_settings'))
->columns([
'default' => 1,
'sm' => 1,
@@ -230,7 +236,7 @@ class EditNode extends EditRecord
->icon('tabler-server-cog')
->schema([
TextInput::make('id')
->label('Node ID')
->label(trans('admin/node.node_id'))
->columnSpan([
'default' => 1,
'sm' => 1,
@@ -245,17 +251,18 @@ class EditNode extends EditRecord
'md' => 2,
'lg' => 2,
])
->label('Node UUID')
->hintAction(CopyAction::make())
->label(trans('admin/node.node_uuid'))
->hintAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->disabled(),
TagsInput::make('tags')
->label(trans('admin/node.tags'))
->placeholder('')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
])
->placeholder('Add Tags'),
]),
TextInput::make('upload_size')
->columnSpan([
'default' => 1,
@@ -263,9 +270,9 @@ class EditNode extends EditRecord
'md' => 2,
'lg' => 1,
])
->label('Upload Limit')
->label(trans('admin/node.upload_limit'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.')
->hintIconTooltip(trans('admin/node.upload_limit_help.0') . trans('admin/node.upload_limit_help.1'))
->numeric()->required()
->minValue(1)
->maxValue(1024)
@@ -277,7 +284,7 @@ class EditNode extends EditRecord
'md' => 1,
'lg' => 3,
])
->label('SFTP Port')
->label(trans('admin/node.sftp_port'))
->minValue(1)
->maxValue(65535)
->default(2022)
@@ -290,8 +297,8 @@ class EditNode extends EditRecord
'md' => 1,
'lg' => 3,
])
->label('SFTP Alias')
->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'),
->label(trans('admin/node.sftp_alias'))
->helperText(trans('admin/node.sftp_alias_help')),
ToggleButtons::make('public')
->columnSpan([
'default' => 1,
@@ -299,10 +306,10 @@ class EditNode extends EditRecord
'md' => 1,
'lg' => 3,
])
->label('Use Node for deployment?')->inline()
->label(trans('admin/node.use_for_deploy'))->inline()
->options([
true => 'Yes',
false => 'No',
true => trans('admin/node.yes'),
false => trans('admin/node.no'),
])
->colors([
true => 'success',
@@ -315,12 +322,12 @@ class EditNode extends EditRecord
'md' => 1,
'lg' => 3,
])
->label('Maintenance Mode')->inline()
->label(trans('admin/node.maintenance_mode'))->inline()
->hinticon('tabler-question-mark')
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
->hintIconTooltip(trans('admin/node.maintenance_mode_help'))
->options([
false => 'Disable',
true => 'Enable',
true => trans('admin/node.enabled'),
false => trans('admin/node.disabled'),
])
->colors([
false => 'success',
@@ -336,14 +343,15 @@ class EditNode extends EditRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/node.memory'))->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('memory') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
@@ -358,7 +366,7 @@ class EditNode extends EditRecord
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->label(trans('admin/node.memory_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
->columnSpan([
@@ -371,11 +379,9 @@ class EditNode extends EditRecord
->minValue(0),
TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label('Overallocate')->inlineLabel()
->label(trans('admin/node.overallocate'))->inlineLabel()
->required()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan([
'default' => 1,
'sm' => 1,
@@ -396,14 +402,15 @@ class EditNode extends EditRecord
])
->schema([
ToggleButtons::make('unlimited_disk')
->label('Disk')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/node.disk'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('disk') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
@@ -418,7 +425,7 @@ class EditNode extends EditRecord
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Limit')->inlineLabel()
->label(trans('admin/node.disk_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
->columnSpan([
@@ -432,9 +439,7 @@ class EditNode extends EditRecord
TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan([
'default' => 1,
'sm' => 1,
@@ -452,14 +457,15 @@ class EditNode extends EditRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/node.cpu'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
@@ -469,7 +475,7 @@ class EditNode extends EditRecord
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->label(trans('admin/node.cpu_limit'))->inlineLabel()
->suffix('%')
->required()
->columnSpan(2)
@@ -478,9 +484,7 @@ class EditNode extends EditRecord
TextInput::make('cpu_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan(2)
->required()
->numeric()
@@ -489,28 +493,28 @@ class EditNode extends EditRecord
->suffix('%'),
]),
]),
Tab::make('Configuration File')
Tab::make('Config')
->label(trans('admin/node.tabs.config_file'))
->icon('tabler-code')
->schema([
Placeholder::make('instructions')
->label(trans('admin/node.instructions'))
->columnSpanFull()
->content(new HtmlString('
Save this file to your <span title="usually /etc/pelican/">daemon\'s root directory</span>, named <code>config.yml</code>
')),
->content(new HtmlString(trans('admin/node.instructions_help'))),
Textarea::make('config')
->label('/etc/pelican/config.yml')
->disabled()
->rows(19)
->hintAction(CopyAction::make())
->hintAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->columnSpanFull(),
Grid::make()
->columns()
->schema([
FormActions::make([
FormActions\Action::make('autoDeploy')
->label('Auto Deploy Command')
->label(trans('admin/node.auto_deploy'))
->color('primary')
->modalHeading('Auto Deploy Command')
->modalHeading(trans('admin/node.auto_deploy'))
->icon('tabler-rocket')
->modalSubmitAction(false)
->modalCancelAction(false)
@@ -519,13 +523,13 @@ class EditNode extends EditRecord
ToggleButtons::make('docker')
->label('Type')
->live()
->helperText('Choose between Standalone and Docker install.')
->helperText(trans('admin/node.auto_question'))
->inline()
->default(false)
->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state)))
->options([
false => 'Standalone',
true => 'Docker',
false => trans('admin/node.standalone'),
true => trans('admin/node.docker'),
])
->colors([
false => 'primary',
@@ -533,27 +537,26 @@ class EditNode extends EditRecord
])
->columnSpan(1),
Textarea::make('generatedToken')
->label('To auto-configure your node run the following command:')
->label(trans('admin/node.auto_command'))
->readOnly()
->autosize()
->hintAction(fn (string $state) => CopyAction::make()->copyable($state))
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))),
])
->mountUsing(function (Forms\Form $form) {
Notification::make()->success()->title('Autodeploy Generated')->send();
$form->fill();
}),
])->fullWidth(),
FormActions::make([
FormActions\Action::make('resetKey')
->label('Reset Daemon Token')
->label(trans('admin/node.reset_token'))
->color('danger')
->requiresConfirmation()
->modalHeading('Reset Daemon Token?')
->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.')
->action(function (NodeUpdateService $nodeUpdateService, Node $node) {
$nodeUpdateService->handle($node, [], true);
Notification::make()->success()->title('Daemon Key Reset')->send();
->modalHeading(trans('admin/node.reset_token'))
->modalDescription(trans('admin/node.reset_help'))
->action(function (Node $node) {
$this->nodeUpdateService->handle($node, [], true);
Notification::make()->success()->title(trans('admin/node.token_reset'))->send();
$this->fillForm();
}),
])->fullWidth(),
@@ -582,6 +585,39 @@ 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 [];
@@ -592,7 +628,7 @@ class EditNode extends EditRecord
return [
Actions\DeleteAction::make()
->disabled(fn (Node $node) => $node->servers()->count() > 0)
->label(fn (Node $node) => $node->servers()->count() > 0 ? 'Node Has Servers' : 'Delete'),
->label(fn (Node $node) => $node->servers()->count() > 0 ? trans('admin/node.node_has_servers') : trans('filament-actions::delete.single.label')),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@@ -12,7 +12,6 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Number;
class ListNodes extends ListRecords
{
@@ -30,35 +29,16 @@ class ListNodes extends ListRecords
->hidden(),
NodeHealthColumn::make('health'),
TextColumn::make('name')
->label(trans('admin/node.table.name'))
->icon('tabler-server-2')
->sortable()
->searchable(),
TextColumn::make('fqdn')
->visibleFrom('md')
->label('Address')
->label(trans('admin/node.table.address'))
->icon('tabler-network')
->sortable()
->searchable(),
TextColumn::make('memory')
->visibleFrom('sm')
->icon('tabler-device-desktop-analytics')
->numeric()
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => Number::format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), maxPrecision: 2, locale: auth()->user()->language))
->sortable(),
TextColumn::make('disk')
->visibleFrom('sm')
->icon('tabler-file')
->numeric()
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => Number::format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), maxPrecision: 2, locale: auth()->user()->language))
->sortable(),
TextColumn::make('cpu')
->visibleFrom('sm')
->icon('tabler-cpu')
->numeric()
->suffix(' %')
->sortable(),
IconColumn::make('scheme')
->visibleFrom('xl')
->label('SSL')
@@ -66,13 +46,14 @@ class ListNodes extends ListRecords
->falseIcon('tabler-lock-open-off')
->state(fn (Node $node) => $node->scheme === 'https'),
IconColumn::make('public')
->label(trans('admin/node.table.public'))
->visibleFrom('lg')
->trueIcon('tabler-eye-check')
->falseIcon('tabler-eye-cancel'),
TextColumn::make('servers_count')
->visibleFrom('sm')
->counts('servers')
->label('Servers')
->label(trans('admin/node.table.servers'))
->sortable()
->icon('tabler-brand-docker'),
])
@@ -81,11 +62,9 @@ class ListNodes extends ListRecords
])
->emptyStateIcon('tabler-server-2')
->emptyStateDescription('')
->emptyStateHeading('No Nodes')
->emptyStateHeading(trans('admin/node.no_nodes'))
->emptyStateActions([
CreateAction::make('create')
->label('Create Node')
->button(),
CreateAction::make(),
]);
}
@@ -93,7 +72,6 @@ class ListNodes extends ListRecords
{
return [
Actions\CreateAction::make()
->label('Create Node')
->hidden(fn () => Node::count() <= 0),
];
}

View File

@@ -9,17 +9,16 @@ use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
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\DeleteBulkAction;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Node getOwnerRecord()
@@ -30,14 +29,10 @@ class AllocationsRelationManager extends RelationManager
protected static ?string $icon = 'tabler-plug-connected';
public function form(Form $form): Form
public function setTitle(): string
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
return trans('admin/server.allocations');
}
public function table(Table $table): Table
@@ -50,8 +45,9 @@ class AllocationsRelationManager extends RelationManager
// All assigned allocations
->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null)
->paginationPageOptions(['10', '20', '50', '100', '200', '500', '1000'])
->paginationPageOptions(['10', '20', '50', '100', '200', '500'])
->searchable()
->heading('')
->selectCurrentPageOnly() //Prevent people from trying to nuke 30,000 ports at once.... -,-
->columns([
TextColumn::make('id')
@@ -59,46 +55,44 @@ class AllocationsRelationManager extends RelationManager
->toggledHiddenByDefault(),
TextColumn::make('port')
->searchable()
->label('Port'),
->label(trans('admin/node.ports')),
TextColumn::make('server.name')
->label('Server')
->label(trans('admin/node.table.servers'))
->icon('tabler-brand-docker')
->visibleFrom('md')
->searchable()
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
TextInputColumn::make('ip_alias')
->searchable()
->label('Alias'),
TextInputColumn::make('ip')
->label(trans('admin/node.table.alias')),
SelectColumn::make('ip')
->options(fn (Allocation $allocation) => collect($this->getOwnerRecord()->ipAddresses())->merge([$allocation->ip])->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->selectablePlaceholder(false)
->searchable()
->label('IP'),
->label(trans('admin/node.table.ip')),
])
->headerActions([
Tables\Actions\Action::make('create new allocation')->label('Create Allocations')
Tables\Actions\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('IP Address')
->label(trans('admin/node.ip_address'))
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->helperText(trans('admin/node.ip_help'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->live()
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->label(trans('admin/node.table.alias'))
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->helperText(trans('admin/node.alias_help'))
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->placeholder('27015, 27017-27019')
->label(trans('admin/node.ports'))
->inlineLabel()
->live()
->disabled(fn (Get $get) => empty($get('allocation_ip')))
@@ -113,7 +107,7 @@ class AllocationsRelationManager extends RelationManager
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete allocation')),
->authorize(fn () => auth()->user()->can('update node')),
]),
]);
}

View File

@@ -14,41 +14,49 @@ class NodesRelationManager extends RelationManager
protected static ?string $icon = 'tabler-brand-docker';
public function setTitle(): string
{
return trans('admin/node.table.servers');
}
public function table(Table $table): Table
{
return $table
->searchable(false)
->heading('')
->columns([
TextColumn::make('user.username')
->label('Owner')
->label(trans('admin/node.table.owner'))
->icon('tabler-user')
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->searchable(),
TextColumn::make('name')
->label(trans('admin/node.table.name'))
->icon('tabler-brand-docker')
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->searchable()
->sortable(),
TextColumn::make('egg.name')
->label(trans('admin/node.table.egg'))
->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->user]))
->sortable(),
SelectColumn::make('allocation.id')
->label('Primary Allocation')
->label(trans('admin/node.primary_allocation'))
->options(fn (Server $server) => [$server->allocation->id => $server->allocation->address])
->selectablePlaceholder(false)
->sortable(),
TextColumn::make('memory')->icon('tabler-device-desktop-analytics'),
TextColumn::make('cpu')->icon('tabler-cpu'),
TextColumn::make('memory')->label(trans('admin/node.memory'))->icon('tabler-device-desktop-analytics'),
TextColumn::make('cpu')->label(trans('admin/node.cpu'))->icon('tabler-cpu'),
TextColumn::make('databases_count')
->counts('databases')
->label('Databases')
->label(trans('admin/node.databases'))
->icon('tabler-database')
->numeric()
->sortable(),
TextColumn::make('backups_count')
->counts('backups')
->label('Backups')
->label(trans('admin/node.backups'))
->icon('tabler-file-download')
->numeric()
->sortable(),

View File

@@ -6,7 +6,6 @@ use App\Models\Node;
use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
class NodeCpuChart extends ChartWidget
@@ -15,15 +14,13 @@ class NodeCpuChart extends ChartWidget
protected static ?string $maxHeight = '300px';
public ?Model $record = null;
public Node $node;
protected function getData(): array
{
/** @var Node $node */
$node = $this->record;
$threads = $node->systemInformation()['cpu_count'] ?? 0;
$threads = $this->node->systemInformation()['cpu_count'] ?? 0;
$cpu = collect(cache()->get("nodes.$node->id.cpu_percent"))
$cpu = collect(cache()->get("nodes.{$this->node->id}.cpu_percent"))
->slice(-10)
->map(fn ($value, $key) => [
'cpu' => Number::format($value * $threads, maxPrecision: 2),
@@ -72,13 +69,11 @@ class NodeCpuChart extends ChartWidget
public function getHeading(): string
{
/** @var Node $node */
$node = $this->record;
$threads = $node->systemInformation()['cpu_count'] ?? 0;
$threads = $this->node->systemInformation()['cpu_count'] ?? 0;
$cpu = Number::format(collect(cache()->get("nodes.$node->id.cpu_percent"))->last() * $threads, maxPrecision: 2, locale: auth()->user()->language);
$max = Number::format($threads * 100, locale: auth()->user()->language) . '%';
$cpu = Number::format(collect(cache()->get("nodes.{$this->node->id}.cpu_percent"))->last() * $threads, maxPrecision: 2, locale: auth()->user()->language);
$max = Number::format($threads * 100, locale: auth()->user()->language);
return 'CPU - ' . $cpu . '% Of ' . $max;
return trans('admin/node.cpu_chart', ['cpu' => $cpu, 'max' => $max]);
}
}

View File

@@ -6,7 +6,6 @@ use App\Models\Node;
use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
class NodeMemoryChart extends ChartWidget
@@ -15,14 +14,11 @@ class NodeMemoryChart extends ChartWidget
protected static ?string $maxHeight = '300px';
public ?Model $record = null;
public Node $node;
protected function getData(): array
{
/** @var Node $node */
$node = $this->record;
$memUsed = collect(cache()->get("nodes.$node->id.memory_used"))->slice(-10)
$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),
'timestamp' => Carbon::createFromTimestamp($key, auth()->user()->timezone ?? 'UTC')->format('H:i:s'),
@@ -70,10 +66,8 @@ class NodeMemoryChart extends ChartWidget
public function getHeading(): string
{
/** @var Node $node */
$node = $this->record;
$latestMemoryUsed = collect(cache()->get("nodes.$node->id.memory_used"))->last();
$totalMemory = collect(cache()->get("nodes.$node->id.memory_total"))->last();
$latestMemoryUsed = collect(cache()->get("nodes.{$this->node->id}.memory_used"))->last();
$totalMemory = collect(cache()->get("nodes.{$this->node->id}.memory_total"))->last();
$used = config('panel.use_binary_prefix')
? Number::format($latestMemoryUsed / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
@@ -83,6 +77,6 @@ class NodeMemoryChart extends ChartWidget
? Number::format($totalMemory / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: Number::format($totalMemory / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
return 'Memory - ' . $used . ' Of ' . $total;
return trans('admin/node.memory_chart', ['used' => $used, 'total' => $total]);
}
}

View File

@@ -4,17 +4,15 @@ namespace App\Filament\Admin\Resources\NodeResource\Widgets;
use App\Models\Node;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
class NodeStorageChart extends ChartWidget
{
protected static ?string $heading = 'Storage';
protected static ?string $pollingInterval = '360s';
protected static ?string $pollingInterval = '60s';
protected static ?string $maxHeight = '200px';
protected static ?string $maxHeight = '300px';
public ?Model $record = null;
public Node $node;
protected static ?array $options = [
'scales' => [
@@ -39,25 +37,31 @@ class NodeStorageChart extends ChartWidget
protected function getData(): array
{
/** @var Node $node */
$node = $this->record;
$total = config('panel.use_binary_prefix')
? ($this->node->statistics()['disk_total']) / 1024 / 1024 / 1024
: ($this->node->statistics()['disk_total']) / 1000 / 1000 / 1000;
$used = config('panel.use_binary_prefix')
? ($this->node->statistics()['disk_used']) / 1024 / 1024 / 1024
: ($this->node->statistics()['disk_used']) / 1000 / 1000 / 1000;
$total = ($node->statistics()['disk_total'] ?? 0) / 1024 / 1024 / 1024;
$used = ($node->statistics()['disk_used'] ?? 0) / 1024 / 1024 / 1024;
$unused = $total - $used;
$used = Number::format($used, maxPrecision: 2);
$unused = Number::format($unused, maxPrecision: 2);
return [
'datasets' => [
[
'data' => [$used, $unused],
'backgroundColor' => [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(59, 130, 246)',
'rgb(74, 222, 128)',
'rgb(255, 205, 86)',
],
],
],
'labels' => ['Used', 'Unused'],
'labels' => [trans('admin/node.used'), trans('admin/node.unused')],
'locale' => auth()->user()->language ?? 'en',
];
}
@@ -65,4 +69,12 @@ class NodeStorageChart extends ChartWidget
{
return 'pie';
}
public function getHeading(): string
{
$used = convert_bytes_to_readable($this->node->statistics()['disk_used']);
$total = convert_bytes_to_readable($this->node->statistics()['disk_total']);
return trans('admin/node.disk_chart', ['used' => $used, 'total' => $total]);
}
}

View File

@@ -16,7 +16,14 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Spatie\Permission\Contracts\Permission;
class RoleResource extends Resource
{
@@ -24,15 +31,68 @@ class RoleResource extends Resource
protected static ?string $navigationIcon = 'tabler-users-group';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return trans('admin/role.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/role.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/role.model_label_plural');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(trans('admin/role.name'))
->sortable()
->searchable(),
TextColumn::make('permissions_count')
->label(trans('admin/role.permissions'))
->badge()
->counts('permissions')
->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? trans('admin/role.all') : $state),
TextColumn::make('users_count')
->label(trans('admin/role.users'))
->counts('users')
->icon('tabler-users'),
])
->actions([
ViewAction::make()
->hidden(fn ($record) => static::canEdit($record)),
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0)
->groupedBulkActions([
DeleteBulkAction::make(),
])
->emptyStateIcon('tabler-users-group')
->emptyStateDescription('')
->emptyStateHeading(trans('admin/role.no_roles'))
->emptyStateActions([
CreateAction::make(),
]);
}
public static function form(Form $form): Form
{
$permissions = [];
@@ -67,7 +127,7 @@ class RoleResource extends Resource
->columns(1)
->schema([
TextInput::make('name')
->label('Role Name')
->label(trans('admin/role.name'))
->required()
->disabled(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
TextInput::make('guard_name')
@@ -75,29 +135,33 @@ class RoleResource extends Resource
->default(Role::DEFAULT_GUARD_NAME)
->nullable()
->hidden(),
Fieldset::make('Permissions')
Fieldset::make(trans('admin/role.permissions'))
->columns(3)
->schema($permissions)
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Placeholder::make('permissions')
->content('The Root Admin has all 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),
]);
}
/**
* @param string[]|int[]|Permission[]|\BackedEnum[] $options
*/
private static function makeSection(string $model, array $options): Section
{
$icon = null;
if (class_exists('\App\Filament\Resources\\' . $model . 'Resource')) {
$icon = ('\App\Filament\Resources\\' . $model . 'Resource')::getNavigationIcon();
} elseif (class_exists('\App\Filament\Pages\\' . $model)) {
$icon = ('\App\Filament\Pages\\' . $model)::getNavigationIcon();
if (class_exists('\App\Filament\Admin\Resources\\' . $model . 'Resource')) {
$icon = ('\App\Filament\Admin\Resources\\' . $model . 'Resource')::getNavigationIcon();
} elseif (class_exists('\App\Filament\Admin\Pages\\' . $model)) {
$icon = ('\App\Filament\Admin\Pages\\' . $model)::getNavigationIcon();
} elseif (class_exists('\App\Filament\Server\Resources\\' . $model . 'Resource')) {
$icon = ('\App\Filament\Server\Resources\\' . $model . 'Resource')::getNavigationIcon();
}
return Section::make(Str::headline(Str::plural($model)))
return Section::make(Str::headline($model))
->columnSpan(1)
->collapsible()
->collapsed()
@@ -143,6 +207,7 @@ class RoleResource extends Resource
return [
'index' => Pages\ListRoles::route('/'),
'create' => Pages\CreateRole::route('/create'),
'view' => Pages\ViewRole::route('/{record}'),
'edit' => Pages\EditRole::route('/{record}/edit'),
];
}

View File

@@ -50,7 +50,12 @@ class EditRole extends EditRecord
return [
DeleteAction::make()
->disabled(fn (Role $role) => $role->isRootAdmin() || $role->users_count >= 1)
->label(fn (Role $role) => $role->isRootAdmin() ? 'Can\'t delete Root Admin' : ($role->users_count >= 1 ? 'In Use' : 'Delete')),
->label(fn (Role $role) => $role->isRootAdmin() ? trans('admin/role.root_admin_delete') : ($role->users_count >= 1 ? trans('admin/role.in_use') : trans('filament-actions::delete.single.label'))), $this->getSaveFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -3,66 +3,17 @@
namespace App\Filament\Admin\Resources\RoleResource\Pages;
use App\Filament\Admin\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction as CreateActionTable;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->sortable()
->searchable(),
TextColumn::make('guard_name')
->hidden()
->sortable()
->searchable(),
TextColumn::make('permissions_count')
->label('Permissions')
->badge()
->counts('permissions')
->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? 'All' : $state),
TextColumn::make('users_count')
->label('Users')
->counts('users')
->icon('tabler-users'),
])
->actions([
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0)
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete role')),
]),
])
->emptyStateIcon('tabler-users-group')
->emptyStateDescription('')
->emptyStateHeading('No Roles')
->emptyStateActions([
CreateActionTable::make('create')
->label('Create Role')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create Role'),
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Admin\Resources\RoleResource\Pages;
use App\Filament\Admin\Resources\RoleResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewRole extends ViewRecord
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -14,6 +14,26 @@ class ServerResource extends Resource
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return trans('admin/server.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/server.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/server.model_label_plural');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;

View File

@@ -65,7 +65,7 @@ class CreateServer extends CreateRecord
->schema([
Wizard::make([
Step::make('Information')
->label('Information')
->label(trans('admin/server.tabs.information'))
->icon('tabler-info-circle')
->completedIcon('tabler-check')
->columns([
@@ -76,7 +76,7 @@ class CreateServer extends CreateRecord
->schema([
TextInput::make('name')
->prefixIcon('tabler-server')
->label('Name')
->label(trans('admin/server.name'))
->suffixAction(Forms\Components\Actions\Action::make('random')
->icon('tabler-dice-' . random_int(1, 6))
->action(function (Set $set, Get $get) {
@@ -96,18 +96,19 @@ class CreateServer extends CreateRecord
->maxLength(255),
TextInput::make('external_id')
->label('External ID')
->label(trans('admin/server.external_id'))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->unique()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('node_id')
->disabledOn('edit')
->prefixIcon('tabler-server-2')
->selectablePlaceholder(false)
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
->columnSpan([
'default' => 1,
@@ -127,8 +128,9 @@ class CreateServer extends CreateRecord
Select::make('owner_id')
->preload()
->prefixIcon('tabler-user')
->selectablePlaceholder(false)
->default(auth()->user()->id)
->label('Owner')
->label(trans('admin/server.owner'))
->columnSpan([
'default' => 1,
'sm' => 2,
@@ -136,23 +138,26 @@ class CreateServer extends CreateRecord
])
->relationship('user', 'username')
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->isRootAdmin() ? '(admin)' : ''))
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)")
->createOptionForm([
TextInput::make('username')
->label(trans('admin/user.username'))
->alphaNum()
->required()
->minLength(3)
->maxLength(255),
TextInput::make('email')
->label(trans('admin/user.email'))
->email()
->required()
->unique()
->maxLength(255),
TextInput::make('password')
->label(trans('admin/user.password'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->hintIconTooltip(trans('admin/user.password_help'))
->password(),
])
->createOptionUsing(function ($data, UserCreationService $service) {
@@ -166,7 +171,7 @@ class CreateServer extends CreateRecord
->preload()
->live()
->prefixIcon('tabler-network')
->label('Primary Allocation')
->label(trans('admin/server.primary_allocation'))
->columnSpan([
'default' => 1,
'sm' => 2,
@@ -186,10 +191,10 @@ class CreateServer extends CreateRecord
$node = Node::find($get('node_id'));
if ($node?->allocations) {
return 'Select an Allocation';
return trans('admin/server.select_allocation');
}
return 'Create a New Allocation';
return trans('admin/server.new_allocation');
})
->relationship(
'allocation',
@@ -204,32 +209,23 @@ class CreateServer extends CreateRecord
return [
Select::make('allocation_ip')
->options(collect(Node::find($get('node_id'))?->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label('IP Address')
->helperText("Usually your machine's public IP unless you are port forwarding.")
->label(trans('admin/server.ip_address'))->inlineLabel()
->helperText(trans('admin/server.ip_address_helper'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->inlineLabel()
->ipv4()
->live()
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->label(trans('admin/server.alias'))->inlineLabel()
->default(null)
->datalist([
$get('name'),
Egg::find($get('egg_id'))?->name,
])
->helperText('Optional display name to help you remember what these are.')
->required(false),
->helperText(trans('admin/server.alias_helper')),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->label(trans('admin/server.port'))->inlineLabel()
->placeholder('27015, 27017-27019')
->live()
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
@@ -247,7 +243,7 @@ class CreateServer extends CreateRecord
->required(),
Repeater::make('allocation_additional')
->label('Additional Allocations')
->label(trans('admin/server.additional_allocations'))
->columnSpan([
'default' => 1,
'sm' => 2,
@@ -271,7 +267,7 @@ class CreateServer extends CreateRecord
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder('Select additional Allocations')
->placeholder(trans('admin/server.select_additional'))
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->relationship(
'allocations',
@@ -284,18 +280,16 @@ class CreateServer extends CreateRecord
),
Textarea::make('description')
->placeholder('Description')
->label(trans('admin/server.description'))
->rows(3)
->columnSpan([
'default' => 1,
'sm' => 4,
'md' => 4,
])
->label('Description'),
]),
]),
Step::make('Egg Configuration')
->label('Egg Configuration')
Step::make(trans('admin/server.tabs.egg_configuration'))
->icon('tabler-egg')
->completedIcon('tabler-check')
->columns([
@@ -306,6 +300,7 @@ class CreateServer extends CreateRecord
])
->schema([
Select::make('egg_id')
->label(trans('admin/server.name'))
->prefixIcon('tabler-egg')
->relationship('egg', 'name')
->columnSpan([
@@ -346,7 +341,7 @@ class CreateServer extends CreateRecord
->required(),
ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?')
->label(trans('admin/server.install_script'))
->default(false)
->columnSpan([
'default' => 1,
@@ -355,8 +350,8 @@ class CreateServer extends CreateRecord
'lg' => 1,
])
->options([
false => 'Yes',
true => 'Skip',
false => trans('admin/server.yes'),
true => trans('admin/server.skip'),
])
->colors([
false => 'primary',
@@ -370,7 +365,7 @@ class CreateServer extends CreateRecord
->required(),
ToggleButtons::make('start_on_completion')
->label('Start Server After Install?')
->label(trans('admin/server.start_after'))
->default(true)
->required()
->columnSpan([
@@ -380,8 +375,8 @@ class CreateServer extends CreateRecord
'lg' => 1,
])
->options([
true => 'Yes',
false => 'No',
true => trans('admin/server.yes'),
false => trans('admin/server.no'),
])
->colors([
true => 'primary',
@@ -395,7 +390,7 @@ class CreateServer extends CreateRecord
Textarea::make('startup')
->hintIcon('tabler-code')
->label('Startup Command')
->label(trans('admin/server.startup_cmd'))
->hidden(fn (Get $get) => $get('egg_id') === null)
->required()
->live()
@@ -414,17 +409,17 @@ class CreateServer extends CreateRecord
Hidden::make('environment')->default([]),
Section::make('Variables')
Section::make(trans('admin/server.variables'))
->icon('tabler-eggs')
->iconColor('primary')
->hidden(fn (Get $get) => $get('egg_id') === null)
->collapsible()
->columnSpanFull()
->schema([
Placeholder::make('Select an egg first to show its variables!')
Placeholder::make(trans('admin/server.select_egg'))
->hidden(fn (Get $get) => $get('egg_id')),
Placeholder::make('The selected egg has no variables!')
Placeholder::make(trans('admin/server.no_variables'))
->hidden(fn (Get $get) => !$get('egg_id') ||
Egg::query()->find($get('egg_id'))?->variables()?->count()
),
@@ -486,12 +481,11 @@ class CreateServer extends CreateRecord
->columnSpan(2),
]),
]),
Step::make('Environment Configuration')
->label('Environment Configuration')
Step::make(trans('admin/server.tabs.environment_configuration'))
->icon('tabler-brand-docker')
->completedIcon('tabler-check')
->schema([
Fieldset::make('Resource Limits')
Fieldset::make(trans('admin/server.resource_limits'))
->columnSpan(6)
->columns([
'default' => 1,
@@ -505,13 +499,14 @@ class CreateServer extends CreateRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/server.cpu'))->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->live()
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
@@ -522,27 +517,28 @@ class CreateServer extends CreateRecord
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->label(trans('admin/server.cpu_limit'))->inlineLabel()
->suffix('%')
->default(0)
->required()
->columnSpan(2)
->numeric()
->minValue(0)
->helperText('100% equals one CPU core.'),
->helperText(trans('admin/server.cpu_helper')),
]),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/server.memory'))->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->live()
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
@@ -553,8 +549,10 @@ class CreateServer extends CreateRecord
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->label(trans('admin/server.memory_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->hintIcon('tabler-question-mark')
->hintIconToolTip(trans('admin/server.memory_helper'))
->default(0)
->required()
->columnSpan(2)
@@ -566,13 +564,14 @@ class CreateServer extends CreateRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_disk')
->label('Disk Space')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/server.disk'))->inlineLabel()->inline()
->default(true)
->live()
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
@@ -583,7 +582,7 @@ class CreateServer extends CreateRecord
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->label(trans('admin/server.disk_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0)
->required()
@@ -594,7 +593,7 @@ class CreateServer extends CreateRecord
]),
Fieldset::make('Advanced Limits')
Fieldset::make(trans('admin/server.advanced_limits'))
->columnSpan(6)
->columns([
'default' => 1,
@@ -613,13 +612,13 @@ class CreateServer extends CreateRecord
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
false => trans('admin/server.disabled'),
true => trans('admin/server.enabled'),
])
->colors([
false => 'success',
@@ -630,12 +629,12 @@ class CreateServer extends CreateRecord
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->label(trans('admin/server.threads'))->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
->placeholder(trans('admin/server.pin_help')),
]),
Grid::make()
->columns(4)
@@ -643,7 +642,7 @@ class CreateServer extends CreateRecord
->schema([
ToggleButtons::make('swap_support')
->live()
->label('Swap Memory')
->label(trans('admin/server.swap'))
->inlineLabel()
->inline()
->columnSpan(2)
@@ -659,9 +658,9 @@ class CreateServer extends CreateRecord
$set('swap', $value);
})
->options([
'unlimited' => 'Unlimited',
'limited' => 'Limited',
'disabled' => 'Disabled',
'unlimited' => trans('admin/server.unlimited'),
'limited' => trans('admin/server.limited'),
'disabled' => trans('admin/server.disabled'),
])
->colors([
'unlimited' => 'primary',
@@ -675,7 +674,7 @@ class CreateServer extends CreateRecord
'disabled', 'unlimited' => true,
default => false,
})
->label('Swap Memory')
->label(trans('admin/server.swap_limit'))
->default(0)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->minValue(-1)
@@ -690,25 +689,22 @@ class CreateServer extends CreateRecord
->columnSpanFull()
->schema([
ToggleButtons::make('oom_killer')
->label('OOM Killer')
->label(trans('admin/server.oom'))
->inlineLabel()->inline()
->default(false)
->columnSpan(2)
->options([
false => 'Disabled',
true => 'Enabled',
false => trans('admin/server.disabled'),
true => trans('admin/server.enabled'),
])
->colors([
false => 'success',
true => 'danger',
]),
TextInput::make('oom_disabled_hidden')
->hidden(),
]),
]),
Fieldset::make('Feature Limits')
Fieldset::make(trans('admin/server.feature_limits'))
->inlineLabel()
->columnSpan(6)
->columns([
@@ -719,28 +715,28 @@ class CreateServer extends CreateRecord
])
->schema([
TextInput::make('allocation_limit')
->label('Allocations')
->label(trans('admin/server.allocations'))
->suffixIcon('tabler-network')
->required()
->numeric()
->minValue(0)
->default(0),
TextInput::make('database_limit')
->label('Databases')
->label(trans('admin/server.databases'))
->suffixIcon('tabler-database')
->required()
->numeric()
->minValue(0)
->default(0),
TextInput::make('backup_limit')
->label('Backups')
->label(trans('admin/server.backups'))
->suffixIcon('tabler-copy-check')
->required()
->numeric()
->minValue(0)
->default(0),
]),
Fieldset::make('Docker Settings')
Fieldset::make(trans('admin/server.docker_settings'))
->columns([
'default' => 1,
'sm' => 2,
@@ -750,7 +746,7 @@ class CreateServer extends CreateRecord
->columnSpan(6)
->schema([
Select::make('select_image')
->label('Image Name')
->label(trans('admin/server.image_name'))
->live()
->afterStateUpdated(fn (Set $set, $state) => $set('image', $state))
->options(function ($state, Get $get, Set $set) {
@@ -775,7 +771,7 @@ class CreateServer extends CreateRecord
]),
TextInput::make('image')
->label('Image')
->label(trans('admin/server.image'))
->required()
->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
@@ -787,7 +783,7 @@ class CreateServer extends CreateRecord
$set('select_image', 'ghcr.io/custom-image');
}
})
->placeholder('Enter a custom Image')
->placeholder(trans('admin/server.image_placeholder'))
->columnSpan([
'default' => 1,
'sm' => 2,
@@ -797,23 +793,23 @@ class CreateServer extends CreateRecord
KeyValue::make('docker_labels')
->label('Container Labels')
->keyLabel('Title')
->valueLabel('Description')
->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"]) ?? [])
->label('Mounts')
->helperText(fn () => $this->node?->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
->columnSpanFull(),
]),
]),
])
->columnSpanFull()
->nextAction(fn (Action $action) => $action->label('Next Step'))
->nextAction(fn (Action $action) => $action->label(trans('admin/server.next_step')))
->submitAction(new HtmlString(Blade::render(<<<'BLADE'
<x-filament::button
type="submit"
@@ -843,7 +839,7 @@ class CreateServer extends CreateRecord
return $this->serverCreationService->handle($data);
} catch (Exception $exception) {
Notification::make()
->title('Could not create server')
->title(trans('admin/server.notifications.create_failed'))
->body($exception->getMessage())
->color('danger')
->danger()
@@ -870,6 +866,9 @@ class CreateServer extends CreateRecord
throw new Exception('Component type not supported: ' . $component::class);
}
/**
* @return array<array-key, string>
*/
private function getSelectOptionsFromRules(Get $get): array
{
$inRule = collect($get('rules'))->reduce(
@@ -884,6 +883,10 @@ class CreateServer extends CreateRecord
->all();
}
/**
* @param string[] $portEntries
* @return array<int>
*/
public static function retrieveValidPorts(Node $node, array $portEntries, string $ip): array
{
$portRangeLimit = AssignmentService::PORT_RANGE_LIMIT;
@@ -906,9 +909,9 @@ class CreateServer extends CreateRecord
if (!is_numeric($start) || !is_numeric($end)) {
Notification::make()
->title('Invalid Port Range')
->title(trans('admin/server.notifications.invalid_port_range'))
->danger()
->body("Your port range are not valid integers: $portEntry")
->body(trans('admin/server.notifications.invalid_port_range_body', ['port' => $portEntry]))
->send();
continue;
@@ -920,9 +923,9 @@ class CreateServer extends CreateRecord
if (count($range) > $portRangeLimit) {
Notification::make()
->title('Too many ports at one time!')
->title(trans('admin/server.notifications.too_many_ports'))
->danger()
->body("The current limit is $portRangeLimit number of ports at one time.")
->body(trans('admin/server.notifications.too_many_ports_body', ['limit' => $portRangeLimit]))
->send();
continue;
@@ -932,9 +935,9 @@ class CreateServer extends CreateRecord
// Invalid port number
if ($i <= $portFloor || $i > $portCeil) {
Notification::make()
->title('Port not in valid range')
->title(trans('admin/server.notifications.invalid_port'))
->danger()
->body("$i is not in the valid port range between $portFloor-$portCeil")
->body(trans('admin/server.notifications.invalid_port_body', ['i' => $i, 'portFloor' => $portFloor, 'portCeil' => $portCeil]))
->send();
continue;
@@ -943,9 +946,9 @@ class CreateServer extends CreateRecord
// Already exists
if (in_array($i, $existingPorts)) {
Notification::make()
->title('Port already in use')
->title(trans('admin/server.notifications.already_exists'))
->danger()
->body("$i is already with an allocation")
->body(trans('admin/server.notifications.already_exists_body', ['i' => $i]))
->send();
continue;

View File

@@ -2,10 +2,10 @@
namespace App\Filament\Admin\Resources\ServerResource\Pages;
use App\Enums\ContainerStatus;
use App\Enums\ServerState;
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\Database;
@@ -14,6 +14,8 @@ use App\Models\Egg;
use App\Models\Mount;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Models\User;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Services\Databases\DatabaseManagementService;
use App\Services\Eggs\EggChangerService;
use App\Services\Servers\RandomWordService;
@@ -47,6 +49,8 @@ use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Validator;
use LogicException;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@@ -55,6 +59,15 @@ class EditServer extends EditRecord
{
protected static string $resource = ServerResource::class;
private bool $errored = false;
private DaemonServerRepository $daemonServerRepository;
public function boot(DaemonServerRepository $daemonServerRepository): void
{
$this->daemonServerRepository = $daemonServerRepository;
}
public function form(Form $form): Form
{
return $form
@@ -69,12 +82,12 @@ class EditServer extends EditRecord
])
->columnSpanFull()
->tabs([
Tab::make('Information')
Tab::make(trans('admin/server.tabs.information'))
->icon('tabler-info-circle')
->schema([
TextInput::make('name')
->prefixIcon('tabler-server')
->label('Display Name')
->label(trans('admin/server.name'))
->suffixAction(Action::make('random')
->icon('tabler-dice-' . random_int(1, 6))
->action(function (Set $set, Get $get) {
@@ -96,7 +109,7 @@ class EditServer extends EditRecord
Select::make('owner_id')
->prefixIcon('tabler-user')
->label('Owner')
->label(trans('admin/server.owner'))
->columnSpan([
'default' => 2,
'sm' => 1,
@@ -104,23 +117,17 @@ class EditServer extends EditRecord
'lg' => 2,
])
->relationship('user', 'username')
->searchable()
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)")
->preload()
->required(),
ToggleButtons::make('condition')
->label('Server Status')
->label(trans('admin/server.server_status'))
->formatStateUsing(fn (Server $server) => $server->condition)
->options(fn ($state) => collect(array_merge(ContainerStatus::cases(), ServerState::cases()))
->filter(fn ($condition) => $condition->value === $state)
->mapWithKeys(fn ($state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()])
)
->colors(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->color()]
))
->icons(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->icon()]
))
->options(fn ($state) => [$state->value => $state->getLabel()])
->colors(fn ($state) => [$state->value => $state->getColor()])
->icons(fn ($state) => [$state->value => $state->getIcon()])
->columnSpan([
'default' => 2,
'sm' => 1,
@@ -129,11 +136,12 @@ class EditServer extends EditRecord
]),
Textarea::make('description')
->label('Description')
->label(trans('admin/server.description'))
->columnSpanFull(),
TextInput::make('uuid')
->hintAction(CopyAction::make())
->label(trans('admin/server.uuid'))
->suffixAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->columnSpan([
'default' => 2,
'sm' => 1,
@@ -143,8 +151,8 @@ class EditServer extends EditRecord
->readOnly()
->dehydrated(false),
TextInput::make('uuid_short')
->label('Short UUID')
->hintAction(CopyAction::make())
->label(trans('admin/server.short_uuid'))
->suffixAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->columnSpan([
'default' => 2,
'sm' => 1,
@@ -154,17 +162,17 @@ class EditServer extends EditRecord
->readOnly()
->dehydrated(false),
TextInput::make('external_id')
->label('External ID')
->label(trans('admin/server.external_id'))
->columnSpan([
'default' => 2,
'sm' => 1,
'md' => 2,
'lg' => 3,
])
->unique()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('node_id')
->label('Node')
->label(trans('admin/server.node'))
->relationship('node', 'name')
->columnSpan([
'default' => 2,
@@ -174,10 +182,10 @@ class EditServer extends EditRecord
])
->disabled(),
]),
Tab::make('Environment')
Tab::make(trans('admin/server.tabs.environment_configuration'))
->icon('tabler-brand-docker')
->schema([
Fieldset::make('Resource Limits')
Fieldset::make(trans('admin/server.resource_limits'))
->columns([
'default' => 1,
'sm' => 2,
@@ -190,13 +198,14 @@ class EditServer extends EditRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/server.cpu'))->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
@@ -207,7 +216,7 @@ class EditServer extends EditRecord
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel()
->label(trans('admin/server.cpu_limit'))->inlineLabel()
->suffix('%')
->required()
->columnSpan(2)
@@ -219,13 +228,14 @@ class EditServer extends EditRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/server.memory'))->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->formatStateUsing(fn (Get $get) => $get('memory') == 0)
->live()
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
@@ -236,8 +246,10 @@ class EditServer extends EditRecord
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->label(trans('admin/server.memory_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->hintIcon('tabler-question-mark')
->hintIconToolTip(trans('admin/server.memory_helper'))
->required()
->columnSpan(2)
->numeric()
@@ -249,13 +261,14 @@ class EditServer extends EditRecord
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_disk')
->label('Disk Space')->inlineLabel()->inline()
->dehydrated()
->label(trans('admin/server.disk'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->formatStateUsing(fn (Get $get) => $get('disk') == 0)
->options([
true => 'Unlimited',
false => 'Limited',
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
@@ -266,7 +279,7 @@ class EditServer extends EditRecord
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->label(trans('admin/server.disk_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
->columnSpan(2)
@@ -275,7 +288,7 @@ class EditServer extends EditRecord
]),
]),
Fieldset::make('Advanced Limits')
Fieldset::make(trans('admin/server.advanced_limits'))
->columns([
'default' => 1,
'sm' => 2,
@@ -292,14 +305,14 @@ class EditServer extends EditRecord
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->formatStateUsing(fn (Get $get) => !empty($get('threads')))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
false => trans('admin/server.disabled'),
true => trans('admin/server.enabled'),
])
->colors([
false => 'success',
@@ -310,16 +323,16 @@ class EditServer extends EditRecord
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->label(trans('admin/server.threads'))->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
->placeholder(trans('admin/server.pin_help')),
]),
ToggleButtons::make('swap_support')
->live()
->label('Swap Memory')->inlineLabel()->inline()
->label(trans('admin/server.swap'))->inlineLabel()->inline()
->columnSpan(2)
->afterStateUpdated(function ($state, Set $set) {
$value = match ($state) {
@@ -340,9 +353,9 @@ class EditServer extends EditRecord
};
})
->options([
'unlimited' => 'Unlimited',
'limited' => 'Limited',
'disabled' => 'Disabled',
'unlimited' => trans('admin/server.unlimited'),
'limited' => trans('admin/server.limited'),
'disabled' => trans('admin/server.disabled'),
])
->colors([
'unlimited' => 'primary',
@@ -356,7 +369,7 @@ class EditServer extends EditRecord
'disabled', 'unlimited', true => true,
default => false,
})
->label('Swap Memory')->inlineLabel()
->label(trans('admin/server.swap'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->minValue(-1)
->columnSpan(2)
@@ -373,23 +386,20 @@ class EditServer extends EditRecord
->columnSpanFull()
->schema([
ToggleButtons::make('oom_killer')
->label('OOM Killer')->inlineLabel()->inline()
->label(trans('admin/server.oom'))->inlineLabel()->inline()
->columnSpan(2)
->options([
false => 'Disabled',
true => 'Enabled',
false => trans('admin/server.disabled'),
true => trans('admin/server.enabled'),
])
->colors([
false => 'success',
true => 'danger',
]),
TextInput::make('oom_disabled_hidden')
->hidden(),
]),
]),
Fieldset::make('Feature Limits')
Fieldset::make(trans('admin/server.feature_limits'))
->inlineLabel()
->columns([
'default' => 1,
@@ -399,25 +409,25 @@ class EditServer extends EditRecord
])
->schema([
TextInput::make('allocation_limit')
->label('Allocations')
->label(trans('admin/server.allocations'))
->suffixIcon('tabler-network')
->required()
->minValue(0)
->numeric(),
TextInput::make('database_limit')
->label('Databases')
->label(trans('admin/server.databases'))
->suffixIcon('tabler-database')
->required()
->minValue(0)
->numeric(),
TextInput::make('backup_limit')
->label('Backups')
->label(trans('admin/server.backups'))
->suffixIcon('tabler-copy-check')
->required()
->minValue(0)
->numeric(),
]),
Fieldset::make('Docker Settings')
Fieldset::make(trans('admin/server.docker_settings'))
->columns([
'default' => 1,
'sm' => 2,
@@ -426,7 +436,7 @@ class EditServer extends EditRecord
])
->schema([
Select::make('select_image')
->label('Image Name')
->label(trans('admin/server.image_name'))
->live()
->afterStateUpdated(fn (Set $set, $state) => $set('image', $state))
->options(function ($state, Get $get, Set $set) {
@@ -451,7 +461,7 @@ class EditServer extends EditRecord
]),
TextInput::make('image')
->label('Image')
->label(trans('admin/server.image'))
->required()
->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
@@ -463,7 +473,7 @@ class EditServer extends EditRecord
$set('select_image', 'ghcr.io/custom-image');
}
})
->placeholder('Enter a custom Image')
->placeholder(trans('admin/server.image_placeholder'))
->columnSpan([
'default' => 1,
'sm' => 2,
@@ -472,13 +482,13 @@ class EditServer extends EditRecord
]),
KeyValue::make('docker_labels')
->label('Container Labels')
->keyLabel('Label Name')
->valueLabel('Label Description')
->label(trans('admin/server.container_labels'))
->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description'))
->columnSpanFull(),
]),
]),
Tab::make('Egg')
Tab::make(trans('admin/server.egg'))
->icon('tabler-egg')
->columns([
'default' => 1,
@@ -497,11 +507,13 @@ class EditServer extends EditRecord
'lg' => 4,
])
->relationship('egg', 'name')
->label(trans('admin/server.name'))
->searchable()
->preload()
->required()
->hintAction(
Action::make('change_egg')
->label(trans('admin/server.change_egg'))
->action(function (array $data, Server $server, EggChangerService $service) {
$service->handle($server, $data['egg_id'], $data['keepOldVariables']);
@@ -510,20 +522,20 @@ class EditServer extends EditRecord
})
->form(fn (Server $server) => [
Select::make('egg_id')
->label('New Egg')
->label(trans('admin/server.new_egg'))
->prefixIcon('tabler-egg')
->options(fn () => Egg::all()->filter(fn (Egg $egg) => $egg->id !== $server->egg->id)->mapWithKeys(fn (Egg $egg) => [$egg->id => $egg->name]))
->searchable()
->preload()
->required(),
Toggle::make('keepOldVariables')
->label('Keep old variables if possible?')
->label(trans('admin/server.keep_old_variables'))
->default(true),
])
),
ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?')->inline()
->label(trans('admin/server.install_script'))->inline()
->columnSpan([
'default' => 6,
'sm' => 1,
@@ -531,8 +543,8 @@ class EditServer extends EditRecord
'lg' => 2,
])
->options([
false => 'Yes',
true => 'Skip',
false => trans('admin/server.yes'),
true => trans('admin/server.skip'),
])
->colors([
false => 'primary',
@@ -543,16 +555,18 @@ class EditServer extends EditRecord
true => 'tabler-code-off',
])
->required(),
Hidden::make('previewing')
->default(false),
Textarea::make('startup')
->label('Startup Command')
->label(trans('admin/server.startup_cmd'))
->required()
->columnSpan(6)
->autosize(),
->autosize()
->hintAction(PreviewStartupAction::make('preview')),
Textarea::make('defaultStartup')
->hintAction(CopyAction::make())
->label('Default Startup Command')
->hintAction(fn () => request()->isSecure() ? CopyAction::make() : null)
->label(trans('admin/server.default_startup'))
->disabled()
->autosize()
->columnSpan(6)
@@ -563,6 +577,7 @@ class EditServer extends EditRecord
}),
Repeater::make('server_variables')
->label('')
->relationship('serverVariables', function (Builder $query) {
/** @var Server $server */
$server = $this->getRecord();
@@ -629,64 +644,77 @@ class EditServer extends EditRecord
})
->columnSpan(6),
]),
Tab::make('Mounts')
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"]))
->label('Mounts')
->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : trans('admin/server.no_mounts'))
->columnSpanFull(),
]),
Tab::make('Databases')
Tab::make(trans('admin/server.databases'))
->hidden(fn () => !auth()->user()->can('viewList database'))
->icon('tabler-database')
->columns(4)
->schema([
Repeater::make('databases')
->label('')
->grid()
->helperText(fn (Server $server) => $server->databases->isNotEmpty() ? '' : 'No Databases exist for this Server')
->helperText(fn (Server $server) => $server->databases->isNotEmpty() ? '' : trans('admin/server.no_databases'))
->columns(2)
->schema([
TextInput::make('host')
->label(trans('admin/databasehost.table.host'))
->disabled()
->formatStateUsing(fn ($record) => $record->address())
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpan(1),
TextInput::make('database')
->columnSpan(2)
->label('Database Name')
->label(trans('admin/databasehost.table.database'))
->disabled()
->formatStateUsing(fn ($record) => $record->database)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->hintAction(
Action::make('Delete')
->label(trans('filament-actions::delete.single.modal.actions.delete.label'))
->authorize(fn (Database $database) => auth()->user()->can('delete database', $database))
->color('danger')
->icon('tabler-trash')
->requiresConfirmation()
->modalIcon('tabler-database-x')
->modalHeading('Delete Database?')
->modalHeading(trans('admin/server.delete_db_heading'))
->modalSubmitActionLabel(fn (Get $get) => 'Delete ' . $get('database') . '?')
->modalDescription(fn (Get $get) => 'Are you sure you want to delete ' . $get('database') . '?')
->modalDescription(fn (Get $get) => trans('admin/server.delete_db') . $get('database') . '?')
->action(function (DatabaseManagementService $databaseManagementService, $record) {
$databaseManagementService->delete($record);
$this->fillForm();
})
),
TextInput::make('username')
->label(trans('admin/databasehost.table.username'))
->disabled()
->formatStateUsing(fn ($record) => $record->username)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpan(1),
TextInput::make('password')
->label(trans('admin/databasehost.table.password'))
->disabled()
->password()
->revealable()
->columnSpan(1)
->hintAction(RotateDatabasePasswordAction::make())
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote')
->disabled()
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote)
->columnSpan(1)
->label('Connections From'),
->label(trans('admin/databasehost.table.remote')),
TextInput::make('max_connections')
->label(trans('admin/databasehost.table.max_connections'))
->disabled()
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections)
->columnSpan(1),
@@ -694,9 +722,10 @@ class EditServer extends EditRecord
->disabled()
->password()
->revealable()
->label('JDBC Connection String')
->label(trans('admin/databasehost.table.connection_string'))
->columnSpan(2)
->formatStateUsing(fn (Database $record) => $record->jdbc),
->formatStateUsing(fn (Database $record) => $record->jdbc)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null),
])
->relationship('databases')
->deletable(false)
@@ -706,9 +735,9 @@ class EditServer extends EditRecord
Action::make('createDatabase')
->authorize(fn () => auth()->user()->can('create database'))
->disabled(fn () => DatabaseHost::query()->count() < 1)
->label(fn () => DatabaseHost::query()->count() < 1 ? 'No Database Hosts' : 'Create Database')
->label(fn () => DatabaseHost::query()->count() < 1 ? trans('admin/server.no_db_hosts') : trans('admin/server.create_database'))
->color(fn () => DatabaseHost::query()->count() < 1 ? 'danger' : 'primary')
->modalSubmitActionLabel('Create Database')
->modalSubmitActionLabel(trans('admin/server.create_database'))
->action(function (array $data, DatabaseManagementService $service, Server $server, RandomWordService $randomWordService) {
if (empty($data['database'])) {
$data['database'] = $randomWordService->word() . random_int(1, 420);
@@ -723,7 +752,7 @@ class EditServer extends EditRecord
$service->setValidateDatabaseLimit(false)->create($server, $data);
} catch (Exception $e) {
Notification::make()
->title('Failed to Create Database')
->title(trans('admin/server.failed_to_create'))
->body($e->getMessage())
->danger()
->persistent()->send();
@@ -732,7 +761,7 @@ class EditServer extends EditRecord
})
->form([
Select::make('database_host_id')
->label('Database Host')
->label(trans('admin/databasehost.table.name'))
->required()
->placeholder('Select Database Host')
->options(fn (Server $server) => DatabaseHost::query()
@@ -742,24 +771,24 @@ class EditServer extends EditRecord
->default(fn () => (DatabaseHost::query()->first())?->id)
->selectablePlaceholder(false),
TextInput::make('database')
->label('Database Name')
->label(trans('admin/server.name'))
->alphaDash()
->prefix(fn (Server $server) => 's' . $server->id . '_')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Leaving this blank will auto generate a random name'),
->hintIconTooltip(trans('admin/databasehost.table.name_helper')),
TextInput::make('remote')
->columnSpan(1)
->regex('/^[\w\-\/.%:]+$/')
->label('Connections From')
->label(trans('admin/databasehost.table.remote'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('Where connections should be allowed from. Leave blank to allow connections from anywhere.'),
->hintIconTooltip(trans('admin/databasehost.table.remote_helper')),
]),
])->alignCenter()->columnSpanFull(),
]),
Tab::make('Actions')
Tab::make(trans('admin/server.actions'))
->icon('tabler-settings')
->schema([
Fieldset::make('Server Actions')
Fieldset::make(trans('admin/server.actions'))
->columns([
'default' => 1,
'sm' => 2,
@@ -772,7 +801,7 @@ class EditServer extends EditRecord
->schema([
Forms\Components\Actions::make([
Action::make('toggleInstall')
->label('Toggle Install Status')
->label(trans('admin/server.toggle_install'))
->disabled(fn (Server $server) => $server->isSuspended())
->action(function (ToggleInstallService $service, Server $server) {
$service->handle($server);
@@ -781,47 +810,55 @@ class EditServer extends EditRecord
}),
])->fullWidth(),
ToggleButtons::make('')
->hint('If you need to change the install status from uninstalled to installed, or vice versa, you may do so with this button.'),
->hint(trans('admin/server.toggle_install_help')),
]),
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
Action::make('toggleSuspend')
->label('Suspend')
->label(trans('admin/server.suspend'))
->color('warning')
->hidden(fn (Server $server) => $server->isSuspended())
->action(function (SuspensionService $suspensionService, Server $server) {
$suspensionService->toggle($server, 'suspend');
Notification::make()->success()->title('Server Suspended!')->send();
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']);
}),
Action::make('toggleUnsuspend')
->label('Unsuspend')
->label(trans('admin/server.unsuspend'))
->color('success')
->hidden(fn (Server $server) => !$server->isSuspended())
->action(function (SuspensionService $suspensionService, Server $server) {
$suspensionService->toggle($server, 'unsuspend');
Notification::make()->success()->title('Server Unsuspended!')->send();
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']);
}),
])->fullWidth(),
ToggleButtons::make('')
->hidden(fn (Server $server) => $server->isSuspended())
->hint('This will suspend the server, stop any running processes, and immediately block the user from being able to access their files or otherwise manage the server through the panel or API.'),
->hint(trans('admin/server.notifications.server_suspend_help')),
ToggleButtons::make('')
->hidden(fn (Server $server) => !$server->isSuspended())
->hint('This will unsuspend the server and restore normal user access.'),
->hint(trans('admin/server.notifications.server_unsuspend_help')),
]),
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
Action::make('transfer')
->label('Transfer Soon™')
->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, []))
->label(trans('admin/server.transfer'))
// ->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, []))
->disabled() //TODO!
->form([ //TODO!
Select::make('newNode')
@@ -845,26 +882,26 @@ class EditServer extends EditRecord
false => 'off',
]),
])
->modalHeading('Transfer'),
->modalheading(trans('admin/server.transfer')),
])->fullWidth(),
ToggleButtons::make('')
->hint('Transfer this server to another node connected to this panel. Warning! This feature has not been fully tested and may have bugs.'),
->hint(trans('admin/server.transfer_help')),
]),
Grid::make()
->columnSpan(3)
->schema([
Forms\Components\Actions::make([
Action::make('reinstall')
->label('Reinstall')
->label(trans('admin/server.reinstall'))
->color('danger')
->requiresConfirmation()
->modalHeading('Are you sure you want to reinstall this server?')
->modalDescription('!! This can result in unrecoverable data loss !!')
->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)),
])->fullWidth(),
ToggleButtons::make('')
->hint('This will reinstall the server with the assigned egg install script.'),
->hint(trans('admin/server.reinstall_help')),
]),
]),
]),
@@ -891,7 +928,7 @@ class EditServer extends EditRecord
Actions\Action::make('Delete')
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger')
->label('Delete')
->label(trans('filament-actions::delete.single.modal.actions.delete.label'))
->requiresConfirmation()
->action(function (Server $server, ServerDeletionService $service) {
$service->handle($server);
@@ -900,7 +937,7 @@ class EditServer extends EditRecord
})
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console')
->label('Console')
->label(trans('admin/server.console'))
->icon('tabler-terminal')
->url(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server)),
$this->getSaveFormAction()->formId('form'),
@@ -924,6 +961,41 @@ class EditServer extends EditRecord
return $data;
}
protected function handleRecordUpdate(Model $record, array $data): Model
{
if (!$record instanceof Server) {
return $record;
}
/** @var Server $record */
$record = parent::handleRecordUpdate($record, $data);
try {
$this->daemonServerRepository->setServer($record)->sync();
} catch (ConnectionException) {
$this->errored = true;
Notification::make()
->title(trans('admin/server.notifications.error_connecting', ['node' => $record->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();
}
public function getRelationManagers(): array
{
return [
@@ -946,6 +1018,9 @@ class EditServer extends EditRecord
throw new Exception('Component type not supported: ' . $component::class);
}
/**
* @return array<string, string>
*/
private function getSelectOptionsFromRules(ServerVariable $serverVariable): array
{
$inRule = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'));

View File

@@ -31,19 +31,22 @@ class ListServers extends ListRecords
])
->columns([
TextColumn::make('condition')
->label(trans('admin/server.condition'))
->default('unknown')
->badge()
->icon(fn (Server $server) => $server->conditionIcon())
->color(fn (Server $server) => $server->conditionColor()),
->icon(fn (Server $server) => $server->condition->getIcon())
->color(fn (Server $server) => $server->condition->getColor()),
TextColumn::make('uuid')
->hidden()
->label('UUID')
->searchable(),
TextColumn::make('name')
->label(trans('admin/server.name'))
->icon('tabler-brand-docker')
->searchable()
->sortable(),
TextColumn::make('node.name')
->label(trans('admin/server.node'))
->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'node.name')
@@ -51,37 +54,39 @@ class ListServers extends ListRecords
->searchable(),
TextColumn::make('egg.name')
->icon('tabler-egg')
->label(trans('admin/server.egg'))
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'egg.name')
->sortable()
->searchable(),
TextColumn::make('user.username')
->icon('tabler-user')
->label('Owner')
->label(trans('admin/user.username'))
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'user.username')
->sortable()
->searchable(),
SelectColumn::make('allocation_id')
->label('Primary Allocation')
->label(trans('admin/server.primary_allocation'))
->hidden(!auth()->user()->can('update server'))
->options(fn (Server $server) => $server->allocations->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address]))
->selectablePlaceholder(false)
->sortable(),
TextColumn::make('allocation_id_readonly')
->label('Primary Allocation')
->label(trans('admin/server.primary_allocation'))
->hidden(auth()->user()->can('update server'))
->state(fn (Server $server) => $server->allocation->address),
TextColumn::make('image')->hidden(),
TextColumn::make('backups_count')
->counts('backups')
->label('Backups')
->label(trans('admin/server.backups'))
->icon('tabler-file-download')
->numeric()
->sortable(),
])
->actions([
Action::make('View')
->label(trans('admin/server.view'))
->icon('tabler-terminal')
->url(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server))
->authorize(fn (Server $server) => auth()->user()->canAccessTenant($server)),
@@ -90,11 +95,9 @@ class ListServers extends ListRecords
->emptyStateIcon('tabler-brand-docker')
->searchable()
->emptyStateDescription('')
->emptyStateHeading('No Servers')
->emptyStateHeading(trans('admin/server.no_servers'))
->emptyStateActions([
CreateAction::make('create')
->label('Create Server')
->button(),
CreateAction::make(),
]);
}
@@ -102,7 +105,6 @@ class ListServers extends ListRecords
{
return [
Actions\CreateAction::make()
->label('Create Server')
->hidden(fn () => Server::count() <= 0),
];
}

View File

@@ -9,7 +9,6 @@ use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
@@ -21,7 +20,6 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Server getOwnerRecord()
@@ -30,16 +28,6 @@ class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
@@ -48,10 +36,11 @@ class AllocationsRelationManager extends RelationManager
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port")
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
->inverseRelationship('server')
->heading(trans('admin/server.allocations'))
->columns([
TextColumn::make('ip')->label('IP'),
TextColumn::make('port')->label('Port'),
TextInputColumn::make('ip_alias')->label('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',
@@ -63,39 +52,33 @@ class AllocationsRelationManager extends RelationManager
})
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id)
->label('Primary'),
->label(trans('admin/server.primary')),
])
->actions([
Action::make('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 ? '' : 'Make Primary'),
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : trans('admin/server.make_primary')),
])
->headerActions([
CreateAction::make()->label('Create Allocation')
CreateAction::make()->label(trans('admin/server.create_allocation'))
->createAnother(false)
->form(fn () => [
Select::make('allocation_ip')
->options(collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label('IP Address')
->label(trans('admin/server.ip_address'))
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->label(trans('admin/server.alias'))
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->helperText(trans('admin/server.alias_helper'))
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->placeholder('27015, 27017-27019')
->label(trans('admin/server.ports'))
->inlineLabel()
->live()
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
@@ -111,7 +94,7 @@ class AllocationsRelationManager extends RelationManager
->preloadRecordSelect()
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node)->whereNull('server_id'))
->recordSelectSearchColumns(['ip', 'port'])
->label('Add Allocation'),
->label(trans('admin/server.add_allocation')),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([

View File

@@ -4,8 +4,19 @@ namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\UserResource\Pages;
use App\Filament\Admin\Resources\UserResource\RelationManagers;
use App\Models\Role;
use App\Models\User;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UserResource extends Resource
{
@@ -15,11 +26,109 @@ class UserResource extends Resource
protected static ?string $recordTitleAttribute = 'username';
public static function getNavigationLabel(): string
{
return trans('admin/user.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/user.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/user.model_label_plural');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function table(Table $table): Table
{
return $table
->columns([
ImageColumn::make('picture')
->visibleFrom('lg')
->label('')
->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
TextColumn::make('username')
->label(trans('admin/user.username')),
TextColumn::make('email')
->label(trans('admin/user.email'))
->icon('tabler-mail'),
IconColumn::make('use_totp')
->label('2FA')
->visibleFrom('lg')
->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off')
->boolean(),
TextColumn::make('roles.name')
->label(trans('admin/user.roles'))
->badge()
->icon('tabler-users-group')
->placeholder(trans('admin/user.no_roles')),
TextColumn::make('servers_count')
->counts('servers')
->icon('tabler-server')
->label(trans('admin/user.servers')),
TextColumn::make('subusers_count')
->visibleFrom('sm')
->label(trans('admin/user.subusers'))
->counts('subusers')
->icon('tabler-users'),
])
->actions([
ViewAction::make()
->hidden(fn ($record) => static::canEdit($record)),
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count)
->groupedBulkActions([
DeleteBulkAction::make(),
]);
}
public static function form(Form $form): Form
{
return $form
->columns(['default' => 1, 'lg' => 3])
->schema([
TextInput::make('username')
->label(trans('admin/user.username'))
->alphaNum()
->required()
->unique(ignoreRecord: true)
->minLength(3)
->maxLength(255),
TextInput::make('email')
->label(trans('admin/user.email'))
->email()
->required()
->unique(ignoreRecord: true)
->maxLength(255),
TextInput::make('password')
->label(trans('admin/user.password'))
->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null)
->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')
->dehydrated()
->label(trans('admin/user.admin_roles'))
->columnSpanFull()
->bulkToggleable(false),
]);
}
public static function getRelations(): array
{
return [
@@ -32,6 +141,7 @@ class UserResource extends Resource
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'view' => Pages\ViewUser::route('/{record}'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}

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