Compare commits

..

No commits in common. "main" and "v1.0.0-beta17" have entirely different histories.

1517 changed files with 60461 additions and 19864 deletions

View File

@ -1,29 +1,10 @@
**.DS_Store
.env
.devcontainer
.dockerignore
.editorconfig
.git .git
.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 node_modules
phpstan.neon vendor
phpunit.xml database/database.sqlite
readme.md
storage/debugbar/*.json storage/debugbar/*.json
storage/logs/*.log
storage/framework/cache/data/* storage/framework/cache/data/*
storage/framework/sessions/* storage/framework/sessions/*
storage/framework/testing storage/framework/testing
storage/framework/views/*.php storage/framework/views/*.php
storage/logs/*.log
vendor

View File

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

6
.eslintignore Normal file
View File

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

52
.eslintrc.js Normal file
View File

@ -0,0 +1,52 @@
/** @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' }],
},
};

76
.github/docker/README.md vendored Normal file
View File

@ -0,0 +1,76 @@
# Pelican Panel - Docker Image
This is a ready to use docker image for the panel.
## Requirements
This docker image requires some additional software to function. The software can either be provided in other containers (see the [docker-compose.yml](https://github.com/pelican-dev/panel/blob/develop/docker-compose.example.yml) as an example) or as existing instances.
A mysql database is required. We recommend the stock [MariaDB Image](https://hub.docker.com/_/mariadb/) image if you prefer to run it in a docker container. As a non-containerized option we recommend mariadb.
A caching software is required as well. We recommend the stock [Redis Image](https://hub.docker.com/_/redis/) image. You can choose any of the [supported options](#cache-drivers).
You can provide additional settings using a custom `.env` file or by setting the appropriate environment variables in the docker-compose file.
## Setup
Start the docker container and the required dependencies (either provide existing ones or start containers as well, see the [docker-compose.yml](https://github.com/pelican-dev/panel/blob/develop/docker-compose.example.yml) file as an example.
After the startup is complete you'll need to create a user.
If you are running the docker container without docker-compose, use:
```
docker exec -it <container id> php artisan p:user:make
```
If you are using docker compose use
```
docker-compose exec panel php artisan p:user:make
```
## Environment Variables
There are multiple environment variables to configure the panel when not providing your own `.env` file, see the following table for details on each available option.
Note: If your `APP_URL` starts with `https://` you need to provide an `LE_EMAIL` as well so Certificates can be generated.
| Variable | Description | Required |
|-------------------| ------------------------------------------------------------------------------ | -------- |
| `APP_URL` | The URL the panel will be reachable with (including protocol) | yes |
| `APP_TIMEZONE` | The timezone to use for the panel | yes |
| `LE_EMAIL` | The email used for letsencrypt certificate generation | yes |
| `DB_HOST` | The host of the mysql instance | yes |
| `DB_PORT` | The port of the mysql instance | yes |
| `DB_DATABASE` | The name of the mysql database | yes |
| `DB_USERNAME` | The mysql user | yes |
| `DB_PASSWORD` | The mysql password for the specified user | yes |
| `CACHE_STORE` | The cache driver (see [Cache drivers](#cache-drivers) for detais) | yes |
| `SESSION_DRIVER` | | yes |
| `QUEUE_DRIVER` | | yes |
| `REDIS_HOST` | The hostname or IP address of the redis database | yes |
| `REDIS_PASSWORD` | The password used to secure the redis database | maybe |
| `REDIS_PORT` | The port the redis database is using on the host | maybe |
| `MAIL_DRIVER` | The email driver (see [Mail drivers](#mail-drivers) for details) | yes |
| `MAIL_FROM` | The email that should be used as the sender email | yes |
| `MAIL_HOST` | The host of your mail driver instance | maybe |
| `MAIL_PORT` | The port of your mail driver instance | maybe |
| `MAIL_USERNAME` | The username for your mail driver | maybe |
| `MAIL_PASSWORD` | The password for your mail driver | maybe |
### Cache drivers
You can choose between different cache drivers depending on what you prefer.
We recommend redis when using docker as it can be started in a container easily.
| Driver | Description | Required variables |
| -------- | ------------------------------------ | ------------------------------------------------------ |
| redis | host where redis is running | `REDIS_HOST` |
| redis | port redis is running on | `REDIS_PORT` |
| redis | redis database password | `REDIS_PASSWORD` |
### Mail drivers
You can choose between different mail drivers according to your needs.
Every driver requires `MAIL_FROM` to be set.
| Driver | Description | Required variables |
| -------- | ------------------------------------ | ------------------------------------------------------------- |
| mail | uses the installed php mail | |
| mandrill | [Mandrill](http://www.mandrill.com/) | `MAIL_USERNAME` |
| postmark | [Postmark](https://postmarkapp.com/) | `MAIL_USERNAME` |
| mailgun | [Mailgun](https://www.mailgun.com/) | `MAIL_USERNAME`, `MAIL_HOST` |
| smtp | Any SMTP server can be configured | `MAIL_USERNAME`, `MAIL_HOST`, `MAIL_PASSWORD`, `MAIL_PORT` |

75
.github/docker/default.conf vendored Normal file
View File

@ -0,0 +1,75 @@
# 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;
}
}

70
.github/docker/default_ssl.conf vendored Normal file
View File

@ -0,0 +1,70 @@
# 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,11 +1,14 @@
#!/bin/ash -e #!/bin/ash -e
## check for .env file or symlink and generate app keys if missing #mkdir -p /var/log/supervisord/ /var/log/php8/ \
if [ -f /var/www/html/.env ]; then
## check for .env file and generate app keys if missing
if [ -f /pelican-data/.env ]; then
echo "external vars exist." echo "external vars exist."
rm -rf /var/www/html/.env
else else
echo "external vars don't exist." echo "external vars don't exist."
# webroot .env is symlinked to this path rm -rf /var/www/html/.env
touch /pelican-data/.env touch /pelican-data/.env
## manually generate a key because key generate --force fails ## manually generate a key because key generate --force fails
@ -23,7 +26,10 @@ else
echo -e "APP_INSTALLED=false" >> /pelican-data/.env echo -e "APP_INSTALLED=false" >> /pelican-data/.env
fi fi
mkdir -p /pelican-data/database /pelican-data/storage/avatars /pelican-data/storage/fonts /var/www/html/storage/logs/supervisord 2>/dev/null mkdir /pelican-data/database
ln -s /pelican-data/.env /var/www/html/
chown -h www-data:www-data /var/www/html/.env
ln -s /pelican-data/database/database.sqlite /var/www/html/database/
if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then
echo "Generating APP_KEY..." echo "Generating APP_KEY..."
@ -39,6 +45,10 @@ php artisan migrate --force
echo -e "Optimizing Filament" echo -e "Optimizing Filament"
php artisan filament:optimize php artisan filament:optimize
## start cronjobs for the queue
echo -e "Starting cron jobs."
crond -L /var/log/crond -l 5
export SUPERVISORD_CADDY=false export SUPERVISORD_CADDY=false
## disable caddy if SKIP_CADDY is set ## disable caddy if SKIP_CADDY is set
@ -49,5 +59,7 @@ else
export SUPERVISORD_CADDY=true export SUPERVISORD_CADDY=true
fi fi
chown -R www-data:www-data /pelican-data/.env /pelican-data/database
echo "Starting Supervisord" echo "Starting Supervisord"
exec "$@" exec "$@"

View File

@ -4,14 +4,16 @@ username=dummy
password=dummy password=dummy
[supervisord] [supervisord]
logfile=/var/www/html/storage/logs/supervisord/supervisord.log ; supervisord log file logfile=/var/log/supervisord/supervisord.log ; supervisord log file
logfile_maxbytes=50MB ; maximum size of logfile before rotation logfile_maxbytes=50MB ; maximum size of logfile before rotation
logfile_backups=2 ; number of backed up logfiles logfile_backups=2 ; number of backed up logfiles
loglevel=error ; info, debug, warn, trace loglevel=error ; info, debug, warn, trace
pidfile=/var/run/supervisord/supervisord.pid ; pidfile location pidfile=/var/run/supervisord.pid ; pidfile location
nodaemon=true ; run supervisord as a daemon nodaemon=false ; run supervisord as a daemon
minfds=1024 ; number of startup file descriptors minfds=1024 ; number of startup file descriptors
minprocs=200 ; number of process descriptors minprocs=200 ; number of process descriptors
user=root ; default user
childlogdir=/var/log/supervisord/ ; where child log files will live
[rpcinterface:supervisor] [rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
@ -28,6 +30,7 @@ autorestart=true
[program:queue-worker] [program:queue-worker]
command=/usr/local/bin/php /var/www/html/artisan queue:work --tries=3 command=/usr/local/bin/php /var/www/html/artisan queue:work --tries=3
user=www-data
autostart=true autostart=true
autorestart=true autorestart=true
@ -36,12 +39,5 @@ command=caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
autostart=%(ENV_SUPERVISORD_CADDY)s autostart=%(ENV_SUPERVISORD_CADDY)s
autorestart=%(ENV_SUPERVISORD_CADDY)s autorestart=%(ENV_SUPERVISORD_CADDY)s
priority=10 priority=10
stdout_logfile=/dev/fd/1 stdout_events_enabled=true
stdout_logfile_maxbytes=0 stderr_events_enabled=true
redirect_stderr=true
[program:supercronic]
command=supercronic -overlapping /etc/supercronic/crontab
autostart=true
autorestart=true
redirect_stderr=true

16
.github/docker/www.conf vendored Normal file
View File

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

View File

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
php: [8.2, 8.3, 8.4] php: [8.2, 8.3]
database: ["mysql:8"] database: ["mysql:8"]
services: services:
database: database:
@ -66,16 +66,16 @@ jobs:
coverage: none coverage: none
- name: Install dependencies - name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts run: composer install --no-interaction --no-suggest --prefer-dist
- name: Unit tests - name: Unit tests
run: vendor/bin/pest tests/Unit run: vendor/bin/phpunit tests/Unit
env: env:
DB_HOST: UNIT_NO_DB DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true SKIP_MIGRATIONS: true
- name: Integration tests - name: Integration tests
run: vendor/bin/pest tests/Integration run: vendor/bin/phpunit tests/Integration
env: env:
DB_PORT: ${{ job.services.database.ports[3306] }} DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root DB_USERNAME: root
@ -86,7 +86,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
php: [8.2, 8.3, 8.4] php: [8.2, 8.3]
database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"] database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"]
services: services:
database: database:
@ -139,16 +139,16 @@ jobs:
coverage: none coverage: none
- name: Install dependencies - name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts run: composer install --no-interaction --no-suggest --prefer-dist
- name: Unit tests - name: Unit tests
run: vendor/bin/pest tests/Unit run: vendor/bin/phpunit tests/Unit
env: env:
DB_HOST: UNIT_NO_DB DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true SKIP_MIGRATIONS: true
- name: Integration tests - name: Integration tests
run: vendor/bin/pest tests/Integration run: vendor/bin/phpunit tests/Integration
env: env:
DB_PORT: ${{ job.services.database.ports[3306] }} DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root DB_USERNAME: root
@ -159,7 +159,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
php: [8.2, 8.3, 8.4] php: [8.2, 8.3]
env: env:
APP_ENV: testing APP_ENV: testing
APP_DEBUG: "false" APP_DEBUG: "false"
@ -200,92 +200,16 @@ jobs:
coverage: none coverage: none
- name: Install dependencies - name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts run: composer install --no-interaction --no-suggest --prefer-dist
- name: Create SQLite file - name: Create SQLite file
run: touch database/testing.sqlite run: touch database/testing.sqlite
- name: Unit tests - name: Unit tests
run: vendor/bin/pest tests/Unit run: vendor/bin/phpunit tests/Unit
env: env:
DB_HOST: UNIT_NO_DB DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true SKIP_MIGRATIONS: true
- name: Integration tests - name: Integration tests
run: vendor/bin/pest tests/Integration run: vendor/bin/phpunit tests/Integration
postgresql:
name: PostgreSQL
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
database: ["postgres:14"]
services:
database:
image: ${{ matrix.database }}
env:
POSTGRES_DB: testing
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: postgres
DB_PASSWORD: postgres
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
steps:
- name: Code Checkout
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Unit tests
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration

View File

@ -1,5 +1,6 @@
name: Docker name: Docker
on: on:
push: push:
branches: branches:
@ -13,67 +14,12 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
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: build-and-push:
name: Build and Push ubuntu-24.04 name: Build and Push
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
needs: build-php-base
permissions: permissions:
contents: read contents: read
packages: write 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. # 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'))" if: "!contains(github.ref, 'main') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))"
steps: steps:
@ -92,14 +38,11 @@ jobs:
type=ref,event=tag type=ref,event=tag
type=ref,event=branch type=ref,event=branch
- name: Set up QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 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 - name: Setup Docker buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@ -114,52 +57,30 @@ jobs:
echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $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) - name: Build and Push (tag)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
if: "github.event_name == 'release' && github.event.action == 'published'" if: "github.event_name == 'release' && github.event.action == 'published'"
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
platforms: 'linux/amd64,linux/arm64' platforms: linux/amd64,linux/arm64
build-args: | build-args: |
VERSION=${{ steps.build_info.outputs.version_tag }} VERSION=${{ steps.build_info.outputs.version_tag }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }} 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) - name: Build and Push (main)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
if: "github.event_name == 'push' && contains(github.ref, 'main')" if: "github.event_name == 'push' && contains(github.ref, 'main')"
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: 'linux/amd64,linux/arm64' platforms: linux/amd64,linux/arm64
build-args: | build-args: |
VERSION=dev-${{ steps.build_info.outputs.short_sha }} VERSION=dev-${{ steps.build_info.outputs.short_sha }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha,scope=${{ matrix.os }} cache-from: type=gha
cache-to: type=gha,scope=${{ matrix.os }},mode=max cache-to: type=gha,mode=max

View File

@ -25,38 +25,21 @@ jobs:
run: cp .env.example .env run: cp .env.example .env
- name: Install dependencies - name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-autoloader --no-scripts run: composer install --no-interaction --no-progress --prefer-dist
- name: Pint - name: Pint
run: vendor/bin/pint --test run: vendor/bin/pint --test
phpstan: phpstan:
name: PHPStan name: PHPStan
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [ 8.2, 8.3, 8.4 ]
steps: steps:
- name: Code Checkout - name: Code Checkout
uses: actions/checkout@v4 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 - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: "8.3"
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
@ -65,7 +48,7 @@ jobs:
run: cp .env.example .env run: cp .env.example .env
- name: Install dependencies - name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts run: composer install --no-interaction --no-progress --prefer-dist
- name: PHPStan - name: PHPStan
run: vendor/bin/phpstan --memory-limit=-1 --error-format=github run: vendor/bin/phpstan --memory-limit=-1

View File

@ -16,28 +16,17 @@ jobs:
- name: Code checkout - name: Code checkout
uses: actions/checkout@v4 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 - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
- name: Install JS dependencies - name: Install dependencies
run: yarn install --frozen-lockfile run: yarn install --frozen-lockfile
- name: Build - name: Build
run: yarn build run: yarn build:production
- name: Create release branch and bump version - name: Create release branch and bump version
env: env:
@ -55,8 +44,8 @@ jobs:
- name: Create release archive - name: Create release archive
run: | run: |
rm -rf node_modules vendor tests CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.xml shell.nix rm -rf node_modules tests CODE_OF_CONDUCT.md CONTRIBUTING.md flake.lock flake.nix phpunit.xml shell.nix
tar -czf panel.tar.gz * .env.example tar -czf panel.tar.gz * .editorconfig .env.example .eslintignore .eslintrc.js .gitignore .prettierrc.json
- name: Create checksum - name: Create checksum
run: | run: |

5
.gitignore vendored
View File

@ -1,9 +1,9 @@
/.phpunit.cache /.phpunit.cache
/node_modules /node_modules
/public/build /public/build
/public/hot
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail
/storage/clockwork/* /storage/clockwork/*
/vendor /vendor
*.DS_Store* *.DS_Store*
@ -19,11 +19,10 @@ npm-debug.log
yarn-error.log yarn-error.log
/.fleet /.fleet
/.idea /.idea
/.nova
/.vscode /.vscode
public/assets/manifest.json public/assets/manifest.json
/database/*.sqlite* /database/*.sqlite
filament-monaco-editor/ filament-monaco-editor/
_ide_helper* _ide_helper*
/.phpstorm.meta.php /.phpstorm.meta.php

52
.php-cs-fixer.dist.php Normal file
View File

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

View File

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

View File

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

View File

@ -1,58 +0,0 @@
<?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

@ -1,42 +0,0 @@
<?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

@ -1,44 +0,0 @@
<?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

@ -1,38 +0,0 @@
<?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

@ -14,34 +14,30 @@ class NodeVersionsCheck extends Check
public function run(): Result public function run(): Result
{ {
$all = Node::all(); $all = Node::query()->count();
if ($all->isEmpty()) { if ($all === 0) {
$result = Result::make() $result = Result::make()->notificationMessage('No Nodes created')->shortSummary('No Nodes');
->notificationMessage(trans('admin/health.results.nodeversions.no_nodes_created'))
->shortSummary(trans('admin/health.results.nodeversions.no_nodes'));
$result->status = Status::skipped(); $result->status = Status::skipped();
return $result; return $result;
} }
$outdated = $all
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && !$this->versionService->isLatestWings($node->systemInformation()['version']))
->count();
$all = $all->count();
$latestVersion = $this->versionService->latestWingsVersion(); $latestVersion = $this->versionService->latestWingsVersion();
$outdated = Node::query()->get()
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion)
->count();
$result = Result::make() $result = Result::make()
->meta([ ->meta([
'all' => $all, 'all' => $all,
'outdated' => $outdated, 'outdated' => $outdated,
'latestVersion' => $latestVersion,
]) ])
->shortSummary($outdated === 0 ? trans('admin/health.results.nodeversions.all_up_to_date') : trans('admin/health.results.nodeversions.outdated', ['outdated' => $outdated, 'all' => $all])); ->shortSummary($outdated === 0 ? 'All up-to-date' : "{$outdated}/{$all} outdated");
return $outdated === 0 return $outdated === 0
? $result->ok(trans('admin/health.results.nodeversions.ok')) ? $result->ok('All Nodes are up-to-date.')
: $result->failed(trans('admin/health.results.nodeversions.failed', ['outdated' => $outdated, 'all' => $all])); : $result->failed(':outdated/:all Nodes are outdated.');
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,21 +18,10 @@ class QueueWorkerServiceCommand extends Command
public function handle(): void public function handle(): void
{ {
if (@file_exists('/.dockerenv')) {
$result = Process::run('supervisorctl restart queue-worker');
if ($result->failed()) {
$this->error('Error restarting service: ' . $result->errorOutput());
return;
}
$this->line('Queue worker service file updated successfully.');
return;
}
$serviceName = $this->option('service-name') ?? $this->ask('Queue worker service name', 'pelican-queue'); $serviceName = $this->option('service-name') ?? $this->ask('Queue worker service name', 'pelican-queue');
$path = '/etc/systemd/system/' . $serviceName . '.service'; $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?')) { 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.'); $this->line('Creation of queue worker service file aborted because service file already exists.');

View File

@ -20,6 +20,8 @@ class RedisSetupCommand extends Command
{--redis-pass= : Password used to connect to redis.} {--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}'; {--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/** /**
* RedisSetupCommand constructor. * RedisSetupCommand constructor.
*/ */
@ -35,7 +37,7 @@ class RedisSetupCommand extends Command
{ {
$this->variables['CACHE_STORE'] = 'redis'; $this->variables['CACHE_STORE'] = 'redis';
$this->variables['QUEUE_CONNECTION'] = 'redis'; $this->variables['QUEUE_CONNECTION'] = 'redis';
$this->variables['SESSION_DRIVER'] = 'redis'; $this->variables['SESSION_DRIVERS'] = 'redis';
$this->requestRedisSettings(); $this->requestRedisSettings();

View File

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

View File

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

View File

@ -2,8 +2,8 @@
namespace App\Console\Commands\Node; namespace App\Console\Commands\Node;
use App\Models\Node;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use App\Services\Nodes\NodeCreationService;
class MakeNodeCommand extends Command class MakeNodeCommand extends Command
{ {
@ -24,13 +24,20 @@ class MakeNodeCommand extends Command
{--overallocateCpu= : Enter the amount of cpu to overallocate (% or -1 to overallocate the maximum).} {--overallocateCpu= : Enter the amount of cpu to overallocate (% or -1 to overallocate the maximum).}
{--uploadSize= : Enter the maximum upload filesize.} {--uploadSize= : Enter the maximum upload filesize.}
{--daemonListeningPort= : Enter the daemon listening port.} {--daemonListeningPort= : Enter the daemon listening port.}
{--daemonConnectingPort= : Enter the daemon connecting port.}
{--daemonSFTPPort= : Enter the daemon SFTP listening port.} {--daemonSFTPPort= : Enter the daemon SFTP listening port.}
{--daemonSFTPAlias= : Enter the daemon SFTP alias.} {--daemonSFTPAlias= : Enter the daemon SFTP alias.}
{--daemonBase= : Enter the base folder.}'; {--daemonBase= : Enter the base folder.}';
protected $description = 'Creates a new node on the system via the CLI.'; protected $description = 'Creates a new node on the system via the CLI.';
/**
* MakeNodeCommand constructor.
*/
public function __construct(private NodeCreationService $creationService)
{
parent::__construct();
}
/** /**
* Handle the command execution process. * Handle the command execution process.
* *
@ -38,32 +45,31 @@ class MakeNodeCommand extends Command
*/ */
public function handle(): void public function handle(): void
{ {
$data['name'] = $this->option('name') ?? $this->ask(trans('commands.make_node.name')); $data['name'] = $this->option('name') ?? $this->ask(__('commands.make_node.name'));
$data['description'] = $this->option('description') ?? $this->ask(trans('commands.make_node.description')); $data['description'] = $this->option('description') ?? $this->ask(__('commands.make_node.description'));
$data['scheme'] = $this->option('scheme') ?? $this->anticipate( $data['scheme'] = $this->option('scheme') ?? $this->anticipate(
trans('commands.make_node.scheme'), __('commands.make_node.scheme'),
['https', 'http'], ['https', 'http'],
'https' 'https'
); );
$data['fqdn'] = $this->option('fqdn') ?? $this->ask(trans('commands.make_node.fqdn')); $data['fqdn'] = $this->option('fqdn') ?? $this->ask(__('commands.make_node.fqdn'));
$data['public'] = $this->option('public') ?? $this->confirm(trans('commands.make_node.public'), true); $data['public'] = $this->option('public') ?? $this->confirm(__('commands.make_node.public'), true);
$data['behind_proxy'] = $this->option('proxy') ?? $this->confirm(trans('commands.make_node.behind_proxy')); $data['behind_proxy'] = $this->option('proxy') ?? $this->confirm(__('commands.make_node.behind_proxy'));
$data['maintenance_mode'] = $this->option('maintenance') ?? $this->confirm(trans('commands.make_node.maintenance_mode')); $data['maintenance_mode'] = $this->option('maintenance') ?? $this->confirm(__('commands.make_node.maintenance_mode'));
$data['memory'] = $this->option('maxMemory') ?? $this->ask(trans('commands.make_node.memory'), '0'); $data['memory'] = $this->option('maxMemory') ?? $this->ask(__('commands.make_node.memory'), '0');
$data['memory_overallocate'] = $this->option('overallocateMemory') ?? $this->ask(trans('commands.make_node.memory_overallocate'), '-1'); $data['memory_overallocate'] = $this->option('overallocateMemory') ?? $this->ask(__('commands.make_node.memory_overallocate'), '-1');
$data['disk'] = $this->option('maxDisk') ?? $this->ask(trans('commands.make_node.disk'), '0'); $data['disk'] = $this->option('maxDisk') ?? $this->ask(__('commands.make_node.disk'), '0');
$data['disk_overallocate'] = $this->option('overallocateDisk') ?? $this->ask(trans('commands.make_node.disk_overallocate'), '-1'); $data['disk_overallocate'] = $this->option('overallocateDisk') ?? $this->ask(__('commands.make_node.disk_overallocate'), '-1');
$data['cpu'] = $this->option('maxCpu') ?? $this->ask(trans('commands.make_node.cpu'), '0'); $data['cpu'] = $this->option('maxCpu') ?? $this->ask(__('commands.make_node.cpu'), '0');
$data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(trans('commands.make_node.cpu_overallocate'), '-1'); $data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(__('commands.make_node.cpu_overallocate'), '-1');
$data['upload_size'] = $this->option('uploadSize') ?? $this->ask(trans('commands.make_node.upload_size'), '256'); $data['upload_size'] = $this->option('uploadSize') ?? $this->ask(__('commands.make_node.upload_size'), '256');
$data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(trans('commands.make_node.daemonListen'), '8080'); $data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(__('commands.make_node.daemonListen'), '8080');
$data['daemon_connect'] = $this->option('daemonConnectingPort') ?? $this->ask(trans('commands.make_node.daemonConnect'), '8080'); $data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(__('commands.make_node.daemonSFTP'), '2022');
$data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(trans('commands.make_node.daemonSFTP'), '2022'); $data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(__('commands.make_node.daemonSFTPAlias'), '');
$data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(trans('commands.make_node.daemonSFTPAlias'), ''); $data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(__('commands.make_node.daemonBase'), '/var/lib/pelican/volumes');
$data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(trans('commands.make_node.daemonBase'), '/var/lib/pelican/volumes');
$node = Node::create($data); $node = $this->creationService->handle($data);
$this->line(trans('commands.make_node.success', ['name' => $data['name'], 'id' => $node->id])); $this->line(__('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 */ /** @var \App\Models\Node $node */
$node = Node::query()->where($column, $this->argument('node'))->firstOr(function () { $node = Node::query()->where($column, $this->argument('node'))->firstOr(function () {
$this->error(trans('commands.node_config.error_not_exist')); $this->error(__('commands.node_config.error_not_exist'));
exit(1); exit(1);
}); });
$format = $this->option('format'); $format = $this->option('format');
if (!in_array($format, ['yaml', 'yml', 'json'])) { if (!in_array($format, ['yaml', 'yml', 'json'])) {
$this->error(trans('commands.node_config.error_invalid_format')); $this->error(__('commands.node_config.error_invalid_format'));
return 1; return 1;
} }

View File

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

View File

@ -6,7 +6,6 @@ use Illuminate\Console\Command;
use App\Models\Schedule; use App\Models\Schedule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use App\Services\Schedules\ProcessScheduleService; use App\Services\Schedules\ProcessScheduleService;
use Throwable;
class ProcessRunnableCommand extends Command class ProcessRunnableCommand extends Command
{ {
@ -14,7 +13,10 @@ class ProcessRunnableCommand extends Command
protected $description = 'Process schedules in the database and determine which are ready to run.'; protected $description = 'Process schedules in the database and determine which are ready to run.';
public function handle(ProcessScheduleService $processScheduleService): int /**
* Handle command execution.
*/
public function handle(): int
{ {
$schedules = Schedule::query() $schedules = Schedule::query()
->with('tasks') ->with('tasks')
@ -25,7 +27,7 @@ class ProcessRunnableCommand extends Command
->get(); ->get();
if ($schedules->count() < 1) { if ($schedules->count() < 1) {
$this->line(trans('commands.schedule.process.no_tasks')); $this->line(__('commands.schedule.process.no_tasks'));
return 0; return 0;
} }
@ -33,7 +35,7 @@ class ProcessRunnableCommand extends Command
$bar = $this->output->createProgressBar(count($schedules)); $bar = $this->output->createProgressBar(count($schedules));
foreach ($schedules as $schedule) { foreach ($schedules as $schedule) {
$bar->clear(); $bar->clear();
$this->processSchedule($processScheduleService, $schedule); $this->processSchedule($schedule);
$bar->advance(); $bar->advance();
$bar->display(); $bar->display();
} }
@ -48,23 +50,23 @@ class ProcessRunnableCommand extends Command
* never throw an exception out, otherwise you'll end up killing the entire run group causing * never throw an exception out, otherwise you'll end up killing the entire run group causing
* any other schedules to not process correctly. * any other schedules to not process correctly.
*/ */
protected function processSchedule(ProcessScheduleService $processScheduleService, Schedule $schedule): void protected function processSchedule(Schedule $schedule): void
{ {
if ($schedule->tasks->isEmpty()) { if ($schedule->tasks->isEmpty()) {
return; return;
} }
try { try {
$processScheduleService->handle($schedule); $this->getLaravel()->make(ProcessScheduleService::class)->handle($schedule);
$this->line(trans('command/messages.schedule.output_line', [ $this->line(trans('command/messages.schedule.output_line', [
'schedule' => $schedule->name, 'schedule' => $schedule->name,
'id' => $schedule->id, 'id' => $schedule->id,
])); ]));
} catch (Throwable $exception) { } catch (\Throwable|\Exception $exception) {
logger()->error($exception, ['schedule_id' => $schedule->id]); logger()->error($exception, ['schedule_id' => $schedule->id]);
$this->error(trans('commands.schedule.process.no_tasks') . " #$schedule->id: " . $exception->getMessage()); $this->error(__('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\ValidationException;
use Illuminate\Validation\Factory as ValidatorFactory; use Illuminate\Validation\Factory as ValidatorFactory;
use App\Repositories\Daemon\DaemonPowerRepository; use App\Repositories\Daemon\DaemonPowerRepository;
use Exception; use App\Exceptions\Http\Connection\DaemonConnectionException;
class BulkPowerActionCommand extends Command class BulkPowerActionCommand extends Command
{ {
@ -19,13 +19,26 @@ class BulkPowerActionCommand extends Command
protected $description = 'Perform bulk power management on large groupings of servers or nodes at once.'; protected $description = 'Perform bulk power management on large groupings of servers or nodes at once.';
public function handle(DaemonPowerRepository $powerRepository, ValidatorFactory $validator): void /**
* 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
{ {
$action = $this->argument('action'); $action = $this->argument('action');
$nodes = empty($this->option('nodes')) ? [] : explode(',', $this->option('nodes')); $nodes = empty($this->option('nodes')) ? [] : explode(',', $this->option('nodes'));
$servers = empty($this->option('servers')) ? [] : explode(',', $this->option('servers')); $servers = empty($this->option('servers')) ? [] : explode(',', $this->option('servers'));
$validator = $validator->make([ $validator = $this->validator->make([
'action' => $action, 'action' => $action,
'nodes' => $nodes, 'nodes' => $nodes,
'servers' => $servers, 'servers' => $servers,
@ -51,17 +64,14 @@ class BulkPowerActionCommand extends Command
} }
$bar = $this->output->createProgressBar($count); $bar = $this->output->createProgressBar($count);
$powerRepository = $this->powerRepository;
$this->getQueryBuilder($servers, $nodes)->get()->each(function ($server, int $index) use ($action, $powerRepository, &$bar): mixed { // @phpstan-ignore-next-line
$this->getQueryBuilder($servers, $nodes)->each(function (Server $server) use ($action, $powerRepository, &$bar) {
$bar->clear(); $bar->clear();
if (!$server instanceof Server) {
return null;
}
try { try {
$powerRepository->setServer($server)->send($action); $powerRepository->setServer($server)->send($action);
} catch (Exception $exception) { } catch (DaemonConnectionException $exception) {
$this->output->error(trans('command/messages.server.power.action_failed', [ $this->output->error(trans('command/messages.server.power.action_failed', [
'name' => $server->name, 'name' => $server->name,
'id' => $server->id, 'id' => $server->id,
@ -72,8 +82,6 @@ class BulkPowerActionCommand extends Command
$bar->advance(); $bar->advance();
$bar->display(); $bar->display();
return null;
}); });
$this->line(''); $this->line('');
@ -81,9 +89,6 @@ class BulkPowerActionCommand extends Command
/** /**
* Returns the query builder instance that will return the servers that should be affected. * 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 protected function getQueryBuilder(array $servers, array $nodes): Builder
{ {

View File

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

View File

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

View File

@ -7,6 +7,7 @@ use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand; use App\Console\Commands\Maintenance\PruneImagesCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand; use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Schedule\ProcessRunnableCommand; use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Jobs\NodeStatistics;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Webhook; use App\Models\Webhook;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
@ -30,11 +31,8 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule): void protected function schedule(Schedule $schedule): void
{ {
if (config('cache.default') === 'redis') {
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags // https://laravel.com/docs/10.x/upgrade#redis-cache-tags
// This only needs to run when using redis. anything else throws an error.
$schedule->command('cache:prune-stale-tags')->hourly(); $schedule->command('cache:prune-stale-tags')->hourly();
}
// Execute scheduled commands for servers every minute, as if there was a normal cron running. // Execute scheduled commands for servers every minute, as if there was a normal cron running.
$schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping(); $schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping();
@ -43,6 +41,8 @@ class Kernel extends ConsoleKernel
$schedule->command(PruneImagesCommand::class)->daily(); $schedule->command(PruneImagesCommand::class)->daily();
$schedule->command(CheckEggUpdatesCommand::class)->hourly(); $schedule->command(CheckEggUpdatesCommand::class)->hourly();
$schedule->job(new NodeStatistics())->everyFiveSeconds()->withoutOverlapping();
if (config('backups.prune_age')) { if (config('backups.prune_age')) {
// Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted. // Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted.
$schedule->command(PruneOrphanedBackupsCommand::class)->everyThirtyMinutes(); $schedule->command(PruneOrphanedBackupsCommand::class)->everyThirtyMinutes();

View File

@ -1,22 +0,0 @@
<?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

@ -1,24 +0,0 @@
<?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

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

View File

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

View File

@ -2,11 +2,7 @@
namespace App\Enums; namespace App\Enums;
use Filament\Support\Contracts\HasColor; enum ContainerStatus: string
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
{ {
// Docker Based // Docker Based
case Created = 'created'; case Created = 'created';
@ -23,7 +19,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
// HTTP Based // HTTP Based
case Missing = 'missing'; case Missing = 'missing';
public function getIcon(): string public function icon(): string
{ {
return match ($this) { return match ($this) {
@ -40,17 +36,8 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
}; };
} }
public function getColor(bool $hex = false): string public function color(): 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) { return match ($this) {
self::Created => 'primary', self::Created => 'primary',
self::Starting => 'warning', self::Starting => 'warning',
@ -62,23 +49,18 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
self::Removing => 'warning', self::Removing => 'warning',
self::Missing => 'danger', self::Missing => 'danger',
self::Stopping => 'warning', self::Stopping => 'warning',
self::Offline => 'danger', self::Offline => 'gray',
}; };
} }
public function getLabel(): string public function colorHex(): string
{ {
return str($this->value)->title(); return match ($this) {
} self::Created, self::Restarting => '#2563EB',
self::Starting, self::Paused, self::Removing, self::Stopping => '#D97706',
public function isOffline(): bool self::Running => '#22C55E',
{ self::Exited, self::Missing, self::Dead, self::Offline => '#EF4444',
return in_array($this, [ContainerStatus::Offline, ContainerStatus::Missing]); };
}
public function isStartingOrRunning(): bool
{
return in_array($this, [ContainerStatus::Starting, ContainerStatus::Running]);
} }
public function isStartingOrStopping(): bool public function isStartingOrStopping(): bool
@ -88,7 +70,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
public function isStartable(): bool public function isStartable(): bool
{ {
return !in_array($this, [ContainerStatus::Running, ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Missing]); return !in_array($this, [ContainerStatus::Running, ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting]);
} }
public function isRestartable(): bool public function isRestartable(): bool
@ -97,16 +79,18 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
return true; return true;
} }
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Missing]); return !in_array($this, [ContainerStatus::Offline]);
} }
public function isStoppable(): bool public function isStoppable(): bool
{ {
return !in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Exited, ContainerStatus::Offline, ContainerStatus::Missing]); return !in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Exited, ContainerStatus::Offline]);
} }
public function isKillable(): bool public function isKillable(): bool
{ {
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Running, ContainerStatus::Exited, ContainerStatus::Missing]); // [ContainerStatus::Restarting, ContainerStatus::Removing, ContainerStatus::Dead, ContainerStatus::Created]
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Running, ContainerStatus::Exited]);
} }
} }

View File

@ -36,7 +36,6 @@ enum EditorLanguages: string implements HasLabel
case java = 'java'; case java = 'java';
case javascript = 'javascript'; case javascript = 'javascript';
case julia = 'julia'; case julia = 'julia';
case json = 'json';
case kotlin = 'kotlin'; case kotlin = 'kotlin';
case less = 'less'; case less = 'less';
case lexon = 'lexon'; case lexon = 'lexon';
@ -90,51 +89,9 @@ enum EditorLanguages: string implements HasLabel
case wgsl = 'wgsl'; case wgsl = 'wgsl';
case xml = 'xml'; case xml = 'xml';
case yaml = 'yaml'; case yaml = 'yaml';
case json = 'json';
public static function fromWithAlias(string $match): self public function getLabel(): ?string
{
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; return $this->name;
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
<?php
namespace App\Events\Auth;
use App\Models\User;
use App\Events\Event;
class DirectLogin extends Event
{
public function __construct(public User $user, public bool $remember) {}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Events\Auth;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class FailedPasswordReset extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public string $ip, public string $email) {}
}

View File

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

View File

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

View File

@ -0,0 +1,73 @@
<?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,9 +42,6 @@ class DataValidationException extends PanelException implements HttpExceptionInt
return 500; return 500;
} }
/**
* @return array<string, string>
*/
public function getHeaders(): array public function getHeaders(): array
{ {
return []; return [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,118 +0,0 @@
<?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

@ -1,106 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,28 @@
<?php <?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; namespace App\Extensions\Filesystem;
use Aws\S3\S3ClientInterface; use Aws\S3\S3ClientInterface;
@ -7,9 +30,6 @@ use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
class S3Filesystem extends AwsS3V3Adapter class S3Filesystem extends AwsS3V3Adapter
{ {
/**
* @param array<mixed> $options
*/
public function __construct( public function __construct(
private S3ClientInterface $client, private S3ClientInterface $client,
private string $bucket, private string $bucket,

View File

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

View File

@ -1,74 +0,0 @@
<?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

@ -1,38 +0,0 @@
<?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

@ -1,64 +0,0 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use 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(Blade::render('<p>Visit the <x-filament::link href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</x-filament::link> and click on <b>New Application</b>. Enter a <b>Name</b> (e.g. your panel name) and click on <b>Create</b>.</p><p>Copy the <b>Client ID</b> and the <b>Client Secret</b> from the OAuth2 tab, you will need them in the final step.</p>'))),
Placeholder::make('')
->content(new HtmlString('<p>Under <b>Redirects</b> add the below URL.</p>')),
TextInput::make('_noenv_callback')
->label('Redirect URL')
->dehydrated()
->disabled()
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn () => 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

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

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

View File

@ -1,131 +0,0 @@
<?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

@ -1,81 +0,0 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use 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(Blade::render('Visit <x-filament::link href="https://steamcommunity.com/dev/apikey" target="_blank">https://steamcommunity.com/dev/apikey</x-filament::link> to generate an API key.'))),
]),
], parent::getSetupSteps());
}
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

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

View File

@ -8,45 +8,25 @@ use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Spatie\Health\Commands\RunHealthChecksCommand; use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\Enums\Status;
use Spatie\Health\ResultStores\ResultStore; use Spatie\Health\ResultStores\ResultStore;
class Health extends Page class Health extends Page
{ {
protected static ?string $navigationIcon = 'tabler-heart'; protected static ?string $navigationIcon = 'tabler-heart';
protected static ?string $navigationGroup = 'Advanced';
protected static string $view = 'filament.pages.health'; protected static string $view = 'filament.pages.health';
/** @var array<string, string> */ // @phpstan-ignore-next-line
protected $listeners = [ protected $listeners = [
'refresh-component' => '$refresh', '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 protected function getActions(): array
{ {
return [ return [
Action::make('refresh') Action::make('refresh')
->label(trans('admin/health.refresh'))
->button() ->button()
->action('refresh'), ->action('refresh'),
]; ];
@ -54,7 +34,7 @@ class Health extends Page
protected function getViewData(): array protected function getViewData(): array
{ {
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions // @phpstan-ignore-next-line
$checkResults = app(ResultStore::class)->latestResults(); $checkResults = app(ResultStore::class)->latestResults();
if ($checkResults === null) { if ($checkResults === null) {
@ -76,14 +56,14 @@ class Health extends Page
$this->dispatch('refresh-component'); $this->dispatch('refresh-component');
Notification::make() Notification::make()
->title(trans('admin/health.results_refreshed')) ->title('Health check results refreshed')
->success() ->success()
->send(); ->send();
} }
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
{ {
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions // @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults(); $results = app(ResultStore::class)->latestResults();
if ($results === null) { if ($results === null) {
@ -106,7 +86,7 @@ class Health extends Page
public static function getNavigationBadgeTooltip(): ?string public static function getNavigationBadgeTooltip(): ?string
{ {
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions // @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults(); $results = app(ResultStore::class)->latestResults();
if ($results === null) { if ($results === null) {
@ -123,12 +103,12 @@ class Health extends Page
return $carry; return $carry;
}, []); }, []);
return trans('admin/health.checks.failed') . implode(', ', $failedNames); return 'Failed: ' . implode(', ', $failedNames);
} }
public static function getNavigationIcon(): string public static function getNavigationIcon(): string
{ {
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions // @phpstan-ignore-next-line
$results = app(ResultStore::class)->latestResults(); $results = app(ResultStore::class)->latestResults();
if ($results === null) { if ($results === null) {
@ -137,37 +117,4 @@ class Health extends Page
return $results->containsFailingCheck() ? 'tabler-heart-exclamation' : 'tabler-heart-check'; 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,20 +2,13 @@
namespace App\Filament\Admin\Pages; namespace App\Filament\Admin\Pages;
use App\Extensions\Avatar\AvatarProvider;
use App\Extensions\Captcha\Providers\CaptchaProvider;
use App\Extensions\OAuth\Providers\OAuthProvider;
use App\Models\Backup; use App\Models\Backup;
use App\Notifications\MailTested; use App\Notifications\MailTested;
use App\Traits\EnvironmentWriterTrait; use App\Traits\EnvironmentWriterTrait;
use Exception; use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action as FormAction; use Filament\Forms\Components\Actions\Action as FormAction;
use Filament\Forms\Components\Component; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Group;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs;
@ -34,10 +27,10 @@ use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth; use Filament\Support\Enums\MaxWidth;
use Illuminate\Http\Client\Factory; use Illuminate\Http\Client\Factory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Notification as MailNotification; use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\Str; use Illuminate\Support\HtmlString;
/** /**
* @property Form $form * @property Form $form
@ -50,9 +43,10 @@ class Settings extends Page implements HasForms
protected static ?string $navigationIcon = 'tabler-settings'; protected static ?string $navigationIcon = 'tabler-settings';
protected static ?string $navigationGroup = 'Advanced';
protected static string $view = 'filament.pages.settings'; protected static string $view = 'filament.pages.settings';
/** @var array<mixed>|null */
public ?array $data = []; public ?array $data = [];
public function mount(): void public function mount(): void
@ -65,16 +59,6 @@ class Settings extends Page implements HasForms
return auth()->user()->can('view settings'); 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 protected function getFormSchema(): array
{ {
return [ return [
@ -84,64 +68,49 @@ class Settings extends Page implements HasForms
->disabled(fn () => !auth()->user()->can('update settings')) ->disabled(fn () => !auth()->user()->can('update settings'))
->tabs([ ->tabs([
Tab::make('general') Tab::make('general')
->label(trans('admin/setting.navigation.general')) ->label('General')
->icon('tabler-home') ->icon('tabler-home')
->schema($this->generalSettings()), ->schema($this->generalSettings()),
Tab::make('captcha') Tab::make('captcha')
->label(trans('admin/setting.navigation.captcha')) ->label('Captcha')
->icon('tabler-shield') ->icon('tabler-shield')
->schema($this->captchaSettings()) ->schema($this->captchaSettings())
->columns(3), ->columns(3),
Tab::make('mail') Tab::make('mail')
->label(trans('admin/setting.navigation.mail')) ->label('Mail')
->icon('tabler-mail') ->icon('tabler-mail')
->schema($this->mailSettings()), ->schema($this->mailSettings()),
Tab::make('backup') Tab::make('backup')
->label(trans('admin/setting.navigation.backup')) ->label('Backup')
->icon('tabler-box') ->icon('tabler-box')
->schema($this->backupSettings()), ->schema($this->backupSettings()),
Tab::make('OAuth') Tab::make('OAuth')
->label(trans('admin/setting.navigation.oauth')) ->label('OAuth')
->icon('tabler-brand-oauth') ->icon('tabler-brand-oauth')
->schema($this->oauthSettings()), ->schema($this->oauthSettings()),
Tab::make('misc') Tab::make('misc')
->label(trans('admin/setting.navigation.misc')) ->label('Misc')
->icon('tabler-tool') ->icon('tabler-tool')
->schema($this->miscSettings()), ->schema($this->miscSettings()),
]), ]),
]; ];
} }
/** @return Component[] */
private function generalSettings(): array private function generalSettings(): array
{ {
return [ return [
TextInput::make('APP_NAME') TextInput::make('APP_NAME')
->label(trans('admin/setting.general.app_name')) ->label('App Name')
->required() ->required()
->default(env('APP_NAME', 'Pelican')), ->default(env('APP_NAME', 'Pelican')),
Group::make()
->columns(2)
->schema([
TextInput::make('APP_LOGO')
->label(trans('admin/setting.general.app_logo'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/setting.general.app_logo_help'))
->default(env('APP_LOGO'))
->placeholder('/pelican.svg'),
TextInput::make('APP_FAVICON') TextInput::make('APP_FAVICON')
->label(trans('admin/setting.general.app_favicon')) ->label('App Favicon')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/setting.general.app_favicon_help')) ->hintIconTooltip('Favicons should be placed in the public folder, located in the root panel directory.')
->required() ->required()
->default(env('APP_FAVICON', '/pelican.ico')) ->default(env('APP_FAVICON', '/pelican.ico')),
->placeholder('/pelican.ico'),
]),
Group::make()
->columns(2)
->schema([
Toggle::make('APP_DEBUG') Toggle::make('APP_DEBUG')
->label(trans('admin/setting.general.debug_mode')) ->label('Enable Debug Mode?')
->inline(false) ->inline(false)
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
@ -151,79 +120,52 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state))
->default(env('APP_DEBUG', config('app.debug'))), ->default(env('APP_DEBUG', config('app.debug'))),
ToggleButtons::make('FILAMENT_TOP_NAVIGATION') ToggleButtons::make('FILAMENT_TOP_NAVIGATION')
->label(trans('admin/setting.general.navigation')) ->label('Navigation')
->inline() ->inline()
->options([ ->options([
false => trans('admin/setting.general.sidebar'), false => 'Sidebar',
true => trans('admin/setting.general.topbar'), true => 'Topbar',
]) ])
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))), ->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
]),
Group::make()
->columns(2)
->schema([
Select::make('FILAMENT_AVATAR_PROVIDER')
->label(trans('admin/setting.general.avatar_provider'))
->native(false)
->options(collect(AvatarProvider::getAll())->mapWithKeys(fn ($provider) => [$provider->getId() => $provider->getName()]))
->selectablePlaceholder(false)
->default(env('FILAMENT_AVATAR_PROVIDER', config('panel.filament.avatar-provider'))),
Toggle::make('FILAMENT_UPLOADABLE_AVATARS')
->label(trans('admin/setting.general.uploadable_avatars'))
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state) => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_UPLOADABLE_AVATARS', (bool) $state))
->default(env('FILAMENT_UPLOADABLE_AVATARS', config('panel.filament.uploadable-avatars'))),
]),
ToggleButtons::make('PANEL_USE_BINARY_PREFIX') ToggleButtons::make('PANEL_USE_BINARY_PREFIX')
->label(trans('admin/setting.general.unit_prefix')) ->label('Unit prefix')
->inline() ->inline()
->options([ ->options([
false => trans('admin/setting.general.decimal_prefix'), false => 'Decimal Prefix (MB/ GB)',
true => trans('admin/setting.general.binary_prefix'), true => 'Binary Prefix (MiB/ GiB)',
]) ])
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_USE_BINARY_PREFIX', (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'))), ->default(env('PANEL_USE_BINARY_PREFIX', config('panel.use_binary_prefix'))),
ToggleButtons::make('APP_2FA_REQUIRED') ToggleButtons::make('APP_2FA_REQUIRED')
->label(trans('admin/setting.general.2fa_requirement')) ->label('2FA Requirement')
->inline() ->inline()
->options([ ->options([
0 => trans('admin/setting.general.not_required'), 0 => 'Not required',
1 => trans('admin/setting.general.admins_only'), 1 => 'Required for only Admins',
2 => trans('admin/setting.general.all_users'), 2 => 'Required for all Users',
]) ])
->formatStateUsing(fn ($state): int => (int) $state) ->formatStateUsing(fn ($state): int => (int) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_2FA_REQUIRED', (int) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('APP_2FA_REQUIRED', (int) $state))
->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))), ->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))),
Select::make('FILAMENT_WIDTH')
->label(trans('admin/setting.general.display_width'))
->native(false)
->options(MaxWidth::class)
->selectablePlaceholder(false)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
TagsInput::make('TRUSTED_PROXIES') TagsInput::make('TRUSTED_PROXIES')
->label(trans('admin/setting.general.trusted_proxies')) ->label('Trusted Proxies')
->separator() ->separator()
->splitKeys(['Tab', ' ']) ->splitKeys(['Tab', ' '])
->placeholder(trans('admin/setting.general.trusted_proxies_help')) ->placeholder('New IP or IP Range')
->default(env('TRUSTED_PROXIES', implode(',', Arr::wrap(config('trustedproxy.proxies'))))) ->default(env('TRUSTED_PROXIES', implode(',', config('trustedproxy.proxies'))))
->hintActions([ ->hintActions([
FormAction::make('clear') FormAction::make('clear')
->label(trans('admin/setting.general.clear')) ->label('Clear')
->color('danger') ->color('danger')
->icon('tabler-trash') ->icon('tabler-trash')
->requiresConfirmation() ->requiresConfirmation()
->authorize(fn () => auth()->user()->can('update settings')) ->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])), ->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])),
FormAction::make('cloudflare') FormAction::make('cloudflare')
->label(trans('admin/setting.general.set_to_cf')) ->label('Set to Cloudflare IPs')
->icon('tabler-brand-cloudflare') ->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings')) ->authorize(fn () => auth()->user()->can('update settings'))
->action(function (Factory $client, Set $set) { ->action(function (Factory $client, Set $set) {
@ -248,67 +190,63 @@ class Settings extends Page implements HasForms
$set('TRUSTED_PROXIES', $ips->values()->all()); $set('TRUSTED_PROXIES', $ips->values()->all());
}), }),
]), ]),
Select::make('FILAMENT_WIDTH')
->label('Display Width')
->native(false)
->options(MaxWidth::class)
->default(env('FILAMENT_WIDTH', config('panel.filament.display-width'))),
]; ];
} }
/**
* @return Component[]
*/
private function captchaSettings(): array private function captchaSettings(): array
{ {
$formFields = []; return [
Toggle::make('TURNSTILE_ENABLED')
$captchaProviders = CaptchaProvider::get(); ->label('Enable Turnstile Captcha?')
foreach ($captchaProviders as $captchaProvider) { ->inline(false)
$id = Str::upper($captchaProvider->getId()); ->columnSpan(1)
$name = Str::title($captchaProvider->getId()); ->onIcon('tabler-check')
->offIcon('tabler-x')
$formFields[] = Section::make($name) ->onColor('success')
->columns(5) ->offColor('danger')
->icon($captchaProvider->getIcon() ?? 'tabler-shield')
->collapsed(fn () => !env("CAPTCHA_{$id}_ENABLED", false))
->collapsible()
->schema([
Hidden::make("CAPTCHA_{$id}_ENABLED")
->live() ->live()
->default(env("CAPTCHA_{$id}_ENABLED")), ->formatStateUsing(fn ($state): bool => (bool) $state)
Actions::make([ ->afterStateUpdated(fn ($state, Set $set) => $set('TURNSTILE_ENABLED', (bool) $state))
FormAction::make("disable_captcha_$id") ->default(env('TURNSTILE_ENABLED', config('turnstile.turnstile_enabled'))),
->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED")) Placeholder::make('info')
->label(trans('admin/setting.captcha.disable')) ->columnSpan(2)
->color('danger') ->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>')),
->action(function (Set $set) use ($id) { TextInput::make('TURNSTILE_SITE_KEY')
$set("CAPTCHA_{$id}_ENABLED", false); ->label('Site Key')
}), ->required()
FormAction::make("enable_captcha_$id") ->visible(fn (Get $get) => $get('TURNSTILE_ENABLED'))
->visible(fn (Get $get) => !$get("CAPTCHA_{$id}_ENABLED")) ->default(env('TURNSTILE_SITE_KEY', config('turnstile.turnstile_site_key')))
->label(trans('admin/setting.captcha.enable')) ->placeholder('1x00000000000000000000AA'),
->color('success') TextInput::make('TURNSTILE_SECRET_KEY')
->action(function (Set $set) use ($id, $captchaProviders) { ->label('Secret Key')
foreach ($captchaProviders as $captchaProvider) { ->required()
$loopId = Str::upper($captchaProvider->getId()); ->visible(fn (Get $get) => $get('TURNSTILE_ENABLED'))
$set("CAPTCHA_{$loopId}_ENABLED", $loopId === $id); ->default(env('TURNSTILE_SECRET_KEY', config('turnstile.secret_key')))
} ->placeholder('1x0000000000000000000000000000000AA'),
}), Toggle::make('TURNSTILE_VERIFY_DOMAIN')
])->columnSpan(1), ->label('Verify domain?')
Group::make($captchaProvider->getSettingsForm()) ->inline(false)
->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED")) ->onIcon('tabler-check')
->columns(4) ->offIcon('tabler-x')
->columnSpan(4), ->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'))),
];
} }
return $formFields;
}
/**
* @return Component[]
*/
private function mailSettings(): array private function mailSettings(): array
{ {
return [ return [
ToggleButtons::make('MAIL_MAILER') ToggleButtons::make('MAIL_MAILER')
->label(trans('admin/setting.mail.mail_driver')) ->label('Mail Driver')
->columnSpanFull() ->columnSpanFull()
->inline() ->inline()
->options([ ->options([
@ -323,138 +261,96 @@ class Settings extends Page implements HasForms
->default(env('MAIL_MAILER', config('mail.default'))) ->default(env('MAIL_MAILER', config('mail.default')))
->hintAction( ->hintAction(
FormAction::make('test') FormAction::make('test')
->label(trans('admin/setting.mail.test_mail')) ->label('Send Test Mail')
->icon('tabler-send') ->icon('tabler-send')
->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log') ->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log')
->authorize(fn () => auth()->user()->can('update settings')) ->authorize(fn () => auth()->user()->can('update settings'))
->action(function (Get $get) { ->action(function () {
// 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.scheme' => config('mail.mailers.smtp.scheme'),
'mail.from.address' => config('mail.from.address'),
'mail.from.name' => config('mail.from.name'),
'services.mailgun.domain' => config('services.mailgun.domain'),
'services.mailgun.secret' => config('services.mailgun.secret'),
'services.mailgun.endpoint' => config('services.mailgun.endpoint'),
];
try { 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.scheme' => $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) MailNotification::route('mail', auth()->user()->email)
->notify(new MailTested(auth()->user())); ->notify(new MailTested(auth()->user()));
Notification::make() Notification::make()
->title(trans('admin/setting.mail.test_mail_sent')) ->title('Test Mail sent')
->success() ->success()
->send(); ->send();
} catch (Exception $exception) { } catch (Exception $exception) {
Notification::make() Notification::make()
->title(trans('admin/setting.mail.test_mail_failed')) ->title('Test Mail failed')
->body($exception->getMessage()) ->body($exception->getMessage())
->danger() ->danger()
->send(); ->send();
} finally {
config($originalConfig);
} }
}) })
), ),
Section::make(trans('admin/setting.mail.from_settings')) Section::make('"From" Settings')
->description(trans('admin/setting.mail.from_settings_help')) ->description('Set the Address and Name used as "From" in mails.')
->columns() ->columns()
->schema([ ->schema([
TextInput::make('MAIL_FROM_ADDRESS') TextInput::make('MAIL_FROM_ADDRESS')
->label(trans('admin/setting.mail.from_address')) ->label('From Address')
->required() ->required()
->email() ->email()
->default(env('MAIL_FROM_ADDRESS', config('mail.from.address'))), ->default(env('MAIL_FROM_ADDRESS', config('mail.from.address'))),
TextInput::make('MAIL_FROM_NAME') TextInput::make('MAIL_FROM_NAME')
->label(trans('admin/setting.mail.from_name')) ->label('From Name')
->required() ->required()
->default(env('MAIL_FROM_NAME', config('mail.from.name'))), ->default(env('MAIL_FROM_NAME', config('mail.from.name'))),
]), ]),
Section::make(trans('admin/setting.mail.smtp.smtp_title')) Section::make('SMTP Configuration')
->columns() ->columns()
->visible(fn (Get $get) => $get('MAIL_MAILER') === 'smtp') ->visible(fn (Get $get) => $get('MAIL_MAILER') === 'smtp')
->schema([ ->schema([
TextInput::make('MAIL_HOST') TextInput::make('MAIL_HOST')
->label(trans('admin/setting.mail.smtp.host')) ->label('Host')
->required() ->required()
->default(env('MAIL_HOST', config('mail.mailers.smtp.host'))), ->default(env('MAIL_HOST', config('mail.mailers.smtp.host'))),
TextInput::make('MAIL_PORT') TextInput::make('MAIL_PORT')
->label(trans('admin/setting.mail.smtp.port')) ->label('Port')
->required() ->required()
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(65535) ->maxValue(65535)
->default(env('MAIL_PORT', config('mail.mailers.smtp.port'))), ->default(env('MAIL_PORT', config('mail.mailers.smtp.port'))),
TextInput::make('MAIL_USERNAME') TextInput::make('MAIL_USERNAME')
->label(trans('admin/setting.mail.smtp.username')) ->label('Username')
->default(env('MAIL_USERNAME', config('mail.mailers.smtp.username'))), ->default(env('MAIL_USERNAME', config('mail.mailers.smtp.username'))),
TextInput::make('MAIL_PASSWORD') TextInput::make('MAIL_PASSWORD')
->label(trans('admin/setting.mail.smtp.password')) ->label('Password')
->password() ->password()
->revealable() ->revealable()
->default(env('MAIL_PASSWORD')), ->default(env('MAIL_PASSWORD')),
ToggleButtons::make('MAIL_SCHEME') ToggleButtons::make('MAIL_ENCRYPTION')
->label(trans('admin/setting.mail.smtp.scheme')) ->label('Encryption')
->inline() ->inline()
->options([ ->options(['tls' => 'TLS', 'ssl' => 'SSL', '' => 'None'])
'smtp' => 'SMTP', ->default(env('MAIL_ENCRYPTION', config('mail.mailers.smtp.encryption', 'tls'))),
'smtps' => 'SMTPS',
])
->default(env('MAIL_SCHEME', config('mail.mailers.smtp.scheme')))
->live()
->afterStateUpdated(function ($state, Set $set) {
$set('MAIL_PORT', $state === 'smtps' ? 587 : 2525);
}),
]), ]),
Section::make(trans('admin/setting.mail.mailgun.mailgun_title')) Section::make('Mailgun Configuration')
->columns() ->columns()
->visible(fn (Get $get) => $get('MAIL_MAILER') === 'mailgun') ->visible(fn (Get $get) => $get('MAIL_MAILER') === 'mailgun')
->schema([ ->schema([
TextInput::make('MAILGUN_DOMAIN') TextInput::make('MAILGUN_DOMAIN')
->label(trans('admin/setting.mail.mailgun.domain')) ->label('Domain')
->required() ->required()
->default(env('MAILGUN_DOMAIN', config('services.mailgun.domain'))), ->default(env('MAILGUN_DOMAIN', config('services.mailgun.domain'))),
TextInput::make('MAILGUN_SECRET') TextInput::make('MAILGUN_SECRET')
->label(trans('admin/setting.mail.mailgun.secret')) ->label('Secret')
->required() ->required()
->default(env('MAILGUN_SECRET', config('services.mailgun.secret'))), ->default(env('MAILGUN_SECRET', config('services.mailgun.secret'))),
TextInput::make('MAILGUN_ENDPOINT') TextInput::make('MAILGUN_ENDPOINT')
->label(trans('admin/setting.mail.mailgun.endpoint')) ->label('Endpoint')
->required() ->required()
->default(env('MAILGUN_ENDPOINT', config('services.mailgun.endpoint'))), ->default(env('MAILGUN_ENDPOINT', config('services.mailgun.endpoint'))),
]), ]),
]; ];
} }
/**
* @return Component[]
*/
private function backupSettings(): array private function backupSettings(): array
{ {
return [ return [
ToggleButtons::make('APP_BACKUP_DRIVER') ToggleButtons::make('APP_BACKUP_DRIVER')
->label(trans('admin/setting.backup.backup_driver')) ->label('Backup Driver')
->columnSpanFull() ->columnSpanFull()
->inline() ->inline()
->options([ ->options([
@ -463,50 +359,50 @@ class Settings extends Page implements HasForms
]) ])
->live() ->live()
->default(env('APP_BACKUP_DRIVER', config('backups.default'))), ->default(env('APP_BACKUP_DRIVER', config('backups.default'))),
Section::make(trans('admin/setting.backup.throttle')) Section::make('Throttles')
->description(trans('admin/setting.backup.throttle_help')) ->description('Configure how many backups can be created in a period. Set period to 0 to disable this throttle.')
->columns() ->columns()
->schema([ ->schema([
TextInput::make('BACKUP_THROTTLE_LIMIT') TextInput::make('BACKUP_THROTTLE_LIMIT')
->label(trans('admin/setting.backup.limit')) ->label('Limit')
->required() ->required()
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->default(config('backups.throttles.limit')), ->default(config('backups.throttles.limit')),
TextInput::make('BACKUP_THROTTLE_PERIOD') TextInput::make('BACKUP_THROTTLE_PERIOD')
->label(trans('admin/setting.backup.period')) ->label('Period')
->required() ->required()
->numeric() ->numeric()
->minValue(0) ->minValue(0)
->suffix('Seconds') ->suffix('Seconds')
->default(config('backups.throttles.period')), ->default(config('backups.throttles.period')),
]), ]),
Section::make(trans('admin/setting.backup.s3.s3_title')) Section::make('S3 Configuration')
->columns() ->columns()
->visible(fn (Get $get) => $get('APP_BACKUP_DRIVER') === Backup::ADAPTER_AWS_S3) ->visible(fn (Get $get) => $get('APP_BACKUP_DRIVER') === Backup::ADAPTER_AWS_S3)
->schema([ ->schema([
TextInput::make('AWS_DEFAULT_REGION') TextInput::make('AWS_DEFAULT_REGION')
->label(trans('admin/setting.backup.s3.default_region')) ->label('Default Region')
->required() ->required()
->default(config('backups.disks.s3.region')), ->default(config('backups.disks.s3.region')),
TextInput::make('AWS_ACCESS_KEY_ID') TextInput::make('AWS_ACCESS_KEY_ID')
->label(trans('admin/setting.backup.s3.access_key')) ->label('Access Key ID')
->required() ->required()
->default(config('backups.disks.s3.key')), ->default(config('backups.disks.s3.key')),
TextInput::make('AWS_SECRET_ACCESS_KEY') TextInput::make('AWS_SECRET_ACCESS_KEY')
->label(trans('admin/setting.backup.s3.secret_key')) ->label('Secret Access Key')
->required() ->required()
->default(config('backups.disks.s3.secret')), ->default(config('backups.disks.s3.secret')),
TextInput::make('AWS_BACKUPS_BUCKET') TextInput::make('AWS_BACKUPS_BUCKET')
->label(trans('admin/setting.backup.s3.bucket')) ->label('Bucket')
->required() ->required()
->default(config('backups.disks.s3.bucket')), ->default(config('backups.disks.s3.bucket')),
TextInput::make('AWS_ENDPOINT') TextInput::make('AWS_ENDPOINT')
->label(trans('admin/setting.backup.s3.endpoint')) ->label('Endpoint')
->required() ->required()
->default(config('backups.disks.s3.endpoint')), ->default(config('backups.disks.s3.endpoint')),
Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT') Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT')
->label(trans('admin/setting.backup.s3.use_path_style_endpoint')) ->label('Use path style endpoint?')
->inline(false) ->inline(false)
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
@ -520,77 +416,85 @@ class Settings extends Page implements HasForms
]; ];
} }
/**
* @return Component[]
*/
private function oauthSettings(): array private function oauthSettings(): array
{ {
$oauthProviders = Config::get('auth.oauth');
$formFields = []; $formFields = [];
$oauthProviders = OAuthProvider::get(); foreach ($oauthProviders as $providerName => $providerConfig) {
foreach ($oauthProviders as $oauthProvider) { $providerEnvPrefix = strtoupper($providerName);
$id = Str::upper($oauthProvider->getId());
$name = Str::title($oauthProvider->getId());
$formFields[] = Section::make($name) $fields = [
->columns(5) Toggle::make("OAUTH_{$providerEnvPrefix}_ENABLED")
->icon($oauthProvider->getIcon() ?? 'tabler-brand-oauth') ->onColor('success')
->collapsed(fn () => !env("OAUTH_{$id}_ENABLED", false)) ->offColor('danger')
->collapsible() ->onIcon('tabler-check')
->schema([ ->offIcon('tabler-x')
Hidden::make("OAUTH_{$id}_ENABLED")
->live() ->live()
->default(env("OAUTH_{$id}_ENABLED")), ->columnSpan(1)
Actions::make([ ->label('Enabled')
FormAction::make("disable_oauth_$id") ->default(env("OAUTH_{$providerEnvPrefix}_ENABLED", false)),
->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) { if (array_key_exists('client_id', $providerConfig['service'] ?? [])) {
$set($key, $value); $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');
} }
}),
])->columnSpan(1), if (array_key_exists('client_secret', $providerConfig['service'] ?? [])) {
Group::make($oauthProvider->getSettingsForm()) $fields[] = TextInput::make("OAUTH_{$providerEnvPrefix}_CLIENT_SECRET")
->visible(fn (Get $get) => $get("OAUTH_{$id}_ENABLED")) ->label('Client Secret')
->columns(4) ->columnSpan(2)
->columnSpan(4), ->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))
->columns(5)
->icon($providerConfig['icon'] ?? 'tabler-brand-oauth')
->collapsed(fn () => !env("OAUTH_{$providerEnvPrefix}_ENABLED", false))
->collapsible()
->schema($fields);
} }
return $formFields; return $formFields;
} }
/**
* @return Component[]
*/
private function miscSettings(): array private function miscSettings(): array
{ {
return [ return [
Section::make(trans('admin/setting.misc.auto_allocation.title')) Section::make('Automatic Allocation Creation')
->description(trans('admin/setting.misc.auto_allocation.helper')) ->description('Toggle if Users can create allocations via the client area.')
->columns() ->columns()
->collapsible() ->collapsible()
->collapsed() ->collapsed()
->schema([ ->schema([
Toggle::make('PANEL_CLIENT_ALLOCATIONS_ENABLED') Toggle::make('PANEL_CLIENT_ALLOCATIONS_ENABLED')
->label(trans('admin/setting.misc.auto_allocation.question')) ->label('Allow Users to create allocations?')
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
->onColor('success') ->onColor('success')
@ -601,7 +505,7 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_CLIENT_ALLOCATIONS_ENABLED', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_CLIENT_ALLOCATIONS_ENABLED', (bool) $state))
->default(env('PANEL_CLIENT_ALLOCATIONS_ENABLED', config('panel.client_features.allocations.enabled'))), ->default(env('PANEL_CLIENT_ALLOCATIONS_ENABLED', config('panel.client_features.allocations.enabled'))),
TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_START') TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_START')
->label(trans('admin/setting.misc.auto_allocation.start')) ->label('Starting Port')
->required() ->required()
->numeric() ->numeric()
->minValue(1024) ->minValue(1024)
@ -609,7 +513,7 @@ class Settings extends Page implements HasForms
->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED')) ->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED'))
->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_START')), ->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_START')),
TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_END') TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_END')
->label(trans('admin/setting.misc.auto_allocation.end')) ->label('Ending Port')
->required() ->required()
->numeric() ->numeric()
->minValue(1024) ->minValue(1024)
@ -617,72 +521,74 @@ class Settings extends Page implements HasForms
->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED')) ->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED'))
->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_END')), ->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_END')),
]), ]),
Section::make(trans('admin/setting.misc.mail_notifications.title')) Section::make('Mail Notifications')
->description(trans('admin/setting.misc.mail_notifications.helper')) ->description('Toggle which mail notifications should be sent to Users.')
->columns() ->columns()
->collapsible() ->collapsible()
->collapsed() ->collapsed()
->schema([ ->schema([
Toggle::make('PANEL_SEND_INSTALL_NOTIFICATION') Toggle::make('PANEL_SEND_INSTALL_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.server_installed')) ->label('Server Installed')
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
->onColor('success') ->onColor('success')
->offColor('danger') ->offColor('danger')
->live() ->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_INSTALL_NOTIFICATION', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_INSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_INSTALL_NOTIFICATION', config('panel.email.send_install_notification'))), ->default(env('PANEL_SEND_INSTALL_NOTIFICATION', config('panel.email.send_install_notification'))),
Toggle::make('PANEL_SEND_REINSTALL_NOTIFICATION') Toggle::make('PANEL_SEND_REINSTALL_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.server_reinstalled')) ->label('Server Reinstalled')
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
->onColor('success') ->onColor('success')
->offColor('danger') ->offColor('danger')
->live() ->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))), ->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))),
]), ]),
Section::make(trans('admin/setting.misc.connections.title')) Section::make('Connections')
->description(trans('admin/setting.misc.connections.helper')) ->description('Timeouts used when making requests.')
->columns() ->columns()
->collapsible() ->collapsible()
->collapsed() ->collapsed()
->schema([ ->schema([
TextInput::make('GUZZLE_TIMEOUT') TextInput::make('GUZZLE_TIMEOUT')
->label(trans('admin/setting.misc.connections.request_timeout')) ->label('Request Timeout')
->required() ->required()
->numeric() ->numeric()
->minValue(15) ->minValue(15)
->maxValue(60) ->maxValue(60)
->suffix(trans('admin/setting.misc.connections.seconds')) ->suffix('Seconds')
->default(env('GUZZLE_TIMEOUT', config('panel.guzzle.timeout'))), ->default(env('GUZZLE_TIMEOUT', config('panel.guzzle.timeout'))),
TextInput::make('GUZZLE_CONNECT_TIMEOUT') TextInput::make('GUZZLE_CONNECT_TIMEOUT')
->label(trans('admin/setting.misc.connections.connection_timeout')) ->label('Connect Timeout')
->required() ->required()
->numeric() ->numeric()
->minValue(5) ->minValue(5)
->maxValue(60) ->maxValue(60)
->suffix(trans('admin/setting.misc.connections.seconds')) ->suffix('Seconds')
->default(env('GUZZLE_CONNECT_TIMEOUT', config('panel.guzzle.connect_timeout'))), ->default(env('GUZZLE_CONNECT_TIMEOUT', config('panel.guzzle.connect_timeout'))),
]), ]),
Section::make(trans('admin/setting.misc.activity_log.title')) Section::make('Activity Logs')
->description(trans('admin/setting.misc.activity_log.helper')) ->description('Configure how often old activity logs should be pruned and whether admin activities should be logged.')
->columns() ->columns()
->collapsible() ->collapsible()
->collapsed() ->collapsed()
->schema([ ->schema([
TextInput::make('APP_ACTIVITY_PRUNE_DAYS') TextInput::make('APP_ACTIVITY_PRUNE_DAYS')
->label(trans('admin/setting.misc.activity_log.prune_age')) ->label('Prune age')
->required() ->required()
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(365) ->maxValue(365)
->suffix(trans('admin/setting.misc.activity_log.days')) ->suffix('Days')
->default(env('APP_ACTIVITY_PRUNE_DAYS', config('activity.prune_days'))), ->default(env('APP_ACTIVITY_PRUNE_DAYS', config('activity.prune_days'))),
Toggle::make('APP_ACTIVITY_HIDE_ADMIN') Toggle::make('APP_ACTIVITY_HIDE_ADMIN')
->label(trans('admin/setting.misc.activity_log.log_admin')) ->label('Hide admin activities?')
->inline(false) ->inline(false)
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
@ -693,65 +599,58 @@ class Settings extends Page implements HasForms
->afterStateUpdated(fn ($state, Set $set) => $set('APP_ACTIVITY_HIDE_ADMIN', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('APP_ACTIVITY_HIDE_ADMIN', (bool) $state))
->default(env('APP_ACTIVITY_HIDE_ADMIN', config('activity.hide_admin_activity'))), ->default(env('APP_ACTIVITY_HIDE_ADMIN', config('activity.hide_admin_activity'))),
]), ]),
Section::make(trans('admin/setting.misc.api.title')) Section::make('API')
->description(trans('admin/setting.misc.api.helper')) ->description('Defines the rate limit for the number of requests per minute that can be executed.')
->columns() ->columns()
->collapsible() ->collapsible()
->collapsed() ->collapsed()
->schema([ ->schema([
TextInput::make('APP_API_CLIENT_RATELIMIT') TextInput::make('APP_API_CLIENT_RATELIMIT')
->label(trans('admin/setting.misc.api.client_rate')) ->label('Client API Rate Limit')
->required() ->required()
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->suffix(trans('admin/setting.misc.api.rpm')) ->suffix('Requests Per Minute')
->default(env('APP_API_CLIENT_RATELIMIT', config('http.rate_limit.client'))), ->default(env('APP_API_CLIENT_RATELIMIT', config('http.rate_limit.client'))),
TextInput::make('APP_API_APPLICATION_RATELIMIT') TextInput::make('APP_API_APPLICATION_RATELIMIT')
->label(trans('admin/setting.misc.api.app_rate')) ->label('Application API Rate Limit')
->required() ->required()
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->suffix(trans('admin/setting.misc.api.rpm')) ->suffix('Requests Per Minute')
->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))), ->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))),
]), ]),
Section::make(trans('admin/setting.misc.server.title')) Section::make('Server')
->description(trans('admin/setting.misc.server.helper')) ->description('Settings for Servers.')
->columns() ->columns()
->collapsible() ->collapsible()
->collapsed() ->collapsed()
->schema([ ->schema([
Toggle::make('PANEL_EDITABLE_SERVER_DESCRIPTIONS') Toggle::make('PANEL_EDITABLE_SERVER_DESCRIPTIONS')
->label(trans('admin/setting.misc.server.edit_server_desc')) ->label('Allow Users to edit Server Descriptions?')
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
->onColor('success') ->onColor('success')
->offColor('danger') ->offColor('danger')
->live() ->live()
->columnSpan(1) ->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state))
->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))), ->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))),
FileUpload::make('ConsoleFonts')
->hint(trans('admin/setting.misc.server.console_font_hint'))
->label(trans('admin/setting.misc.server.console_font_upload'))
->directory('fonts')
->columnSpan(1)
->maxFiles(1)
->preserveFilenames(),
]), ]),
Section::make(trans('admin/setting.misc.webhook.title')) Section::make('Webhook')
->description(trans('admin/setting.misc.webhook.helper')) ->description('Configure how often old webhook logs should be pruned.')
->columns() ->columns()
->collapsible() ->collapsible()
->collapsed() ->collapsed()
->schema([ ->schema([
TextInput::make('APP_WEBHOOK_PRUNE_DAYS') TextInput::make('APP_WEBHOOK_PRUNE_DAYS')
->label(trans('admin/setting.misc.webhook.prune_age')) ->label('Prune age')
->required() ->required()
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(365) ->maxValue(365)
->suffix(trans('admin/setting.misc.webhook.days')) ->suffix('Days')
->default(env('APP_WEBHOOK_PRUNE_DAYS', config('panel.webhook.prune_days'))), ->default(env('APP_WEBHOOK_PRUNE_DAYS', config('panel.webhook.prune_days'))),
]), ]),
]; ];
@ -766,7 +665,6 @@ class Settings extends Page implements HasForms
{ {
try { try {
$data = $this->form->getState(); $data = $this->form->getState();
unset($data['ConsoleFonts']);
// Convert bools to a string, so they are correctly written to the .env file // Convert bools to a string, so they are correctly written to the .env file
$data = array_map(fn ($value) => is_bool($value) ? ($value ? 'true' : 'false') : $value, $data); $data = array_map(fn ($value) => is_bool($value) ? ($value ? 'true' : 'false') : $value, $data);
@ -779,12 +677,12 @@ class Settings extends Page implements HasForms
$this->redirect($this->getUrl()); $this->redirect($this->getUrl());
Notification::make() Notification::make()
->title(trans('admin/setting.save_success')) ->title('Settings saved')
->success() ->success()
->send(); ->send();
} catch (Exception $exception) { } catch (Exception $exception) {
Notification::make() Notification::make()
->title(trans('admin/setting.save_failed')) ->title('Save failed')
->body($exception->getMessage()) ->body($exception->getMessage())
->danger() ->danger()
->send(); ->send();

View File

@ -3,143 +3,26 @@
namespace App\Filament\Admin\Resources; namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\ApiKeyResource\Pages; 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 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\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 class ApiKeyResource extends Resource
{ {
protected static ?string $model = ApiKey::class; 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 $navigationIcon = 'tabler-key';
public static function getNavigationLabel(): string protected static ?string $navigationGroup = 'Advanced';
{
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 public static function getNavigationBadge(): ?string
{ {
return (string) static::getEloquentQuery()->count() ?: null; return static::getModel()::where('key_type', ApiKey::TYPE_APPLICATION)->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', $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 public static function getPages(): array

View File

@ -4,6 +4,12 @@ namespace App\Filament\Admin\Resources\ApiKeyResource\Pages;
use App\Filament\Admin\Resources\ApiKeyResource; use App\Filament\Admin\Resources\ApiKeyResource;
use App\Models\ApiKey; 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 Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -25,13 +31,78 @@ class CreateApiKey extends CreateRecord
return []; 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 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 = []; $permissions = [];
foreach (ApiKey::getPermissionList() as $permission) { foreach (ApiKey::getPermissionList() as $permission) {

View File

@ -3,18 +3,70 @@
namespace App\Filament\Admin\Resources\ApiKeyResource\Pages; namespace App\Filament\Admin\Resources\ApiKeyResource\Pages;
use App\Filament\Admin\Resources\ApiKeyResource; use App\Filament\Admin\Resources\ApiKeyResource;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\ApiKey; use App\Models\ApiKey;
use Filament\Actions\CreateAction; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; 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 class ListApiKeys extends ListRecords
{ {
protected static string $resource = ApiKeyResource::class; 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 protected function getHeaderActions(): array
{ {
return [ return [
CreateAction::make() Actions\CreateAction::make()
->label('Create API Key')
->hidden(fn () => ApiKey::where('key_type', ApiKey::TYPE_APPLICATION)->count() <= 0), ->hidden(fn () => ApiKey::where('key_type', ApiKey::TYPE_APPLICATION)->count() <= 0),
]; ];
} }

View File

@ -4,19 +4,7 @@ namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\DatabaseHostResource\Pages; use App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Models\DatabaseHost; 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\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\Database\Eloquent\Builder;
class DatabaseHostResource extends Resource class DatabaseHostResource extends Resource
{ {
@ -24,130 +12,13 @@ class DatabaseHostResource extends Resource
protected static ?string $navigationIcon = 'tabler-database'; protected static ?string $navigationIcon = 'tabler-database';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $recordTitleAttribute = 'name'; protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string public static function getNavigationBadge(): ?string
{ {
return (string) static::getEloquentQuery()->count() ?: null; 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', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))),
]),
]);
} }
public static function getPages(): array public static function getPages(): array
@ -155,19 +26,7 @@ class DatabaseHostResource extends Resource
return [ return [
'index' => Pages\ListDatabaseHosts::route('/'), 'index' => Pages\ListDatabaseHosts::route('/'),
'create' => Pages\CreateDatabaseHost::route('/create'), 'create' => Pages\CreateDatabaseHost::route('/create'),
'view' => Pages\ViewDatabaseHost::route('/{record}'),
'edit' => Pages\EditDatabaseHost::route('/{record}/edit'), 'edit' => Pages\EditDatabaseHost::route('/{record}/edit'),
]; ];
} }
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
return $query->where(function (Builder $query) {
return $query->whereHas('nodes', function (Builder $query) {
$query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'));
})->orDoesntHave('nodes');
});
}
} }

View File

@ -4,30 +4,19 @@ namespace App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Filament\Admin\Resources\DatabaseHostResource; use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Services\Databases\Hosts\HostCreationService; use App\Services\Databases\Hosts\HostCreationService;
use Filament\Forms\Components\Fieldset; use Filament\Forms;
use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Form;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Filament\Resources\Pages\CreateRecord\Concerns\HasWizard;
use Filament\Support\Exceptions\Halt; use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use PDOException; use PDOException;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class CreateDatabaseHost extends CreateRecord class CreateDatabaseHost extends CreateRecord
{ {
use HasWizard;
protected static string $resource = DatabaseHostResource::class; protected static string $resource = DatabaseHostResource::class;
protected static bool $canCreateAnother = false; protected static bool $canCreateAnother = false;
@ -39,125 +28,82 @@ class CreateDatabaseHost extends CreateRecord
$this->service = $service; $this->service = $service;
} }
/** @return Step[] */ public function form(Form $form): Form
public function getSteps(): array
{ {
return [ return $form
Step::make(trans('admin/databasehost.setup.preparations'))
->columns()
->schema([ ->schema([
Placeholder::make('') Section::make()
->content(trans('admin/databasehost.setup.note')),
Toggle::make('different_server')
->label(new HtmlString(trans('admin/databasehost.setup.different_server')))
->dehydrated(false)
->live()
->columnSpanFull()
->afterStateUpdated(fn ($state, Set $set) => $state ? $set('panel_ip', gethostbyname(str(config('app.url'))->replace(['http:', 'https:', '/'], ''))) : '127.0.0.1'),
Hidden::make('panel_ip')
->default('127.0.0.1')
->dehydrated(false),
TextInput::make('username')
->label(trans('admin/databasehost.username'))
->helperText(trans('admin/databasehost.username_help'))
->required()
->default('pelicanuser')
->maxLength(255),
TextInput::make('password')
->label(trans('admin/databasehost.password'))
->helperText(trans('admin/databasehost.password_help'))
->required()
->default(Str::password(16))
->password()
->revealable()
->maxLength(255),
])
->afterValidation(function (Get $get, Set $set) {
$set('create_user', "CREATE USER '{$get('username')}'@'{$get('panel_ip')}' IDENTIFIED BY '{$get('password')}';");
$set('assign_permissions', "GRANT ALL PRIVILEGES ON *.* TO '{$get('username')}'@'{$get('panel_ip')}' WITH GRANT OPTION;");
}),
Step::make(trans('admin/databasehost.setup.database_setup'))
->schema([
Fieldset::make(trans('admin/databasehost.setup.database_user'))
->schema([
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.cli_login')))
->columnSpanFull(),
TextInput::make('create_user')
->label(trans('admin/databasehost.setup.command_create_user'))
->default(fn (Get $get) => "CREATE USER '{$get('username')}'@'{$get('panel_ip')}' IDENTIFIED BY '{$get('password')}';")
->disabled()
->dehydrated(false)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull(),
TextInput::make('assign_permissions')
->label(trans('admin/databasehost.setup.command_assign_permissions'))
->default(fn (Get $get) => "GRANT ALL PRIVILEGES ON *.* TO '{$get('username')}'@'{$get('panel_ip')}' WITH GRANT OPTION;")
->disabled()
->dehydrated(false)
->suffixAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->columnSpanFull(),
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.cli_exit')))
->columnSpanFull(),
]),
Fieldset::make(trans('admin/databasehost.setup.external_access'))
->schema([
Placeholder::make('')
->content(new HtmlString(trans('admin/databasehost.setup.allow_external_access')))
->columnSpanFull(),
]),
]),
Step::make(trans('admin/databasehost.setup.panel_setup'))
->columns([ ->columns([
'default' => 2, 'default' => 2,
'lg' => 3, 'sm' => 3,
'md' => 3,
'lg' => 4,
]) ])
->schema([ ->schema([
TextInput::make('host') TextInput::make('host')
->columnSpan(2) ->columnSpan(2)
->label(trans('admin/databasehost.host')) ->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->helperText(trans('admin/databasehost.host_help'))
->required() ->required()
->live(onBlur: true) ->live(onBlur: true)
->afterStateUpdated(fn ($state, Set $set) => $set('name', $state)) ->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxLength(255), ->maxLength(255),
TextInput::make('port') TextInput::make('port')
->label(trans('admin/databasehost.port')) ->columnSpan(1)
->helperText(trans('admin/databasehost.port_help')) ->helperText('The port that MySQL is running on for this host.')
->required() ->required()
->numeric() ->numeric()
->default(3306) ->default(3306)
->minValue(0) ->minValue(0)
->maxValue(65535), ->maxValue(65535),
TextInput::make('max_databases') TextInput::make('max_databases')
->label(trans('admin/databasehost.max_database')) ->label('Max databases')
->helpertext(trans('admin/databasehost.max_databases_help')) ->helpertext('Blank is unlimited.')
->placeholder(trans('admin/databasehost.unlimited'))
->numeric(), ->numeric(),
TextInput::make('name') TextInput::make('name')
->label(trans('admin/databasehost.display_name')) ->label('Display Name')
->helperText(trans('admin/databasehost.display_name_help')) ->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->required() ->required()
->maxLength(60), ->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') Select::make('node_ids')
->multiple() ->multiple()
->searchable() ->searchable()
->preload() ->preload()
->helperText(trans('admin/databasehost.linked_nodes_help')) ->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label(trans('admin/databasehost.linked_nodes')) ->label('Linked Nodes')
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', auth()->user()->accessibleNodes()->pluck('id'))), ->relationship('nodes', 'name'),
]), ]),
]);
}
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
]; ];
} }
protected function getFormActions(): array
{
return [];
}
protected function handleRecordCreation(array $data): Model protected function handleRecordCreation(array $data): Model
{ {
try { try {
return $this->service->handle($data); return $this->service->handle($data);
} catch (PDOException $exception) { } catch (PDOException $exception) {
Notification::make() Notification::make()
->title(trans('admin/databasehost.error')) ->title('Error connecting to database host')
->body($exception->getMessage()) ->body($exception->getMessage())
->color('danger') ->color('danger')
->icon('tabler-database') ->icon('tabler-database')

View File

@ -6,7 +6,12 @@ use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Filament\Admin\Resources\DatabaseHostResource\RelationManagers\DatabasesRelationManager; use App\Filament\Admin\Resources\DatabaseHostResource\RelationManagers\DatabasesRelationManager;
use App\Models\DatabaseHost; use App\Models\DatabaseHost;
use App\Services\Databases\Hosts\HostUpdateService; use App\Services\Databases\Hosts\HostUpdateService;
use Filament\Actions\DeleteAction; 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\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Filament\Support\Exceptions\Halt; use Filament\Support\Exceptions\Halt;
@ -24,11 +29,66 @@ class EditDatabaseHost extends EditRecord
$this->hostUpdateService = $hostUpdateService; $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 protected function getHeaderActions(): array
{ {
return [ return [
DeleteAction::make() Actions\DeleteAction::make()
->label(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0 ? trans('admin/databasehost.delete_help') : trans('filament-actions::delete.single.modal.actions.delete.label')) ->label(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0 ? 'Database Host Has Databases' : 'Delete')
->disabled(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0), ->disabled(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0),
$this->getSaveFormAction()->formId('form'), $this->getSaveFormAction()->formId('form'),
]; ];
@ -60,7 +120,7 @@ class EditDatabaseHost extends EditRecord
return $this->hostUpdateService->handle($record, $data); return $this->hostUpdateService->handle($record, $data);
} catch (PDOException $exception) { } catch (PDOException $exception) {
Notification::make() Notification::make()
->title(trans('admin/databasehost.error')) ->title('Error connecting to database host')
->body($exception->getMessage()) ->body($exception->getMessage())
->color('danger') ->color('danger')
->icon('tabler-database') ->icon('tabler-database')

View File

@ -4,17 +4,69 @@ namespace App\Filament\Admin\Resources\DatabaseHostResource\Pages;
use App\Filament\Admin\Resources\DatabaseHostResource; use App\Filament\Admin\Resources\DatabaseHostResource;
use App\Models\DatabaseHost; use App\Models\DatabaseHost;
use Filament\Actions\CreateAction; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; 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 class ListDatabaseHosts extends ListRecords
{ {
protected static string $resource = DatabaseHostResource::class; 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 protected function getHeaderActions(): array
{ {
return [ return [
CreateAction::make() Actions\CreateAction::make('create')
->label('Create Database Host')
->hidden(fn () => DatabaseHost::count() <= 0), ->hidden(fn () => DatabaseHost::count() <= 0),
]; ];
} }

View File

@ -1,31 +0,0 @@
<?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,22 +23,19 @@ class DatabasesRelationManager extends RelationManager
->schema([ ->schema([
TextInput::make('database') TextInput::make('database')
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('username') TextInput::make('username'),
->label(trans('admin/databasehost.table.username')),
TextInput::make('password') TextInput::make('password')
->label(trans('admin/databasehost.table.password'))
->password() ->password()
->revealable() ->revealable()
->hintAction(RotateDatabasePasswordAction::make()) ->hintAction(RotateDatabasePasswordAction::make())
->formatStateUsing(fn (Database $database) => $database->password), ->formatStateUsing(fn (Database $database) => $database->password),
TextInput::make('remote') TextInput::make('remote')
->label(trans('admin/databasehost.table.remote')) ->label('Connections From')
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote), ->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
TextInput::make('max_connections') TextInput::make('max_connections')
->label(trans('admin/databasehost.table.max_connections')) ->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections),
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections),
TextInput::make('jdbc') TextInput::make('jdbc')
->label(trans('admin/databasehost.table.connection_string')) ->label('JDBC Connection String')
->columnSpanFull() ->columnSpanFull()
->password() ->password()
->revealable() ->revealable()
@ -50,31 +47,26 @@ class DatabasesRelationManager extends RelationManager
{ {
return $table return $table
->recordTitleAttribute('servers') ->recordTitleAttribute('servers')
->heading('')
->columns([ ->columns([
TextColumn::make('database') TextColumn::make('database')
->icon('tabler-database'), ->icon('tabler-database'),
TextColumn::make('username') TextColumn::make('username')
->label(trans('admin/databasehost.table.username'))
->icon('tabler-user'), ->icon('tabler-user'),
TextColumn::make('remote') TextColumn::make('remote')
->label(trans('admin/databasehost.table.remote')) ->formatStateUsing(fn (Database $record) => $record->remote === '%' ? 'Anywhere ( % )' : $record->remote),
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote),
TextColumn::make('server.name') TextColumn::make('server.name')
->icon('tabler-brand-docker') ->icon('tabler-brand-docker')
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])), ->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
TextColumn::make('max_connections') TextColumn::make('max_connections')
->label(trans('admin/databasehost.table.max_connections')) ->formatStateUsing(fn ($record) => $record->max_connections === 0 ? 'Unlimited' : $record->max_connections),
->formatStateUsing(fn ($record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections), DateTimeColumn::make('created_at'),
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
]) ])
->actions([ ->actions([
DeleteAction::make() DeleteAction::make()
->authorize(fn (Database $database) => auth()->user()->can('delete', $database)), ->authorize(fn (Database $database) => auth()->user()->can('delete database', $database)),
ViewAction::make() ViewAction::make()
->color('primary') ->color('primary')
->hidden(fn () => !auth()->user()->can('viewAny', Database::class)), ->hidden(fn () => !auth()->user()->can('viewList database')),
]); ]);
} }
} }

View File

@ -19,26 +19,6 @@ class EggResource extends Resource
return static::getModel()::count() ?: null; return static::getModel()::count() ?: null;
} }
public static function getNavigationGroup(): ?string
{
return config('panel.filament.top-navigation', false) ? null : 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 public static function getGloballySearchableAttributes(): array
{ {
return ['name', 'tags', 'uuid', 'id']; return ['name', 'tags', 'uuid', 'id'];

View File

@ -4,8 +4,6 @@ namespace App\Filament\Admin\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor; use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Admin\Resources\EggResource; 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\Checkbox;
use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Hidden;
@ -19,12 +17,10 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set; use Filament\Forms\Set;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Unique;
class CreateEgg extends CreateRecord class CreateEgg extends CreateRecord
{ {
@ -49,101 +45,97 @@ class CreateEgg extends CreateRecord
return $form return $form
->schema([ ->schema([
Tabs::make()->tabs([ Tabs::make()->tabs([
Tab::make(trans('admin/egg.tabs.configuration')) Tab::make('Configuration')
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4]) ->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
->schema([ ->schema([
TextInput::make('name') TextInput::make('name')
->label(trans('admin/egg.name'))
->required() ->required()
->maxLength(255) ->maxLength(255)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText(trans('admin/egg.name_help')), ->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
TextInput::make('author') TextInput::make('author')
->label(trans('admin/egg.author'))
->maxLength(255) ->maxLength(255)
->required() ->required()
->email() ->email()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText(trans('admin/egg.author_help')), ->helperText('The author of this version of the Egg.'),
Textarea::make('description') Textarea::make('description')
->label(trans('admin/egg.description')) ->rows(3)
->rows(2)
->columnSpanFull() ->columnSpanFull()
->helperText(trans('admin/egg.description_help')), ->helperText('A description of this Egg that will be displayed throughout the Panel as needed.'),
Textarea::make('startup') Textarea::make('startup')
->label(trans('admin/egg.startup'))
->rows(3) ->rows(3)
->columnSpanFull() ->columnSpanFull()
->required() ->required()
->placeholder(implode("\n", [ ->placeholder(implode("\n", [
'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}', 'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}',
])) ]))
->helperText(trans('admin/egg.startup_help')), ->helperText('The default startup command that should be used for new servers using this Egg.'),
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') TagsInput::make('features')
->label(trans('admin/egg.features')) ->placeholder('Add Feature')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1]), ->helperText('')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
Toggle::make('force_outgoing_ip') Toggle::make('force_outgoing_ip')
->label(trans('admin/egg.force_ip'))
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/egg.force_ip_help')), ->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."),
Hidden::make('script_is_privileged') Hidden::make('script_is_privileged')
->default(1), ->default(1),
TagsInput::make('tags') TagsInput::make('tags')
->label(trans('admin/egg.tags')) ->placeholder('Add Tags')
->helperText('')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]), ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TextInput::make('update_url') TextInput::make('update_url')
->label(trans('admin/egg.update_url'))
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/egg.update_url_help')) ->hintIconTooltip('URLs must point directly to the raw .json file.')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->url(), ->url(),
KeyValue::make('docker_images') KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live() ->live()
->columnSpanFull() ->columnSpanFull()
->required() ->required()
->addActionLabel(trans('admin/egg.add_image')) ->addActionLabel('Add Image')
->keyLabel(trans('admin/egg.docker_name')) ->keyLabel('Name')
->keyPlaceholder('Java 21') ->keyPlaceholder('Java 21')
->valueLabel(trans('admin/egg.docker_uri')) ->valueLabel('Image URI')
->valuePlaceholder('ghcr.io/parkervcp/yolks:java_21') ->valuePlaceholder('ghcr.io/parkervcp/yolks:java_21')
->helperText(trans('admin/egg.docker_help')), ->helperText('The docker images available to servers using this egg.'),
]), ]),
Tab::make(trans('admin/egg.tabs.process_management')) Tab::make('Process Management')
->columns() ->columns()
->schema([ ->schema([
CopyFrom::make('copy_process_from') Hidden::make('config_from')
->process(), ->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.'),
TextInput::make('config_stop') TextInput::make('config_stop')
->label(trans('admin/egg.stop_command'))
->required() ->required()
->maxLength(255) ->maxLength(255)
->helperText(trans('admin/egg.stop_command_help')), ->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.'),
Textarea::make('config_startup')->rows(10)->json() Textarea::make('config_startup')->rows(10)->json()
->label(trans('admin/egg.start_config')) ->label('Start Configuration')
->default('{}') ->default('{}')
->helperText(trans('admin/egg.start_config_help')), ->helperText('List of values the daemon should be looking for when booting a server to determine completion.'),
Textarea::make('config_files')->rows(10)->json() Textarea::make('config_files')->rows(10)->json()
->label(trans('admin/egg.config_files')) ->label('Configuration Files')
->default('{}') ->default('{}')
->helperText(trans('admin/egg.config_files_help')), ->helperText('This should be a JSON representation of configuration files to modify and what parts should be changed.'),
Textarea::make('config_logs')->rows(10)->json() Textarea::make('config_logs')->rows(10)->json()
->label(trans('admin/egg.log_config')) ->label('Log Configuration')
->default('{}') ->default('{}')
->helperText(trans('admin/egg.log_config_help')), ->helperText('This should be a JSON representation of where log files are stored, and whether or not the daemon should be creating custom logs.'),
]), ]),
Tab::make(trans('admin/egg.tabs.egg_variables')) Tab::make('Egg Variables')
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Repeater::make('variables') Repeater::make('variables')
->label('') ->label('')
->addActionLabel(trans('admin/egg.add_new_variable')) ->addActionLabel('Add New Egg Variable')
->grid() ->grid()
->relationship('variables') ->relationship('variables')
->name('name') ->name('name')
@ -172,42 +164,31 @@ class CreateEgg extends CreateRecord
}) })
->schema([ ->schema([
TextInput::make('name') TextInput::make('name')
->label(trans('admin/egg.name'))
->live() ->live()
->debounce(750) ->debounce(750)
->maxLength(255) ->maxLength(255)
->columnSpanFull() ->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(), ->required(),
Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(), Textarea::make('description')->columnSpanFull(),
TextInput::make('env_variable') TextInput::make('env_variable')
->label(trans('admin/egg.environment_variable')) ->label('Environment Variable')
->maxLength(255) ->maxLength(255)
->prefix('{{') ->prefix('{{')
->suffix('}}') ->suffix('}}')
->hintIcon('tabler-code') ->hintIcon('tabler-code')
->hintIconTooltip(fn ($state) => "{{{$state}}}") ->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(), ->required(),
TextInput::make('default_value')->label(trans('admin/egg.default_value'))->maxLength(255), TextInput::make('default_value')->maxLength(255),
Fieldset::make(trans('admin/egg.user_permissions')) Fieldset::make('User Permissions')
->schema([ ->schema([
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')), Checkbox::make('user_viewable')->label('Viewable'),
Checkbox::make('user_editable')->label(trans('admin/egg.editable')), Checkbox::make('user_editable')->label('Editable'),
]), ]),
TagsInput::make('rules') TagsInput::make('rules')
->label(trans('admin/egg.rules'))
->columnSpanFull() ->columnSpanFull()
->placeholder('Add Rule')
->reorderable() ->reorderable()
->suggestions([ ->suggestions([
'required', 'required',
@ -231,25 +212,26 @@ class CreateEgg extends CreateRecord
]), ]),
]), ]),
]), ]),
Tab::make(trans('admin/egg.tabs.install_script')) Tab::make('Install Script')
->columns(3) ->columns(3)
->schema([ ->schema([
CopyFrom::make('copy_script_from')
->script(), Hidden::make('copy_script_from'),
//->placeholder('None')
//->relationship('scriptFrom', 'name', ignoreRecord: true),
TextInput::make('script_container') TextInput::make('script_container')
->label(trans('admin/egg.script_container'))
->required() ->required()
->maxLength(255) ->maxLength(255)
->default('ghcr.io/pelican-eggs/installers:debian'), ->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry') Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->native(false)
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->default('bash') ->default('bash')
->options(['bash', 'ash', '/bin/bash']) ->options(['bash', 'ash', '/bin/bash'])
->required(), ->required(),
MonacoEditor::make('script_install') MonacoEditor::make('script_install')
->label(trans('admin/egg.script_install'))
->columnSpanFull() ->columnSpanFull()
->fontSize('16px') ->fontSize('16px')
->language('shell') ->language('shell')

View File

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

View File

@ -7,19 +7,15 @@ use App\Filament\Components\Actions\ImportEggAction as ImportEggHeaderAction;
use App\Filament\Components\Tables\Actions\ExportEggAction; use App\Filament\Components\Tables\Actions\ExportEggAction;
use App\Filament\Components\Tables\Actions\ImportEggAction; use App\Filament\Components\Tables\Actions\ImportEggAction;
use App\Filament\Components\Tables\Actions\UpdateEggAction; use App\Filament\Components\Tables\Actions\UpdateEggAction;
use App\Filament\Components\Tables\Actions\UpdateEggBulkAction;
use App\Filament\Components\Tables\Filters\TagsFilter;
use App\Models\Egg; use App\Models\Egg;
use Filament\Actions\CreateAction as CreateHeaderAction; use Filament\Actions\CreateAction as CreateHeaderAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction; use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ReplicateAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
class ListEggs extends ListRecords class ListEggs extends ListRecords
{ {
@ -30,12 +26,12 @@ class ListEggs extends ListRecords
return $table return $table
->searchable(true) ->searchable(true)
->defaultPaginationPageOption(25) ->defaultPaginationPageOption(25)
->checkIfRecordIsSelectableUsing(fn (Egg $egg) => $egg->servers_count <= 0)
->columns([ ->columns([
TextColumn::make('id') TextColumn::make('id')
->label('Id') ->label('Id')
->hidden(), ->hidden(),
TextColumn::make('name') TextColumn::make('name')
->label(trans('admin/egg.name'))
->icon('tabler-egg') ->icon('tabler-egg')
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description) ->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
->wrap() ->wrap()
@ -44,63 +40,35 @@ class ListEggs extends ListRecords
TextColumn::make('servers_count') TextColumn::make('servers_count')
->counts('servers') ->counts('servers')
->icon('tabler-server') ->icon('tabler-server')
->label(trans('admin/egg.servers')), ->label('Servers'),
]) ])
->actions([ ->actions([
EditAction::make() EditAction::make(),
->iconButton() ExportEggAction::make(),
->tooltip(trans('filament-actions::edit.single.label')), UpdateEggAction::make(),
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])),
]) ])
->groupedBulkActions([ ->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make() DeleteBulkAction::make()
->before(fn (DeleteBulkAction $action, Collection $records) => $action->records($records->filter(function ($egg) { ->authorize(fn () => auth()->user()->can('delete egg')),
/** @var Egg $egg */ ]),
return $egg->servers_count <= 0;
}))),
UpdateEggBulkAction::make()
->before(fn (UpdateEggBulkAction $action, Collection $records) => $action->records($records->filter(function ($egg) {
/** @var Egg $egg */
return cache()->get("eggs.$egg->uuid.update", false);
}))),
]) ])
->emptyStateIcon('tabler-eggs') ->emptyStateIcon('tabler-eggs')
->emptyStateDescription('') ->emptyStateDescription('')
->emptyStateHeading(trans('admin/egg.no_eggs')) ->emptyStateHeading('No Eggs')
->emptyStateActions([ ->emptyStateActions([
CreateAction::make(), CreateAction::make()
ImportEggAction::make() ->label('Create Egg'),
->multiple(), ImportEggAction::make(),
])
->filters([
TagsFilter::make()
->model(Egg::class),
]); ]);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
ImportEggHeaderAction::make() ImportEggHeaderAction::make(),
->multiple(), CreateHeaderAction::make()
CreateHeaderAction::make(), ->label('Create Egg'),
]; ];
} }
} }

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